diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index 5ed6fb12..faa473c2 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -1169,31 +1169,36 @@ export default class Interpreter extends EventEmitter { return; } - let pageState = {}; - try { - // Check if page is still valid before accessing state - if (p.isClosed()) { - this.log('Page was closed during execution', Level.WARN); - return; - } - - pageState = await this.getState(p, workflowCopy, selectors); - selectors = []; - console.log("Empty selectors:", selectors) - } catch (e: any) { - this.log(`Failed to get page state: ${e.message}`, Level.ERROR); - // If state access fails, attempt graceful recovery - if (p.isClosed()) { - this.log('Browser has been closed, terminating workflow', Level.WARN); - return; - } - // For other errors, continue with empty state to avoid complete failure - pageState = { url: p.url(), selectors: [], cookies: {} }; + if (workflowCopy.length === 0) { + this.log('All actions completed. Workflow finished.', Level.LOG); + return; } - if (this.options.debug) { - this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN); - } + // let pageState = {}; + // try { + // // Check if page is still valid before accessing state + // if (p.isClosed()) { + // this.log('Page was closed during execution', Level.WARN); + // return; + // } + + // pageState = await this.getState(p, workflowCopy, selectors); + // selectors = []; + // console.log("Empty selectors:", selectors) + // } catch (e: any) { + // this.log(`Failed to get page state: ${e.message}`, Level.ERROR); + // // If state access fails, attempt graceful recovery + // if (p.isClosed()) { + // this.log('Browser has been closed, terminating workflow', Level.WARN); + // return; + // } + // // For other errors, continue with empty state to avoid complete failure + // pageState = { url: p.url(), selectors: [], cookies: {} }; + // } + + // if (this.options.debug) { + // this.log(`Current state is: \n${JSON.stringify(pageState, null, 2)}`, Level.WARN); + // } // const actionId = workflow.findIndex((step) => { // const isApplicable = this.applicable(step.where, pageState, usedActions); @@ -1205,8 +1210,9 @@ export default class Interpreter extends EventEmitter { // return isApplicable; // }); - actionId = this.getMatchingActionId(workflowCopy, pageState, usedActions); + // actionId = this.getMatchingActionId(workflowCopy, pageState, usedActions); + const actionId = workflowCopy.length - 1; const action = workflowCopy[actionId]; console.log("MATCHED ACTION:", action); @@ -1235,12 +1241,12 @@ export default class Interpreter extends EventEmitter { console.log(`Action with ID ${action.id} removed from the workflow copy.`); // const newSelectors = this.getPreviousSelectors(workflow, actionId); - const newSelectors = this.getSelectors(workflowCopy); - newSelectors.forEach(selector => { - if (!selectors.includes(selector)) { - selectors.push(selector); - } - }); + // const newSelectors = this.getSelectors(workflowCopy); + // newSelectors.forEach(selector => { + // if (!selectors.includes(selector)) { + // selectors.push(selector); + // } + // }); // Reset loop iteration counter on successful action loopIterations = 0; diff --git a/public/locales/de.json b/public/locales/de.json index 19b37c7b..58d90cff 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -442,6 +442,9 @@ "created_at": "Erstellungsdatum des Roboters", "errors": { "robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut." + }, + "buttons": { + "close": "Schließen" } }, "robot_edit": { diff --git a/public/locales/en.json b/public/locales/en.json index 51aada6b..f900089f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -442,6 +442,9 @@ "created_at": "Robot Created At", "errors": { "robot_not_found": "Could not find robot details. Please try again." + }, + "buttons": { + "close": "Close" } }, "robot_edit": { diff --git a/public/locales/es.json b/public/locales/es.json index 92bf7fe3..880f3be9 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -460,6 +460,9 @@ "created_at": "Fecha de Creación del Robot", "errors": { "robot_not_found": "No se pudieron encontrar los detalles del robot. Inténtelo de nuevo." + }, + "buttons": { + "close": "Cerrar" } }, "robot_edit": { diff --git a/public/locales/ja.json b/public/locales/ja.json index 421bdecf..05071e8a 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -442,6 +442,9 @@ "created_at": "作成日時", "errors": { "robot_not_found": "ロボットの詳細が見つかりませんでした。もう一度試してください。" + }, + "buttons": { + "close": "閉じる" } }, "robot_edit": { diff --git a/public/locales/tr.json b/public/locales/tr.json index b4118ed0..db2f9ffd 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -442,6 +442,9 @@ "created_at": "Oluşturulma", "errors": { "robot_not_found": "Robot bulunamadı. Tekrar deneyin." + }, + "buttons": { + "close": "Kapat" } }, "robot_edit": { diff --git a/public/locales/zh.json b/public/locales/zh.json index c1e32760..abd5de9f 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -442,6 +442,9 @@ "created_at": "机器人创建时间", "errors": { "robot_not_found": "无法找到机器人详细信息。请重试。" + }, + "buttons": { + "close": "关闭" } }, "robot_edit": { diff --git a/server/src/browser-management/inputHandlers.ts b/server/src/browser-management/inputHandlers.ts index da48f4fd..36bfd1c6 100644 --- a/server/src/browser-management/inputHandlers.ts +++ b/server/src/browser-management/inputHandlers.ts @@ -234,11 +234,11 @@ const handleMousemove = async (activeBrowser: RemoteBrowser, page: Page, { x, y const generator = activeBrowser.generator; await page.mouse.move(x, y); - throttle(async () => { - if (!page.isClosed()) { - await generator.generateDataForHighlighter(page, { x, y }); - } - }, 100)(); + // throttle(async () => { + // if (!page.isClosed()) { + // await generator.generateDataForHighlighter(page, { x, y }); + // } + // }, 100)(); logger.log("debug", `Moved over position x:${x}, y:${y}`); } catch (e) { const { message } = e as Error; diff --git a/server/src/server.ts b/server/src/server.ts index ca2470c5..5b729b72 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -145,6 +145,22 @@ if (require.main === module) { io = new Server(server); + io.of('/queued-run').on('connection', (socket) => { + const userId = socket.handshake.query.userId as string; + + if (userId) { + socket.join(`user-${userId}`); + logger.log('info', `Client joined queued-run namespace for user: ${userId}, socket: ${socket.id}`); + + socket.on('disconnect', () => { + logger.log('info', `Client disconnected from queued-run namespace: ${socket.id}`); + }); + } else { + logger.log('warn', `Client connected to queued-run namespace without userId: ${socket.id}`); + socket.disconnect(); + } + }); + if (!isProduction) { if (process.platform === 'win32') { workerProcess = fork(workerPath, [], { diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index eb0e33a7..a5bc2edc 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -541,6 +541,10 @@ export class WorkflowGenerator { } if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) { + if (page.isClosed()) { + logger.log('debug', 'Page is closed, cannot get cursor position'); + return; + } const positionAndCursor = await page.evaluate( ({ selector, coords }) => { const getCursorPosition = (element: any, clickX: any) => { @@ -1342,4 +1346,4 @@ export class WorkflowGenerator { public clearLastIndex = () => { this.generatedData.lastIndex = null; } -} \ No newline at end of file +} diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index eddd1fb1..43c1d2f8 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -21,6 +21,10 @@ export const getElementInformation = async ( ) => { try { if (!getList || listSelector !== '') { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get element information'); + return null; + } const elementInfo = await page.evaluate( async ({ x, y }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { @@ -302,6 +306,10 @@ export const getElementInformation = async ( ); return elementInfo; } else { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get element information (else branch)'); + return null; + } const elementInfo = await page.evaluate( async ({ x, y }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { @@ -611,6 +619,10 @@ export const getElementInformation = async ( export const getRect = async (page: Page, coordinates: Coordinates, listSelector: string, getList: boolean) => { try { if (!getList || listSelector !== '') { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get element rect'); + return null; + } const rect = await page.evaluate( async ({ x, y }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { @@ -834,6 +846,10 @@ export const getRect = async (page: Page, coordinates: Coordinates, listSelector ); return rect; } else { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get element rect (else branch)'); + return null; + } const rect = await page.evaluate( async ({ x, y }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { @@ -1076,6 +1092,10 @@ export const getRect = async (page: Page, coordinates: Coordinates, listSelector */ export const getSelectors = async (page: Page, coordinates: Coordinates) => { try { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get selectors'); + return null; + } const selectors: any = await page.evaluate(async ({ x, y }) => { // version @medv/finder // https://github.com/antonmedv/finder/blob/master/finder.ts @@ -2010,6 +2030,10 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates try { if (!listSelector) { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get non-unique selectors'); + return { generalSelector: '' }; + } const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { let elements = document.elementsFromPoint(x, y) as HTMLElement[]; @@ -2364,6 +2388,10 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates return selectors || { generalSelector: '' }; } else { // When we have a list selector, we need special handling while maintaining shadow DOM and frame support + if (page.isClosed()) { + logger.debug('Page is closed, cannot get list selectors'); + return { generalSelector: '' }; + } const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { const getDeepestElementFromPoint = (x: number, y: number): HTMLElement | null => { let elements = document.elementsFromPoint(x, y) as HTMLElement[]; @@ -2725,6 +2753,10 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates export const getChildSelectors = async (page: Page, parentSelector: string): Promise => { try { + if (page.isClosed()) { + logger.debug('Page is closed, cannot get child selectors'); + return []; + } const childSelectors = await page.evaluate((parentSelector: string) => { // Function to get a non-unique selector based on tag and class (if present) function getNonUniqueSelector(element: HTMLElement): string { diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx index a3db4f7a..6f463ba4 100644 --- a/src/components/robot/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -2,11 +2,14 @@ import React, { useEffect, useState } from "react"; import { RecordingsTable } from "./RecordingsTable"; import { Grid } from "@mui/material"; import { RunSettings, RunSettingsModal } from "../run/RunSettings"; -import { ScheduleSettings, ScheduleSettingsModal } from "./ScheduleSettings"; -import { IntegrationSettingsModal } from "../integration/IntegrationSettings"; -import { RobotSettingsModal } from "./RobotSettings"; -import { RobotEditModal } from "./RobotEdit"; -import { RobotDuplicationModal } from "./RobotDuplicate"; +import { + ScheduleSettings, + ScheduleSettingsPage, +} from "./pages/ScheduleSettingsPage"; +import { RobotIntegrationPage } from "./pages/RobotIntegrationPage"; +import { RobotSettingsPage } from "./pages/RobotSettingsPage"; +import { RobotEditPage } from "./pages/RobotEditPage"; +import { RobotDuplicatePage } from "./pages/RobotDuplicatePage"; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useTranslation } from "react-i18next"; @@ -26,12 +29,16 @@ export const Recordings = ({ }: RecordingsProps) => { const navigate = useNavigate(); const location = useLocation(); - const { selectedRecordingId } = useParams(); const [params, setParams] = useState([]); const { notify } = useGlobalInfoStore(); const { t } = useTranslation(); - const handleNavigate = (path: string, id: string, name: string, params: string[]) => { + const handleNavigate = ( + path: string, + id: string, + name: string, + params: string[] + ) => { setParams(params); setRecordingInfo(id, name); navigate(path); @@ -47,35 +54,36 @@ export const Recordings = ({ // Helper function to get and clear a cookie const getAndClearCookie = (name: string) => { const value = document.cookie - .split('; ') - .find(row => row.startsWith(`${name}=`)) - ?.split('=')[1]; - + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; + if (value) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; } - + return value; }; - const authStatus = getAndClearCookie('robot_auth_status'); - const airtableAuthStatus = getAndClearCookie('airtable_auth_status'); - const robotId = getAndClearCookie('robot_auth_robotId'); + const authStatus = getAndClearCookie("robot_auth_status"); + const airtableAuthStatus = getAndClearCookie("airtable_auth_status"); + const robotId = getAndClearCookie("robot_auth_robotId"); - if (airtableAuthStatus === 'success' && robotId) { + if (airtableAuthStatus === "success" && robotId) { console.log("Airtable Auth Status:", airtableAuthStatus); - notify(airtableAuthStatus, t("recordingtable.notifications.auth_success")); + notify( + airtableAuthStatus, + t("recordingtable.notifications.auth_success") + ); handleNavigate(`/robots/${robotId}/integrate/airtable`, robotId, "", []); - } - else if (authStatus === 'success' && robotId) { + } else if (authStatus === "success" && robotId) { console.log("Google Auth Status:", authStatus); notify(authStatus, t("recordingtable.notifications.auth_success")); handleNavigate(`/robots/${robotId}/integrate/google`, robotId, "", []); } }, []); - // Determine which modal to open based on the current route - const getCurrentModal = () => { + const getCurrentPageComponent = () => { const currentPath = location.pathname; if (currentPath.endsWith("/run")) { @@ -89,79 +97,36 @@ export const Recordings = ({ /> ); } else if (currentPath.endsWith("/schedule")) { + return ; + } else if (currentPath.includes("/integrate")) { return ( - - ); - } else if (currentPath.endsWith("/integrate/google")) { - return ( - {}} - preSelectedIntegrationType="googleSheets" - /> - ); - } else if (currentPath.endsWith("/integrate/airtable")) { - return ( - {}} - preSelectedIntegrationType="airtable" - /> - ); - } else if (currentPath.endsWith("/integrate/webhook")) { - return ( - {}} - preSelectedIntegrationType="webhook" - /> - ); - } else if (currentPath.endsWith("/integrate")) { - return ( - {}} - /> + {}} robotPath={"robots"} /> ); } else if (currentPath.endsWith("/settings")) { - return ( - {}} - /> - ); + return {}} />; } else if (currentPath.endsWith("/edit")) { - return ( - {}} - /> - ); + return {}} />; } else if (currentPath.endsWith("/duplicate")) { - return ( - {}} - /> - ); + return {}} />; } return null; }; + const currentPath = location.pathname; + const isConfigPage = + currentPath.includes("/schedule") || + currentPath.includes("/integrate") || + currentPath.includes("/settings") || + currentPath.includes("/edit") || + currentPath.includes("/duplicate") || + currentPath.includes("/run"); + + if (isConfigPage) { + return getCurrentPageComponent(); + } + return ( - {getCurrentModal()} void; + onCancel?: () => void; + saveButtonText?: string; + cancelButtonText?: string; + showSaveButton?: boolean; + showCancelButton?: boolean; + isLoading?: boolean; + icon?: React.ReactNode; + onBackToSelection?: () => void; + backToSelectionText?: string; + onArrowBack?: () => void; // Optional prop for custom back action +} + +export const RobotConfigPage: React.FC = ({ + title, + children, + onSave, + onCancel, + saveButtonText, + cancelButtonText, + showSaveButton = true, + showCancelButton = true, + isLoading = false, + icon, + onBackToSelection, + backToSelectionText, + onArrowBack, +}) => { + const navigate = useNavigate(); + const location = useLocation(); + const theme = useTheme(); + const { t } = useTranslation(); + + const handleBack = () => { + if (onCancel) { + onCancel(); + } else { + // Try to determine the correct path based on current URL + const currentPath = location.pathname; + const basePath = currentPath.includes('/prebuilt-robots') ? '/prebuilt-robots' : '/robots'; + navigate(basePath); + } + }; + + return ( + + {/* Header Section - Fixed Position */} + + + + + {icon && ( + + {icon} + + )} + + {title} + + + + + {/* Content Section */} + + {children} + + + {/* Action Buttons */} + {(showSaveButton || showCancelButton || onBackToSelection) && ( + + {/* Left side - Back to Selection button */} + {onBackToSelection && ( + + )} + + {/* Right side - Save/Cancel buttons */} + + {showCancelButton && ( + + )} + {showSaveButton && onSave && ( + + )} + + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/robot/pages/RobotDuplicatePage.tsx b/src/components/robot/pages/RobotDuplicatePage.tsx new file mode 100644 index 00000000..b02cecde --- /dev/null +++ b/src/components/robot/pages/RobotDuplicatePage.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from "react"; +import { + TextField, + Typography, + Box, + Button, +} from "@mui/material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { + duplicateRecording, + getStoredRecording, + getStoredRecordings, +} from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { useTranslation } from "react-i18next"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + prebuiltId?: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + type?: string; + description?: string; + usedByUsers?: number[]; + subscriptionLevel?: number; + access?: string; + sample?: any[]; + url?: string; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: "MINUTES" | "HOURS" | "DAYS" | "WEEKS" | "MONTHS"; + startFrom: + | "SUNDAY" + | "MONDAY" + | "TUESDAY" + | "WEDNESDAY" + | "THURSDAY" + | "FRIDAY" + | "SATURDAY"; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + handleStart: (settings: RobotSettings) => void; +} + +export const RobotDuplicatePage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [targetUrl, setTargetUrl] = useState(""); + const [robot, setRobot] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { recordingId, notify, setRerenderRobots} = + useGlobalInfoStore(); + + useEffect(() => { + getRobot(); + }, []); + + useEffect(() => { + if (robot) { + let url = robot.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + setTargetUrl(url); + } + }, [robot]); + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_duplication.notifications.robot_not_found")); + } + } else { + notify("error", t("robot_duplication.notifications.robot_not_found")); + } + }; + + const handleTargetUrlChange = (e: React.ChangeEvent) => { + setTargetUrl(e.target.value); + }; + + const handleSave = async () => { + if (!robot || !targetUrl) { + notify("error", t("robot_duplication.notifications.url_required")); + return; + } + + setIsLoading(true); + try { + const success = await duplicateRecording( + robot.recording_meta.id, + targetUrl + ); + + if (success) { + setRerenderRobots(true); + notify( + "success", + t("robot_duplication.notifications.duplicate_success") + ); + handleStart(robot); + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + } else { + notify("error", t("robot_duplication.notifications.duplicate_error")); + } + } catch (error) { + notify("error", t("robot_duplication.notifications.unknown_error")); + console.error("Error updating Target URL:", error); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + }; + + return ( + + <> + + {robot && ( + <> + {t("robot_duplication.descriptions.purpose")} +
+ producthunt.com/topics/api", + url2: "producthunt.com/topics/database", + }), + }} + /> +
+ + {t("robot_duplication.descriptions.warning")} + + + + )} +
+ +
+ ); +}; \ No newline at end of file diff --git a/src/components/robot/pages/RobotEditPage.tsx b/src/components/robot/pages/RobotEditPage.tsx new file mode 100644 index 00000000..bce3a5ea --- /dev/null +++ b/src/components/robot/pages/RobotEditPage.tsx @@ -0,0 +1,656 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + TextField, + Typography, + Box, + Button, + IconButton, + InputAdornment, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording, updateRecording } from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + prebuiltId?: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + type?: string; + description?: string; + usedByUsers?: number[]; + subscriptionLevel?: number; + access?: string; + sample?: any[]; + url?: string; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: "MINUTES" | "HOURS" | "DAYS" | "WEEKS" | "MONTHS"; + startFrom: + | "SUNDAY" + | "MONDAY" + | "TUESDAY" + | "WEDNESDAY" + | "THURSDAY" + | "FRIDAY" + | "SATURDAY"; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + handleStart: (settings: RobotSettings) => void; +} + +interface CredentialInfo { + value: string; + type: string; +} + +interface Credentials { + [key: string]: CredentialInfo; +} + +interface CredentialVisibility { + [key: string]: boolean; +} + +interface GroupedCredentials { + passwords: string[]; + emails: string[]; + usernames: string[]; + others: string[]; +} + +interface ScrapeListLimit { + pairIndex: number; + actionIndex: number; + argIndex: number; + currentLimit: number; +} + +export const RobotEditPage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [credentials, setCredentials] = useState({}); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); + const [robot, setRobot] = useState(null); + const [credentialGroups, setCredentialGroups] = useState({ + passwords: [], + emails: [], + usernames: [], + others: [], + }); + const [showPasswords, setShowPasswords] = useState({}); + const [scrapeListLimits, setScrapeListLimits] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(false); + + const isEmailPattern = (value: string): boolean => { + return value.includes("@"); + }; + + const isUsernameSelector = (selector: string): boolean => { + return ( + selector.toLowerCase().includes("username") || + selector.toLowerCase().includes("user") || + selector.toLowerCase().includes("email") + ); + }; + + const determineCredentialType = ( + selector: string, + info: CredentialInfo + ): "password" | "email" | "username" | "other" => { + if ( + info.type === "password" || + selector.toLowerCase().includes("password") + ) { + return "password"; + } + if ( + isEmailPattern(info.value) || + selector.toLowerCase().includes("email") + ) { + return "email"; + } + if (isUsernameSelector(selector)) { + return "username"; + } + return "other"; + }; + + useEffect(() => { + getRobot(); + }, []); + + useEffect(() => { + if (robot?.recording?.workflow) { + const extractedCredentials = extractInitialCredentials( + robot.recording.workflow + ); + setCredentials(extractedCredentials); + setCredentialGroups(groupCredentialsByType(extractedCredentials)); + + findScrapeListLimits(robot.recording.workflow); + } + }, [robot]); + + const findScrapeListLimits = (workflow: WhereWhatPair[]) => { + const limits: ScrapeListLimit[] = []; + + workflow.forEach((pair, pairIndex) => { + if (!pair.what) return; + + pair.what.forEach((action, actionIndex) => { + if ( + action.action === "scrapeList" && + action.args && + action.args.length > 0 + ) { + // Check if first argument has a limit property + const arg = action.args[0]; + if (arg && typeof arg === "object" && "limit" in arg) { + limits.push({ + pairIndex, + actionIndex, + argIndex: 0, + currentLimit: arg.limit, + }); + } + } + }); + }); + + setScrapeListLimits(limits); + }; + + function extractInitialCredentials(workflow: any[]): Credentials { + const credentials: Credentials = {}; + + const isPrintableCharacter = (char: string): boolean => { + return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); + }; + + workflow.forEach((step) => { + if (!step.what) return; + + let currentSelector = ""; + let currentValue = ""; + let currentType = ""; + let i = 0; + + while (i < step.what.length) { + const action = step.what[i]; + + if (!action.action || !action.args?.[0]) { + i++; + continue; + } + + const selector = action.args[0]; + + // Handle full word type actions first + if ( + action.action === "type" && + action.args?.length >= 2 && + typeof action.args[1] === "string" && + action.args[1].length > 1 + ) { + if (!credentials[selector]) { + credentials[selector] = { + value: action.args[1], + type: action.args[2] || "text", + }; + } + i++; + continue; + } + + // Handle character-by-character sequences (both type and press) + if ( + (action.action === "type" || action.action === "press") && + action.args?.length >= 2 && + typeof action.args[1] === "string" + ) { + if (selector !== currentSelector) { + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || "text", + }; + } + currentSelector = selector; + currentValue = credentials[selector]?.value || ""; + currentType = + action.args[2] || credentials[selector]?.type || "text"; + } + + const character = action.args[1]; + + if (isPrintableCharacter(character)) { + currentValue += character; + } else if (character === "Backspace") { + currentValue = currentValue.slice(0, -1); + } + + if (!currentType && action.args[2]?.toLowerCase() === "password") { + currentType = "password"; + } + + let j = i + 1; + while (j < step.what.length) { + const nextAction = step.what[j]; + if ( + !nextAction.action || + !nextAction.args?.[0] || + nextAction.args[0] !== selector || + (nextAction.action !== "type" && nextAction.action !== "press") + ) { + break; + } + if (nextAction.args[1] === "Backspace") { + currentValue = currentValue.slice(0, -1); + } else if (isPrintableCharacter(nextAction.args[1])) { + currentValue += nextAction.args[1]; + } + j++; + } + + credentials[currentSelector] = { + value: currentValue, + type: currentType, + }; + + i = j; + } else { + i++; + } + } + + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || "text", + }; + } + }); + + return credentials; + } + + const groupCredentialsByType = ( + credentials: Credentials + ): GroupedCredentials => { + return Object.entries(credentials).reduce( + (acc: GroupedCredentials, [selector, info]) => { + const credentialType = determineCredentialType(selector, info); + + switch (credentialType) { + case "password": + acc.passwords.push(selector); + break; + case "email": + acc.emails.push(selector); + break; + case "username": + acc.usernames.push(selector); + break; + default: + acc.others.push(selector); + } + + return acc; + }, + { passwords: [], emails: [], usernames: [], others: [] } + ); + }; + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_edit.notifications.update_failed")); + } + } else { + notify("error", t("robot_edit.notifications.update_failed")); + } + }; + + const handleClickShowPassword = (selector: string) => { + setShowPasswords((prev) => ({ + ...prev, + [selector]: !prev[selector], + })); + }; + + const handleRobotNameChange = (newName: string) => { + setRobot((prev) => + prev + ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } + : prev + ); + }; + + const handleCredentialChange = (selector: string, value: string) => { + setCredentials((prev) => ({ + ...prev, + [selector]: { + ...prev[selector], + value, + }, + })); + }; + + const handleLimitChange = ( + pairIndex: number, + actionIndex: number, + argIndex: number, + newLimit: number + ) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + if ( + updatedWorkflow.length > pairIndex && + updatedWorkflow[pairIndex]?.what && + updatedWorkflow[pairIndex].what.length > actionIndex && + updatedWorkflow[pairIndex].what[actionIndex].args && + updatedWorkflow[pairIndex].what[actionIndex].args.length > argIndex + ) { + updatedWorkflow[pairIndex].what[actionIndex].args[argIndex].limit = + newLimit; + + setScrapeListLimits((prev) => { + return prev.map((item) => { + if ( + item.pairIndex === pairIndex && + item.actionIndex === actionIndex && + item.argIndex === argIndex + ) { + return { ...item, currentLimit: newLimit }; + } + return item; + }); + }); + } + + return { + ...prev, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; + + const handleTargetUrlChange = (newUrl: string) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + const lastPairIndex = updatedWorkflow.length - 1; + + if (lastPairIndex >= 0) { + const gotoAction = updatedWorkflow[lastPairIndex]?.what?.find( + (action) => action.action === "goto" + ); + if (gotoAction && gotoAction.args && gotoAction.args.length > 0) { + gotoAction.args[0] = newUrl; + } + } + + return { + ...prev, + recording_meta: { ...prev.recording_meta, url: newUrl }, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; + + const renderAllCredentialFields = () => { + return ( + <> + {renderCredentialFields( + credentialGroups.usernames, + t("Username"), + "text" + )} + + {renderCredentialFields(credentialGroups.emails, t("Email"), "text")} + + {renderCredentialFields( + credentialGroups.passwords, + t("Password"), + "password" + )} + + {renderCredentialFields(credentialGroups.others, t("Other"), "text")} + + ); + }; + + const renderScrapeListLimitFields = () => { + if (scrapeListLimits.length === 0) return null; + + return ( + <> + + {t("List Limits")} + + + {scrapeListLimits.map((limitInfo, index) => ( + { + const value = parseInt(e.target.value, 10); + if (value >= 1) { + handleLimitChange( + limitInfo.pairIndex, + limitInfo.actionIndex, + limitInfo.argIndex, + value + ); + } + }} + inputProps={{ min: 1 }} + style={{ marginBottom: "20px" }} + /> + ))} + + ); + }; + + const renderCredentialFields = ( + selectors: string[], + headerText: string, + defaultType: "text" | "password" = "text" + ) => { + if (selectors.length === 0) return null; + + return ( + <> + {selectors.map((selector, index) => { + const isVisible = showPasswords[selector]; + + return ( + handleCredentialChange(selector, e.target.value)} + fullWidth + style={{ marginBottom: "20px" }} + InputProps={{ + endAdornment: ( + + handleClickShowPassword(selector)} + edge="end" + disabled={!credentials[selector]?.value} + > + {isVisible ? : } + + + ), + }} + /> + ); + })} + + ); + }; + + const getTargetUrl = () => { + let url = robot?.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + return url; + }; + + const handleSave = async () => { + if (!robot) return; + + setIsLoading(true); + try { + const credentialsForPayload = Object.entries(credentials).reduce( + (acc, [selector, info]) => { + const enforceType = info.type === "password" ? "password" : "text"; + + acc[selector] = { + value: info.value, + type: enforceType, + }; + return acc; + }, + {} as Record + ); + + const targetUrl = getTargetUrl(); + + const payload = { + name: robot.recording_meta.name, + limits: scrapeListLimits.map((limit) => ({ + pairIndex: limit.pairIndex, + actionIndex: limit.actionIndex, + argIndex: limit.argIndex, + limit: limit.currentLimit, + })), + credentials: credentialsForPayload, + targetUrl: targetUrl, + }; + + const success = await updateRecording(robot.recording_meta.id, payload); + + if (success) { + setRerenderRobots(true); + notify("success", t("robot_edit.notifications.update_success")); + handleStart(robot); + const basePath = "/robots"; + navigate(basePath); + } else { + notify("error", t("robot_edit.notifications.update_failed")); + } + } catch (error) { + notify("error", t("robot_edit.notifications.update_error")); + console.error("Error updating robot:", error); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + const basePath = "/robots"; + navigate(basePath); + }; + + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + const targetUrl = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + + return ( + + <> + + {robot && ( + <> + handleRobotNameChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + handleTargetUrlChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + {renderScrapeListLimitFields()} + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/robot/pages/RobotIntegrationPage.tsx b/src/components/robot/pages/RobotIntegrationPage.tsx new file mode 100644 index 00000000..9b941a3d --- /dev/null +++ b/src/components/robot/pages/RobotIntegrationPage.tsx @@ -0,0 +1,868 @@ +import React, { useState, useEffect } from "react"; +import { + MenuItem, + Typography, + CircularProgress, + Alert, + AlertTitle, + Button, + TextField, + IconButton, + Box, + Chip, + Card, + CardContent, + CardActions, + Switch, + FormControlLabel, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Science as ScienceIcon, +} from "@mui/icons-material"; +import axios from "axios"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording } from "../../../api/storage"; +import { apiUrl } from "../../../apiConfig.js"; +import { v4 as uuid } from "uuid"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams, useLocation } from "react-router-dom"; +import { + addWebhook, + updateWebhook, + removeWebhook, + getWebhooks, + testWebhook, + WebhookConfig, +} from "../../../api/webhook"; +import { RobotConfigPage } from "./RobotConfigPage"; + +interface IntegrationProps { + handleStart: (data: IntegrationSettings) => void; + robotPath?: string; + preSelectedIntegrationType?: "googleSheets" | "airtable" | "webhook" | null; +} + +export interface IntegrationSettings { + spreadsheetId?: string; + spreadsheetName?: string; + airtableBaseId?: string; + airtableBaseName?: string; + airtableTableName?: string; + airtableTableId?: string; + webhooks?: WebhookConfig[]; + data: string; + integrationType: "googleSheets" | "airtable" | "webhook"; +} + +export const RobotIntegrationPage = ({ + handleStart, + robotPath = "robots", + preSelectedIntegrationType = null, +}: IntegrationProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + const pathSegments = location.pathname.split('/'); + const robotsIndex = pathSegments.findIndex(segment => segment === 'robots' || segment === 'prebuilt-robots'); + const integrateIndex = pathSegments.findIndex(segment => segment === 'integrate'); + + const robotIdFromUrl = robotsIndex !== -1 && robotsIndex + 1 < pathSegments.length + ? pathSegments[robotsIndex + 1] + : null; + + const integrationType = integrateIndex !== -1 && integrateIndex + 1 < pathSegments.length + ? pathSegments[integrateIndex + 1] as "googleSheets" | "airtable" | "webhook" + : preSelectedIntegrationType || null; + + const [settings, setSettings] = useState({ + spreadsheetId: "", + spreadsheetName: "", + airtableBaseId: "", + airtableBaseName: "", + airtableTableName: "", + airtableTableId: "", + webhooks: [], + data: "", + integrationType: integrationType || "airtable", + }); + + const [spreadsheets, setSpreadsheets] = useState<{ id: string; name: string }[]>([]); + const [airtableBases, setAirtableBases] = useState<{ id: string; name: string }[]>([]); + const [airtableTables, setAirtableTables] = useState<{ id: string; name: string }[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [showWebhookForm, setShowWebhookForm] = useState(false); + const [editingWebhook, setEditingWebhook] = useState(null); + const [newWebhook, setNewWebhook] = useState({ + id: "", + url: "", + events: ["run_completed"], + active: true, + }); + const [urlError, setUrlError] = useState(null); + + const { recordingId: recordingIdFromStore, notify, setRerenderRobots, setRecordingId } = useGlobalInfoStore(); + + const recordingId = robotIdFromUrl || recordingIdFromStore; + + useEffect(() => { + if (robotIdFromUrl && robotIdFromUrl !== recordingIdFromStore) { + setRecordingId(robotIdFromUrl); + } + }, [robotIdFromUrl, recordingIdFromStore, setRecordingId]); + + const [recording, setRecording] = useState(null); + const [selectedIntegrationType, setSelectedIntegrationType] = useState< + "googleSheets" | "airtable" | "webhook" | null + >(integrationType); + + const authenticateWithGoogle = () => { + if (!recordingId) { + console.error("Cannot authenticate: recordingId is null"); + return; + } + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + const redirectUrl = `${window.location.origin}${basePath}/${recordingId}/integrate/googleSheets`; + window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}&redirectUrl=${encodeURIComponent(redirectUrl)}`; + }; + + const authenticateWithAirtable = () => { + if (!recordingId) { + console.error("Cannot authenticate: recordingId is null"); + return; + } + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + const redirectUrl = `${window.location.origin}${basePath}/${recordingId}/integrate/airtable`; + window.location.href = `${apiUrl}/auth/airtable?robotId=${recordingId}&redirectUrl=${encodeURIComponent(redirectUrl)}`; + }; + + const validateWebhookData = ( + url: string, + events: string[], + excludeId?: string + ) => { + if (!url) { + setUrlError("Please provide webhook URL"); + return false; + } + try { + new URL(url); + } catch { + setUrlError("Please provide a valid URL"); + return false; + } + const existingWebhook = settings.webhooks?.find( + (webhook) => webhook.url === url && webhook.id !== excludeId + ); + if (existingWebhook) { + setUrlError("This webhook URL is already in use"); + return false; + } + if (!events || events.length === 0) { + setUrlError("Please select at least one event"); + return false; + } + setUrlError(null); + return true; + }; + + const fetchWebhooks = async () => { + try { + setLoading(true); + if (!recordingId) return; + const response = await getWebhooks(recordingId); + if (response.ok && response.webhooks) { + setSettings((prev) => ({ ...prev, webhooks: response.webhooks })); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching webhooks:", error); + } + }; + + const addWebhookSetting = async () => { + if (!validateWebhookData(newWebhook.url, newWebhook.events)) { + if (!newWebhook.url) notify("error", "Please provide webhook URL"); + else if (!newWebhook.events || newWebhook.events.length === 0) notify("error", "Please select at least one event"); + return; + } + if (!recordingId) return; + try { + setLoading(true); + const webhookWithId = { ...newWebhook, id: uuid() }; + const response = await addWebhook(webhookWithId, recordingId); + if (response.ok) { + setSettings((prev) => ({ ...prev, webhooks: [...(prev.webhooks || []), webhookWithId] })); + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setShowWebhookForm(false); + notify("success", "Webhook added successfully"); + } else { + notify("error", response.message || "Failed to add webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to add webhook"); + console.error("Error adding webhook:", error); + } + }; + + const updateWebhookSetting = async () => { + if (!validateWebhookData(newWebhook.url, newWebhook.events, editingWebhook || undefined)) return; + if (!recordingId || !editingWebhook) return; + try { + setLoading(true); + const response = await updateWebhook({ ...newWebhook, id: editingWebhook }, recordingId); + if (response.ok) { + setSettings((prev) => ({ + ...prev, + webhooks: (prev.webhooks || []).map((webhook) => + webhook.id === editingWebhook ? { ...newWebhook, id: editingWebhook } : webhook + ), + })); + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setEditingWebhook(null); + setShowWebhookForm(false); + notify("success", "Webhook updated successfully"); + } else { + notify("error", response.message || "Failed to update webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to update webhook"); + console.error("Error updating webhook:", error); + } + }; + + const deleteWebhookSetting = async (webhookId: string) => { + if (!recordingId) return; + try { + setLoading(true); + const response = await removeWebhook(webhookId, recordingId); + if (response.ok) { + setSettings((prev) => ({ ...prev, webhooks: (prev.webhooks || []).filter((webhook) => webhook.id !== webhookId) })); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", "Webhook removed successfully"); + } else { + notify("error", response.error || "Failed to remove webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to remove webhook"); + console.error("Error removing webhook:", error); + } + }; + + const testWebhookSetting = async (webhookId: string) => { + if (!recordingId) return; + const webhook = settings.webhooks?.find(w => w.id === webhookId); + if (!webhook) return; + try { + setLoading(true); + const response = await testWebhook(webhook, recordingId); + if (response.ok) { + notify("success", "Test webhook sent successfully"); + } else { + notify("error", response.message || "Failed to test webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to test webhook"); + console.error("Error testing webhook:", error); + } + }; + + useEffect(() => { + setSelectedIntegrationType(integrationType); + setSettings(prev => ({ ...prev, integrationType: integrationType || "airtable" })); + }, [integrationType]); + + useEffect(() => { + const fetchRecording = async () => { + if (recordingId) { + try { + const recordingData = await getStoredRecording(recordingId); + setRecording(recordingData); + } catch (error) { + console.error("Failed to fetch recording:", error); + } + } + }; + fetchRecording(); + if (selectedIntegrationType === "webhook") { + fetchWebhooks(); + } + }, [recordingId, selectedIntegrationType]); + + const handleCancel = () => { + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + navigate(basePath); + }; + + const fetchSpreadsheetFiles = async () => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`, { withCredentials: true }); + setSpreadsheets(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching spreadsheet files:", error); + notify("error", t("integration_settings.google.errors.fetch_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const handleSpreadsheetSelect = (e: React.ChangeEvent) => { + const selectedSheet = spreadsheets.find((sheet) => sheet.id === e.target.value); + if (selectedSheet) { + setSettings({ ...settings, spreadsheetId: selectedSheet.id, spreadsheetName: selectedSheet.name }); + } + }; + + const updateGoogleSheetId = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/gsheets/update`, { + spreadsheetId: settings.spreadsheetId, + spreadsheetName: settings.spreadsheetName, + robotId: recordingId, + }, { withCredentials: true }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.google.notifications.sheet_selected")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error updating Google Sheet ID:", error); + notify("error", t("integration_settings.google.errors.update_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const removeGoogleSheetsIntegration = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/gsheets/remove`, { robotId: recordingId }, { withCredentials: true }); + setSpreadsheets([]); + setSettings({ ...settings, spreadsheetId: "", spreadsheetName: "" }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.google.notifications.integration_removed")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error removing Google Sheets integration:", error); + notify("error", t("integration_settings.google.errors.remove_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const fetchAirtableBases = async () => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/airtable/bases?robotId=${recordingId}`, { withCredentials: true }); + setAirtableBases(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching Airtable bases:", error); + notify("error", t("integration_settings.airtable.errors.fetch_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const fetchAirtableTables = async (baseId: string, recordingId: string) => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/airtable/tables?robotId=${recordingId}&baseId=${baseId}`, { withCredentials: true }); + setAirtableTables(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching Airtable tables:", error); + notify("error", t("integration_settings.airtable.errors.fetch_tables_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const handleAirtableBaseSelect = async (e: React.ChangeEvent) => { + const selectedBase = airtableBases.find((base) => base.id === e.target.value); + if (selectedBase) { + setSettings((prevSettings) => ({ ...prevSettings, airtableBaseId: selectedBase.id, airtableBaseName: selectedBase.name })); + if (recordingId) await fetchAirtableTables(selectedBase.id, recordingId); + } + }; + + const handleAirtabletableSelect = (e: React.ChangeEvent) => { + const selectedTable = airtableTables.find((table) => table.id === e.target.value); + if (selectedTable) { + setSettings((prevSettings) => ({ ...prevSettings, airtableTableId: e.target.value, airtableTableName: selectedTable?.name || "" })); + } + }; + + const updateAirtableBase = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/airtable/update`, { + baseId: settings.airtableBaseId, + baseName: settings.airtableBaseName, + robotId: recordingId, + tableName: settings.airtableTableName, + tableId: settings.airtableTableId, + }, { withCredentials: true }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.airtable.notifications.base_selected")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error updating Airtable base:", error); + notify("error", t("integration_settings.airtable.errors.update_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const removeAirtableIntegration = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/airtable/remove`, { robotId: recordingId }, { withCredentials: true }); + setAirtableBases([]); + setAirtableTables([]); + setSettings({ ...settings, airtableBaseId: "", airtableBaseName: "", airtableTableName: "", airtableTableId: "" }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.airtable.notifications.integration_removed")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error removing Airtable integration:", error); + notify("error", t("integration_settings.airtable.errors.remove_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const renderGoogleSheetsIntegration = () => ( + <> + + {t("integration_settings.google.title")} + + {recording?.google_sheet_id ? ( + <> + + {t("integration_settings.google.alerts.success.title")} + {t("integration_settings.google.alerts.success.content", { sheetName: recording.google_sheet_name })} + + {t("integration_settings.google.alerts.success.here")} + + + + + ) : ( + <> + {!recording?.google_sheet_email ? ( + <> +

{t("integration_settings.google.descriptions.sync_info")}

+ + + ) : ( + <> + + {t("integration_settings.google.descriptions.authenticated_as", { email: recording.google_sheet_email })} + + {loading ? ( + + ) : error ? ( + {error} + ) : spreadsheets.length === 0 ? ( + + ) : ( + <> + + {spreadsheets.map((sheet) => ({sheet.name}))} + + + + )} + + )} + + )} + + ); + + const renderAirtableIntegration = () => ( + <> + + {t("integration_settings.airtable.title")} + + {recording?.airtable_base_id ? ( + <> + + {t("integration_settings.airtable.alerts.success.title")} + {t("integration_settings.airtable.alerts.success.content", { baseName: recording.airtable_base_name, tableName: recording.airtable_table_name })} + + {t("integration_settings.airtable.alerts.success.here")} + + + + + ) : ( + <> + {!recording?.airtable_access_token ? ( + <> +

{t("integration_settings.airtable.descriptions.sync_info")}

+ + + ) : ( + <> + + {t("integration_settings.airtable.descriptions.authenticated_as")} + + {loading ? ( + + ) : error ? ( + {error} + ) : airtableBases.length === 0 ? ( + + + + + ) : ( + <> + + {airtableBases.map((base) => ({base.name}))} + + + {airtableTables.map((table) => ({table.name}))} + + + + )} + + )} + + )} + + ); + + const getIntegrationTitle = () => { + switch (selectedIntegrationType) { + case "googleSheets": + return "Google Sheets Integration"; + case "airtable": + return "Airtable Integration"; + case "webhook": + return "Webhook Integration"; + default: + return "Integration"; + } + }; + + const editWebhookSetting = (webhook: WebhookConfig) => { + setNewWebhook(webhook); + setEditingWebhook(webhook.id); + setShowWebhookForm(true); + }; + + const resetWebhookForm = () => { + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setShowWebhookForm(false); + setEditingWebhook(null); + setUrlError(null); + }; + + const toggleWebhookStatusSetting = async (webhookId: string) => { + if (!recordingId) return; + try { + const webhook = settings.webhooks?.find((w) => w.id === webhookId); + if (!webhook) return; + const updatedWebhook = { ...webhook, active: !webhook.active }; + const response = await updateWebhook(updatedWebhook, recordingId); + if (response.ok) { + const updatedWebhooks = (settings.webhooks || []).map((w) => w.id === webhookId ? updatedWebhook : w); + setSettings({ ...settings, webhooks: updatedWebhooks }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", `Webhook ${updatedWebhook.active ? "enabled" : "disabled"}`); + } else { + notify("error", response.message || "Failed to update webhook"); + } + } catch (error: any) { + console.error("Error toggling webhook status:", error); + notify("error", "Failed to update webhook"); + } + }; + + const formatEventName = (event: string) => { + switch (event) { + case "run_completed": return "Run finished"; + case "run_failed": return "Run failed"; + default: return event; + } + }; + + const formatLastCalled = (lastCalledAt?: string | null) => { + if (!lastCalledAt) return "Not called yet"; + const date = new Date(lastCalledAt); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + if (diffMinutes < 1) return "Just now"; + else if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`; + else if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + else if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + else return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + }; + + const handleBack = () => { + if (!recordingId) { + console.error("Cannot navigate: recordingId is null"); + return; + } + + setSelectedIntegrationType(null); + setSettings({ ...settings, integrationType: "airtable" }); + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + navigate(`${basePath}/${recordingId}/integrate`); + }; + + // --- MAIN RENDER --- + if (!selectedIntegrationType && !integrationType) { + return ( + +
+
+
+ + + + +
+
+
+
+ ); + } + + return ( + +
+
+ {(selectedIntegrationType === "googleSheets" || integrationType === "googleSheets") && ( + <>{renderGoogleSheetsIntegration()} + )} + + {(selectedIntegrationType === "airtable" || integrationType === "airtable") && ( + <>{renderAirtableIntegration()} + )} + + {(selectedIntegrationType === "webhook" || integrationType === "webhook") && ( + <> + Integrate using Webhooks + {settings.webhooks && settings.webhooks.length > 0 && ( + + + + + Webhook URL + Call when + Last called + Status + Actions + + + + {settings.webhooks.map((webhook) => ( + + {webhook.url} + {webhook.events.map((event) => ())} + {formatLastCalled(webhook.lastCalledAt)} + toggleWebhookStatusSetting(webhook.id)} size="small" /> + + testWebhookSetting(webhook.id)} disabled={loading || !webhook.active} title="Test"> + editWebhookSetting(webhook)} disabled={loading} title="Edit"> + deleteWebhookSetting(webhook.id)} disabled={loading} title="Delete"> + + + ))} + +
+
+ )} + {!showWebhookForm && ( + + + { setNewWebhook({ ...newWebhook, url: e.target.value }); if (urlError) setUrlError(null); }} error={!!urlError} helperText={urlError} required aria-describedby="webhook-url-help" /> + setNewWebhook({ ...newWebhook, events: [e.target.value] })} sx={{ minWidth: "200px" }} required> + Run finished + Run failed + + + + Refer to the API documentation for examples and details. + + + + )} + {showWebhookForm && ( + + + {editingWebhook ? "Edit Webhook" : "Add New Webhook"} + { setNewWebhook({ ...newWebhook, url: e.target.value }); if (urlError) setUrlError(null); }} sx={{ marginBottom: "15px" }} placeholder="https://your-api.com/webhook/endpoint" required error={!!urlError} helperText={urlError} /> + setNewWebhook({ ...newWebhook, events: typeof e.target.value === "string" ? [e.target.value] : e.target.value })} + SelectProps={{ multiple: true, renderValue: (selected) => ({(selected as string[]).map((value) => ())}), }} sx={{ marginBottom: "20px" }} required> + Run finished + Run failed + + setNewWebhook({ ...newWebhook, active: e.target.checked })} />} label="Active" sx={{ marginBottom: "10px" }} /> + + + + + + + )} + + )} +
+
+
+ ); +}; diff --git a/src/components/robot/pages/RobotSettingsPage.tsx b/src/components/robot/pages/RobotSettingsPage.tsx new file mode 100644 index 00000000..11832935 --- /dev/null +++ b/src/components/robot/pages/RobotSettingsPage.tsx @@ -0,0 +1,193 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { TextField, Typography, Box, Card, CardContent } from "@mui/material"; +import { Settings, Info } from "@mui/icons-material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording } from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { getUserById } from "../../../api/auth"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + url?: string; +} + +interface RobotWorkflow { + workflow: WhereWhatPair[]; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: "MINUTES" | "HOURS" | "DAYS" | "WEEKS" | "MONTHS"; + startFrom: + | "SUNDAY" + | "MONDAY" + | "TUESDAY" + | "WEDNESDAY" + | "THURSDAY" + | "FRIDAY" + | "SATURDAY"; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; +} + +export interface RobotSettings { + id: string; + userId?: number; + recording_meta: RobotMeta; + recording: RobotWorkflow; + google_sheet_email?: string | null; + google_sheet_name?: string | null; + google_sheet_id?: string | null; + google_access_token?: string | null; + google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface RobotSettingsProps { + handleStart: (settings: RobotSettings) => void; +} + +export const RobotSettingsPage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [userEmail, setUserEmail] = useState(null); + const [robot, setRobot] = useState(null); + const { recordingId, notify } = useGlobalInfoStore(); + + useEffect(() => { + getRobot(); + }, []); + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_settings.errors.robot_not_found")); + } + } else { + notify("error", t("robot_settings.errors.robot_not_found")); + } + }; + + const getTargetUrl = () => { + let url = robot?.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + return url; + }; + + useEffect(() => { + const fetchUserEmail = async () => { + if (robot && robot.userId) { + try { + const userData = await getUserById(robot.userId.toString()); + if (userData && userData.user) { + setUserEmail(userData.user.email); + } + } catch (error) { + console.error("Failed to fetch user email:", error); + } + } + }; + fetchUserEmail(); + }, [robot?.userId]); + + const handleCancel = () => { + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + }; + + const targetUrl = getTargetUrl(); + + return ( + + <> + + {robot && ( + <> + + + {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== + undefined && ( + + )} + + + + )} + + + + ); +}; diff --git a/src/components/robot/pages/ScheduleSettingsPage.tsx b/src/components/robot/pages/ScheduleSettingsPage.tsx new file mode 100644 index 00000000..5108d485 --- /dev/null +++ b/src/components/robot/pages/ScheduleSettingsPage.tsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + MenuItem, + TextField, + Typography, + Box, + Button, +} from "@mui/material"; +import { Schedule } from "@mui/icons-material"; +import { Dropdown } from "../../ui/DropdownMui"; +import { validMomentTimezones } from "../../../constants/const"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getSchedule, deleteSchedule } from "../../../api/storage"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface ScheduleSettingsProps { + handleStart: (settings: ScheduleSettings) => void; +} + +export interface ScheduleSettings { + runEvery: number; + runEveryUnit: string; + startFrom: string; + dayOfMonth?: string; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; +} + +export const ScheduleSettingsPage = ({ + handleStart, +}: ScheduleSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [schedule, setSchedule] = useState(null); + const [settings, setSettings] = useState({ + runEvery: 1, + runEveryUnit: "HOURS", + startFrom: "MONDAY", + dayOfMonth: "1", + atTimeStart: "00:00", + atTimeEnd: "01:00", + timezone: "UTC", + }); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = ( + field: keyof ScheduleSettings, + value: string | number | boolean + ) => { + setSettings((prev) => ({ ...prev, [field]: value })); + }; + + const units = ["MINUTES", "HOURS", "DAYS", "WEEKS", "MONTHS"]; + + const days = [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY", + ]; + + const { recordingId, notify } = useGlobalInfoStore(); + + const deleteRobotSchedule = async () => { + if (recordingId) { + setIsLoading(true); + try { + await deleteSchedule(recordingId); + setSchedule(null); + notify("success", t("Schedule deleted successfully")); + + setSettings({ + runEvery: 1, + runEveryUnit: "HOURS", + startFrom: "MONDAY", + dayOfMonth: "", + atTimeStart: "00:00", + atTimeEnd: "01:00", + timezone: "UTC", + }); + } catch (error) { + notify("error", "Failed to delete schedule"); + } finally { + setIsLoading(false); + } + } else { + console.error("No recording id provided"); + } + }; + + const getRobotSchedule = async () => { + if (recordingId) { + try { + const scheduleData = await getSchedule(recordingId); + setSchedule(scheduleData); + } catch (error) { + console.error("Failed to fetch robot schedule:", error); + } + } else { + console.error("No recording id provided"); + } + }; + + useEffect(() => { + const fetchSchedule = async () => { + await getRobotSchedule(); + }; + fetchSchedule(); + }, []); + + const handleSave = async () => { + setIsLoading(true); + try { + await handleStart(settings); + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + } catch (error) { + notify("error", "Failed to save schedule"); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + }; + + const getDayOrdinal = (day: string | undefined) => { + if (!day) return ""; + const lastDigit = day.slice(-1); + const lastTwoDigits = day.slice(-2); + + // Special cases for 11, 12, 13 + if (["11", "12", "13"].includes(lastTwoDigits)) { + return t("schedule_settings.labels.on_day.th"); + } + + // Other cases + switch (lastDigit) { + case "1": + return t("schedule_settings.labels.on_day.st"); + case "2": + return t("schedule_settings.labels.on_day.nd"); + case "3": + return t("schedule_settings.labels.on_day.rd"); + default: + return t("schedule_settings.labels.on_day.th"); + } + }; + + const textStyle = { + width: "150px", + height: "52px", + marginRight: "10px", + }; + + const dropDownStyle = { + marginTop: "2px", + width: "150px", + height: "59px", + marginRight: "10px", + }; + + return ( + + *": { marginBottom: "20px" }, + }} + > + <> + {schedule !== null ? ( + <> + + {t("schedule_settings.run_every")}: {schedule.runEvery}{" "} + {schedule.runEveryUnit.toLowerCase()} + + + {["MONTHS", "WEEKS"].includes(settings.runEveryUnit) + ? t("schedule_settings.start_from") + : t("schedule_settings.start_from")} + : {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()} + + {schedule.runEveryUnit === "MONTHS" && ( + + {t("schedule_settings.on_day")}: {schedule.dayOfMonth} + {getDayOrdinal(schedule.dayOfMonth)} of the month + + )} + + {t("schedule_settings.at_around")}: {schedule.atTimeStart},{" "} + {schedule.timezone} {t("schedule_settings.timezone")} + + + + + + ) : ( + <> + + + {t("schedule_settings.labels.run_once_every")} + + + handleChange("runEvery", parseInt(e.target.value)) + } + sx={textStyle} + inputProps={{ min: 1 }} + /> + + handleChange("runEveryUnit", e.target.value) + } + sx={dropDownStyle} + > + {units.map((unit) => ( + + {" "} + {unit.charAt(0).toUpperCase() + + unit.slice(1).toLowerCase()} + + ))} + + + + + + {["MONTHS", "WEEKS"].includes(settings.runEveryUnit) + ? t("schedule_settings.labels.start_from_label") + : t("schedule_settings.labels.start_from_label")} + + + handleChange("startFrom", e.target.value) + } + sx={dropDownStyle} + > + {days.map((day) => ( + + {day.charAt(0).toUpperCase() + + day.slice(1).toLowerCase()} + + ))} + + + + {settings.runEveryUnit === "MONTHS" && ( + + + {t("schedule_settings.labels.on_day_of_month")} + + + handleChange("dayOfMonth", e.target.value) + } + sx={textStyle} + inputProps={{ min: 1, max: 31 }} + /> + + )} + + {["MINUTES", "HOURS"].includes(settings.runEveryUnit) ? ( + + + + {t("schedule_settings.labels.in_between")} + + + handleChange("atTimeStart", e.target.value) + } + sx={textStyle} + /> + + handleChange("atTimeEnd", e.target.value) + } + sx={textStyle} + /> + + + ) : ( + + + {t("schedule_settings.at_around")} + + + handleChange("atTimeStart", e.target.value) + } + sx={textStyle} + /> + + )} + + + + {t("schedule_settings.timezone")} + + + handleChange("timezone", e.target.value) + } + sx={dropDownStyle} + > + {validMomentTimezones.map((tz) => ( + + {tz.charAt(0).toUpperCase() + + tz.slice(1).toLowerCase()} + + ))} + + + + )} + + + + ); +}; diff --git a/src/context/socket.tsx b/src/context/socket.tsx index 4d9c95e1..05a8c989 100644 --- a/src/context/socket.tsx +++ b/src/context/socket.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import React, { createContext, useCallback, useContext, useState, useRef, useEffect } from 'react'; import { io, Socket } from 'socket.io-client'; import { apiUrl } from "../apiConfig"; @@ -6,12 +6,16 @@ const SERVER_ENDPOINT = apiUrl; interface SocketState { socket: Socket | null; + queueSocket: Socket | null; id: string; setId: (id: string) => void; + connectToQueueSocket: (userId: string, onRunCompleted?: (data: any) => void) => void; + disconnectQueueSocket: () => void; }; class SocketStore implements Partial { - socket = null; + socket: Socket | null = null; + queueSocket: Socket | null = null; id = ''; }; @@ -22,7 +26,9 @@ export const useSocketStore = () => useContext(socketStoreContext); export const SocketProvider = ({ children }: { children: JSX.Element }) => { const [socket, setSocket] = useState(socketStore.socket); + const [queueSocket, setQueueSocket] = useState(socketStore.queueSocket); const [id, setActiveId] = useState(socketStore.id); + const runCompletedCallbackRef = 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 @@ -39,12 +45,70 @@ export const SocketProvider = ({ children }: { children: JSX.Element }) => { setActiveId(id); }, [setSocket]); + const connectToQueueSocket = useCallback((userId: string, onRunCompleted?: (data: any) => void) => { + runCompletedCallbackRef.current = onRunCompleted || null; + + const newQueueSocket = io(`${SERVER_ENDPOINT}/queued-run`, { + transports: ["websocket"], + rejectUnauthorized: false, + query: { userId } + }); + + newQueueSocket.on('connect', () => { + console.log('Queue socket connected for user:', userId); + }); + + newQueueSocket.on('connect_error', (error) => { + console.log('Queue socket connection error:', error); + }); + + newQueueSocket.on('run-completed', (completionData) => { + console.log('Run completed event received:', completionData); + if (runCompletedCallbackRef.current) { + runCompletedCallbackRef.current(completionData); + } + }); + + setQueueSocket(currentSocket => { + if (currentSocket) { + currentSocket.disconnect(); + } + return newQueueSocket; + }); + + socketStore.queueSocket = newQueueSocket; + }, []); + + const disconnectQueueSocket = useCallback(() => { + setQueueSocket(currentSocket => { + if (currentSocket) { + currentSocket.disconnect(); + } + return null; + }); + + socketStore.queueSocket = null; + runCompletedCallbackRef.current = null; + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (queueSocket) { + queueSocket.disconnect(); + } + }; + }, [queueSocket]); + return ( {children} diff --git a/src/helpers/clientSelectorGenerator.ts b/src/helpers/clientSelectorGenerator.ts index 9ec690b7..184a199b 100644 --- a/src/helpers/clientSelectorGenerator.ts +++ b/src/helpers/clientSelectorGenerator.ts @@ -109,6 +109,11 @@ class ClientSelectorGenerator { private getList: boolean = false; private paginationMode: boolean = false; + private pathCache = new WeakMap(); + private descendantsCache = new WeakMap(); + private meaningfulCache = new WeakMap(); + private selectorCache = new Map(); + private elementGroups: Map = new Map(); private groupedElements: Set = new Set(); private lastAnalyzedDocument: Document | null = null; @@ -397,7 +402,22 @@ class ClientSelectorGenerator { this.lastAnalyzedDocument = iframeDoc; // Get all visible elements INCLUDING shadow DOM - const allElements = this.getAllVisibleElementsWithShadow(iframeDoc); + let allElements = this.getAllVisibleElementsWithShadow(iframeDoc); + + if (this.getList === true && this.listSelector === "") { + const dialogElements = this.findAllDialogElements(iframeDoc); + + if (dialogElements.length > 0) { + // Check if dialogs contain significant content worth analyzing + const dialogContentElements = this.getElementsFromDialogs(dialogElements); + + // Only switch to dialog-focused analysis if dialogs have substantial content + if (dialogContentElements.length > 5) { + allElements = dialogContentElements; + } + } + } + const processedInTables = new Set(); // 1. Specifically find and group rows within each table, bypassing normal similarity checks. @@ -517,16 +537,42 @@ class ClientSelectorGenerator { return meaningfulChildren; } + /** + * Check if element has meaningful content for extraction (cached version) + */ + private isMeaningfulElementCached(element: HTMLElement): boolean { + if (this.meaningfulCache.has(element)) { + return this.meaningfulCache.get(element)!; + } + + const result = this.isMeaningfulElement(element); + this.meaningfulCache.set(element, result); + return result; + } + /** * Check if element has meaningful content for extraction */ private isMeaningfulElement(element: HTMLElement): boolean { const tagName = element.tagName.toLowerCase(); + + // Fast path for common meaningful elements + if (["a", "img", "input", "button", "select"].includes(tagName)) { + return true; + } + const text = (element.textContent || "").trim(); const hasHref = element.hasAttribute("href"); const hasSrc = element.hasAttribute("src"); + + // Quick checks first + if (text.length > 0 || hasHref || hasSrc) { + return true; + } + const isCustomElement = tagName.includes("-"); + // For custom elements, be more lenient about what's considered meaningful if (isCustomElement) { const hasChildren = element.children.length > 0; const hasSignificantAttributes = Array.from(element.attributes).some( @@ -534,9 +580,6 @@ class ClientSelectorGenerator { ); return ( - text.length > 0 || - hasHref || - hasSrc || hasChildren || hasSignificantAttributes || element.hasAttribute("role") || @@ -544,12 +587,7 @@ class ClientSelectorGenerator { ); } - return ( - text.length > 0 || - hasHref || - hasSrc || - ["a", "img", "input", "button", "select"].includes(tagName) - ); + return false; } /** @@ -2457,6 +2495,13 @@ class ClientSelectorGenerator { parentSelector: string ): string[] => { try { + const cacheKey = `${parentSelector}_${iframeDoc.location?.href || 'doc'}`; + if (this.selectorCache.has(cacheKey)) { + return this.selectorCache.get(cacheKey)!; + } + + this.pathCache = new WeakMap(); + // Use XPath evaluation to find parent elements let parentElements: HTMLElement[] = this.evaluateXPath( parentSelector, @@ -2468,9 +2513,17 @@ class ClientSelectorGenerator { return []; } - const allChildSelectors = new Set(); + if (parentElements.length > 10) { + parentElements = parentElements.slice(0, 10); + } + + const allChildSelectors = new Set(); + const processedParents = new Set(); + + for (const parentElement of parentElements) { + if (processedParents.has(parentElement)) continue; + processedParents.add(parentElement); - parentElements.forEach((parentElement) => { const otherListElements = parentElements.filter( (el) => el !== parentElement ); @@ -2481,12 +2534,15 @@ class ClientSelectorGenerator { iframeDoc, otherListElements ); - childSelectors.forEach((selector) => allChildSelectors.add(selector)); - }); + + for (const selector of childSelectors) { + allChildSelectors.add(selector); + } + } - // Convert Set back to array and sort for consistency - const childSelectors = Array.from(allChildSelectors).sort(); - return childSelectors; + const result = Array.from(allChildSelectors).sort(); + this.selectorCache.set(cacheKey, result); + return result; } catch (error) { console.error("Error in getChildSelectors:", error); return []; @@ -2494,39 +2550,81 @@ class ClientSelectorGenerator { }; private getAllDescendantsIncludingShadow( - parentElement: HTMLElement, - maxDepth: number = 20 + parentElement: HTMLElement ): HTMLElement[] { - const allDescendants: HTMLElement[] = []; + if (this.descendantsCache.has(parentElement)) { + return this.descendantsCache.get(parentElement)!; + } + + const meaningfulDescendants: HTMLElement[] = []; + const queue: HTMLElement[] = [parentElement]; const visited = new Set(); + visited.add(parentElement); - const traverse = (element: HTMLElement, currentDepth: number) => { - if (currentDepth >= maxDepth || visited.has(element)) { - return; - } - visited.add(element); + const MAX_MEANINGFUL_ELEMENTS = 300; + const MAX_NODES_TO_CHECK = 1200; + const MAX_DEPTH = 12; + let nodesChecked = 0; - if (element !== parentElement) { - allDescendants.push(element); + let adjustedMaxDepth = MAX_DEPTH; + const elementDensityThreshold = 50; + + const depths: number[] = [0]; + let queueIndex = 0; + + while (queueIndex < queue.length) { + const element = queue[queueIndex]; + const currentDepth = depths[queueIndex]; + queueIndex++; + nodesChecked++; + + if (currentDepth <= 3 && meaningfulDescendants.length > elementDensityThreshold) { + adjustedMaxDepth = Math.max(6, adjustedMaxDepth - 2); } - // Traverse light DOM children - const children = Array.from(element.children) as HTMLElement[]; - for (const child of children) { - traverse(child, currentDepth + 1); + if ( + nodesChecked > MAX_NODES_TO_CHECK || + meaningfulDescendants.length >= MAX_MEANINGFUL_ELEMENTS || + currentDepth > adjustedMaxDepth + ) { + break; } - // Traverse shadow DOM if it exists - if (element.shadowRoot) { - const shadowChildren = Array.from(element.shadowRoot.children) as HTMLElement[]; - for (const shadowChild of shadowChildren) { - traverse(shadowChild, currentDepth + 1); + if (element !== parentElement && this.isMeaningfulElementCached(element)) { + meaningfulDescendants.push(element); + } + + if (currentDepth >= adjustedMaxDepth) { + continue; + } + + const children = element.children; + const childLimit = Math.min(children.length, 30); + for (let i = 0; i < childLimit; i++) { + const child = children[i] as HTMLElement; + if (!visited.has(child)) { + visited.add(child); + queue.push(child); + depths.push(currentDepth + 1); } } - }; - traverse(parentElement, 0); - return allDescendants; + if (element.shadowRoot && currentDepth < adjustedMaxDepth - 1) { + const shadowChildren = element.shadowRoot.children; + const shadowLimit = Math.min(shadowChildren.length, 20); + for (let i = 0; i < shadowLimit; i++) { + const child = shadowChildren[i] as HTMLElement; + if (!visited.has(child)) { + visited.add(child); + queue.push(child); + depths.push(currentDepth + 1); + } + } + } + } + + this.descendantsCache.set(parentElement, meaningfulDescendants); + return meaningfulDescendants; } private generateOptimizedChildXPaths( @@ -2541,24 +2639,35 @@ class ClientSelectorGenerator { // Get all meaningful descendants (not just direct children) const allDescendants = this.getAllDescendantsIncludingShadow(parentElement); - allDescendants.forEach((descendant, i) => { - if (processedElements.has(descendant)) return; - processedElements.add(descendant); + const batchSize = 25; + for (let i = 0; i < allDescendants.length; i += batchSize) { + const batch = allDescendants.slice(i, i + batchSize); + + for (const descendant of batch) { + if (processedElements.has(descendant)) continue; + processedElements.add(descendant); - if (!this.isMeaningfulElement(descendant)) return; + const absolutePath = this.buildOptimizedAbsoluteXPath( + descendant, + listSelector, + parentElement, + document, + otherListElements + ); - const absolutePath = this.buildOptimizedAbsoluteXPath( - descendant, - listSelector, - parentElement, - document, - otherListElements - ); + if (absolutePath) { + selectors.push(absolutePath); + } - if (absolutePath) { - selectors.push(absolutePath); + if (selectors.length >= 250) { + break; + } } - }); + + if (selectors.length >= 250) { + break; + } + } return [...new Set(selectors)]; } @@ -2736,6 +2845,10 @@ class ClientSelectorGenerator { rootElement: HTMLElement, otherListElements: HTMLElement[] = [] ): string | null { + if (this.pathCache.has(targetElement)) { + return this.pathCache.get(targetElement)!; + } + if ( !this.elementContains(rootElement, targetElement) || targetElement === rootElement @@ -2788,45 +2901,64 @@ class ClientSelectorGenerator { } if (current !== rootElement) { + this.pathCache.set(targetElement, null); return null; } - return pathParts.length > 0 ? "/" + pathParts.join("/") : null; + const result = pathParts.length > 0 ? "/" + pathParts.join("/") : null; + + this.pathCache.set(targetElement, result); + + return result; } private getCommonClassesAcrossLists( targetElement: HTMLElement, otherListElements: HTMLElement[] ): string[] { - const targetClasses = this.normalizeClasses(targetElement.classList).split(" ").filter(Boolean); + if (otherListElements.length === 0) { + return this.normalizeClasses(targetElement.classList).split(" ").filter(Boolean); + } - const otherListsKey = otherListElements.map(el => `${el.tagName}-${el.className}`).sort().join('|'); - const cacheKey = `${targetElement.tagName}-${targetClasses.sort().join(',')}-${otherListsKey}`; + const targetClasses = this.normalizeClasses(targetElement.classList).split(" ").filter(Boolean); + + if (targetClasses.length === 0) { + return []; + } + + const cacheKey = `${targetElement.tagName}_${targetClasses.join(',')}_${otherListElements.length}`; if (this.classCache.has(cacheKey)) { return this.classCache.get(cacheKey)!; } - if (otherListElements.length === 0) { - this.classCache.set(cacheKey, targetClasses); - return targetClasses; - } + const maxElementsToCheck = 100; + let checkedElements = 0; + const similarElements: HTMLElement[] = []; - const similarElements = otherListElements.flatMap(listEl => - this.getAllDescendantsIncludingShadow(listEl).filter(child => - child.tagName === targetElement.tagName - ) - ); + for (const listEl of otherListElements) { + if (checkedElements >= maxElementsToCheck) break; + + const descendants = this.getAllDescendantsIncludingShadow(listEl); + for (const child of descendants) { + if (checkedElements >= maxElementsToCheck) break; + if (child.tagName === targetElement.tagName) { + similarElements.push(child); + checkedElements++; + } + } + } if (similarElements.length === 0) { this.classCache.set(cacheKey, targetClasses); return targetClasses; } + const targetClassSet = new Set(targetClasses); const exactMatches = similarElements.filter(el => { const elClasses = this.normalizeClasses(el.classList).split(" ").filter(Boolean); - return targetClasses.length === elClasses.length && - targetClasses.every(cls => elClasses.includes(cls)); + if (elClasses.length !== targetClasses.length) return false; + return elClasses.every(cls => targetClassSet.has(cls)); }); if (exactMatches.length > 0) { @@ -3913,6 +4045,168 @@ class ClientSelectorGenerator { return depth; } + /** + * Find dialog element in the elements array + */ + 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; + } + + /** + * Find all dialog elements in the document + */ + private findAllDialogElements(doc: Document): HTMLElement[] { + const dialogElements: HTMLElement[] = []; + const allElements = Array.from(doc.querySelectorAll("*")) as HTMLElement[]; + + for (const element of allElements) { + if (element.getAttribute("role") === "dialog") { + dialogElements.push(element); + continue; + } + + if (element.tagName.toLowerCase() === "dialog") { + dialogElements.push(element); + continue; + } + } + + return dialogElements; + } + + /** + * Get all visible elements from within dialog elements + */ + private getElementsFromDialogs(dialogElements: HTMLElement[]): HTMLElement[] { + const elements: HTMLElement[] = []; + const visited = new Set(); + + for (const dialog of dialogElements) { + const dialogChildren = Array.from(dialog.querySelectorAll("*")).filter( + (el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + ) as HTMLElement[]; + + // Add dialog itself if it's visible + const dialogRect = dialog.getBoundingClientRect(); + if (dialogRect.width > 0 && dialogRect.height > 0 && !visited.has(dialog)) { + visited.add(dialog); + elements.push(dialog); + } + + // Add all visible children + dialogChildren.forEach((element) => { + if (!visited.has(element)) { + visited.add(element); + elements.push(element); + + // Traverse shadow DOM if it exists within dialog + if (element.shadowRoot) { + const shadowElements = this.getElementsFromShadowRoot(element.shadowRoot); + shadowElements.forEach(shadowEl => { + if (!visited.has(shadowEl)) { + visited.add(shadowEl); + elements.push(shadowEl); + } + }); + } + } + }); + } + + return elements; + } + + /** + * Get elements from shadow root (helper for dialog analysis) + */ + private getElementsFromShadowRoot(shadowRoot: ShadowRoot): HTMLElement[] { + const elements: HTMLElement[] = []; + try { + const shadowChildren = Array.from(shadowRoot.querySelectorAll("*")).filter( + (el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + ) as HTMLElement[]; + + shadowChildren.forEach((element) => { + elements.push(element); + + // Recursively traverse nested shadow DOMs + if (element.shadowRoot) { + const nestedShadowElements = this.getElementsFromShadowRoot(element.shadowRoot); + elements.push(...nestedShadowElements); + } + }); + } catch (error) { + console.warn("Could not access shadow root:", error); + } + + return elements; + } + /** * Clean up when component unmounts or mode changes @@ -3926,6 +4220,10 @@ class ClientSelectorGenerator { this.spatialIndex.clear(); this.lastCachedDocument = null; this.classCache.clear(); + this.selectorCache.clear(); + this.pathCache = new WeakMap(); + this.descendantsCache = new WeakMap(); + this.meaningfulCache = new WeakMap(); } // Update generateSelector to use instance variables diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index ed223ba1..c71478b3 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -15,6 +15,7 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { apiUrl } from "../apiConfig"; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../context/auth'; +import { useSocketStore } from '../context/socket'; interface MainPageProps { handleEditRecording: (id: string, fileName: string) => void; @@ -54,6 +55,8 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) const { state } = useContext(AuthContext); const { user } = state; + const { connectToQueueSocket, disconnectQueueSocket } = useSocketStore(); + const abortRunHandler = (runId: string, robotName: string, browserId: string) => { notify('info', t('main_page.notifications.abort_initiated', { name: robotName })); @@ -138,50 +141,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) navigate(`/runs/${robotMetaId}/run/${runId}`); if (queued) { - console.log('Creating queue socket for queued run:', runId); - setQueuedRuns(prev => new Set([...prev, runId])); - - const queueSocket = io(`${apiUrl}/queued-run`, { - transports: ["websocket"], - rejectUnauthorized: false, - query: { userId: user?.id } - }); - - queueSocket.on('connect', () => { - console.log('Queue socket connected for user:', user?.id); - }); - - queueSocket.on('connect_error', (error) => { - console.log('Queue socket connection error:', error); - }); - - queueSocket.on('run-completed', (completionData) => { - if (completionData.runId === runId) { - setRunningRecordingName(''); - setCurrentInterpretationLog(''); - setRerenderRuns(true); - - setQueuedRuns(prev => { - const newSet = new Set(prev); - newSet.delete(runId); - return newSet; - }); - - const robotName = completionData.robotName || runningRecordingName; - - if (completionData.status === 'success') { - notify('success', t('main_page.notifications.interpretation_success', { name: robotName })); - } else { - notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); - } - - queueSocket.disconnect(); - } - }); - - setSockets(sockets => [...sockets, queueSocket]); - notify('info', `Run queued: ${runningRecordingName}`); } else { const socket = io(`${apiUrl}/${browserId}`, { @@ -245,6 +205,36 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) return message === 'success'; } + useEffect(() => { + if (user?.id) { + const handleRunCompleted = (completionData: any) => { + setRerenderRuns(true); + + if (queuedRuns.has(completionData.runId)) { + setQueuedRuns(prev => { + const newSet = new Set(prev); + newSet.delete(completionData.runId); + return newSet; + }); + } + + const robotName = completionData.robotName || 'Unknown Robot'; + + if (completionData.status === 'success') { + notify('success', t('main_page.notifications.interpretation_success', { name: robotName })); + } else { + notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); + } + }; + + connectToQueueSocket(user.id, handleRunCompleted); + + return () => { + disconnectQueueSocket(); + }; + } + }, [user?.id, connectToQueueSocket, disconnectQueueSocket, t, setRerenderRuns, queuedRuns, setQueuedRuns]); + const DisplayContent = () => { switch (content) { case 'robots':