diff --git a/README.md b/README.md index 376bf25b..13c994a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- +
Maxun diff --git a/maxun-core/package.json b/maxun-core/package.json index d403360a..5b732a6b 100644 --- a/maxun-core/package.json +++ b/maxun-core/package.json @@ -1,6 +1,6 @@ { "name": "maxun-core", - "version": "0.0.9", + "version": "0.0.10", "description": "Core package for Maxun, responsible for data extraction", "main": "build/index.js", "typings": "build/index.d.ts", diff --git a/maxun-core/src/interpret.ts b/maxun-core/src/interpret.ts index 70d425a1..d87e2477 100644 --- a/maxun-core/src/interpret.ts +++ b/maxun-core/src/interpret.ts @@ -490,6 +490,14 @@ export default class Interpreter extends EventEmitter { const executeAction = async (invokee: any, methodName: string, args: any) => { console.log("Executing action:", methodName, args); + + if (methodName === 'press' || methodName === 'type') { + // Extract only the first two arguments for these methods + const limitedArgs = Array.isArray(args) ? args.slice(0, 2) : [args]; + await (invokee[methodName])(...limitedArgs); + return; + } + if (!args || Array.isArray(args)) { await (invokee[methodName])(...(args ?? [])); } else { @@ -547,221 +555,225 @@ export default class Interpreter extends EventEmitter { let scrapedItems: Set = new Set(); let visitedUrls: string[] = []; + // Debug logging helper + const debugLog = (message: string, ...args: any[]) => { + console.log(`[Page ${visitedUrls.length + 1}] ${message}`, ...args); + }; + let availableSelectors = config.pagination.selector.split(','); while (true) { - switch (config.pagination.type) { - case 'scrollDown': - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(2000); + switch (config.pagination.type) { + case 'scrollDown': + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(2000); - const currentHeight = await page.evaluate(() => document.body.scrollHeight); - if (currentHeight === previousHeight) { - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } + const currentHeight = await page.evaluate(() => document.body.scrollHeight); + if (currentHeight === previousHeight) { + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } - previousHeight = currentHeight; - break; - case 'scrollUp': - await page.evaluate(() => window.scrollTo(0, 0)); - await page.waitForTimeout(2000); - - const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); - if (currentTopHeight === 0) { - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - previousHeight = currentTopHeight; - break; - case 'clickNext': - console.log("Page URL:", page.url()); - const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - - // console.log("Page results:", pageResults); - - // Filter out already scraped items - const newResults = pageResults.filter(item => { - const uniqueKey = JSON.stringify(item); - if (scrapedItems.has(uniqueKey)) return false; // Ignore if already scraped - scrapedItems.add(uniqueKey); // Mark as scraped - return true; - }); - - allResults = allResults.concat(newResults); - console.log("Results so far:", allResults.length); - - if (config.limit && allResults.length >= config.limit) { - return allResults.slice(0, config.limit); - } - - let checkButton = null; - let workingSelector = null; - - for (let i = 0; i < availableSelectors.length; i++) { - const selector = availableSelectors[i]; - try { - // Wait for selector with a short timeout - checkButton = await page.waitForSelector(selector, { state: 'attached' }); - if (checkButton) { - workingSelector = selector; + previousHeight = currentHeight; break; + + case 'scrollUp': + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(2000); + + const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); + if (currentTopHeight === 0) { + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + + previousHeight = currentTopHeight; + break; + + case 'clickNext': + debugLog("Current URL:", page.url()); + const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + + // Filter out already scraped items + const newResults = pageResults.filter(item => { + const uniqueKey = JSON.stringify(item); + if (scrapedItems.has(uniqueKey)) return false; + scrapedItems.add(uniqueKey); + return true; + }); + + allResults = allResults.concat(newResults); + debugLog("Results collected so far:", allResults.length); + + if (config.limit && allResults.length >= config.limit) { + return allResults.slice(0, config.limit); } - } catch (error) { - console.log(`Selector failed: ${selector}`); - } - } - if (!workingSelector) { - return allResults; - } + await page.waitForLoadState('networkidle', { timeout: 30000 }); + await page.waitForTimeout(2000); - // const nextButton = await page.$(config.pagination.selector); - const nextButton = await page.$(workingSelector); - if (!nextButton) { - return allResults; // No more pages to scrape - } + let checkButton = null; + let workingSelector = null; - const selectorIndex = availableSelectors.indexOf(workingSelector!); - availableSelectors = availableSelectors.slice(selectorIndex); + // Try each selector with explicit waiting + for (const selector of availableSelectors) { + try { + checkButton = await page.waitForSelector(selector, { + state: 'attached', + timeout: 30000 + }); + if (checkButton) { + workingSelector = selector; + debugLog('Found working selector:', selector); + break; + } + } catch (error) { + debugLog(`Selector failed: ${selector} - ${error.message}`); + } + } - // await Promise.all([ - // nextButton.dispatchEvent('click'), - // page.waitForNavigation({ waitUntil: 'networkidle' }) - // ]); + if (!workingSelector) { + debugLog('No working selector found after trying all options'); + return allResults; + } - const previousUrl = page.url(); - visitedUrls.push(previousUrl); + const nextButton = await page.$(workingSelector); + if (!nextButton) { + debugLog('Next button not found'); + return allResults; + } - try { - // Try both click methods simultaneously - await Promise.race([ - Promise.all([ - page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }), - nextButton.click() - ]), - Promise.all([ - page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }), - nextButton.dispatchEvent('click') - ]) - ]); - } catch (error) { - // Verify if navigation actually succeeded - const currentUrl = page.url(); - if (currentUrl === previousUrl) { - console.log("Previous URL same as current URL. Navigation failed."); - } - } + const selectorIndex = availableSelectors.indexOf(workingSelector); + availableSelectors = availableSelectors.slice(selectorIndex); - const currentUrl = page.url(); - if (visitedUrls.includes(currentUrl)) { - console.log(`Detected navigation to a previously visited URL: ${currentUrl}`); - - // Extract the current page number from the URL - const match = currentUrl.match(/\d+/); - if (match) { - const currentNumber = match[0]; - // Use visitedUrls.length + 1 as the next page number - const nextNumber = visitedUrls.length + 1; - - // Create new URL by replacing the current number with the next number - const nextUrl = currentUrl.replace(currentNumber, nextNumber.toString()); - - console.log(`Navigating to constructed URL: ${nextUrl}`); - - // Navigate to the next page - await Promise.all([ - page.waitForNavigation({ waitUntil: 'networkidle' }), - page.goto(nextUrl) - ]); - } - } - - // Give the page a moment to stabilize after navigation - await page.waitForTimeout(1000); - break; - case 'clickLoadMore': - while (true) { - let checkButton = null; - let workingSelector = null; - - for (let i = 0; i < availableSelectors.length; i++) { - const selector = availableSelectors[i]; try { - // Wait for selector with a short timeout - checkButton = await page.waitForSelector(selector, { state: 'attached' }); - if (checkButton) { - workingSelector = selector; + // Store current URL to check if navigation succeeded + const previousUrl = page.url(); + visitedUrls.push(previousUrl); + + // Try both click methods in sequence + try { + await Promise.all([ + page.waitForNavigation({ + waitUntil: 'networkidle', + timeout: 15000 + }), + nextButton.click() + ]); + } catch (error) { + // If we're still on the same URL, try dispatch event + if (page.url() === previousUrl) { + await Promise.all([ + page.waitForNavigation({ + waitUntil: 'networkidle', + timeout: 15000 + }), + nextButton.dispatchEvent('click') + ]); + } + } + + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle', { timeout: 30000 }); + + const currentUrl = page.url(); + if (visitedUrls.includes(currentUrl)) { + debugLog(`Navigation failed/Detected navigation to previously visited URL: ${currentUrl}`); + return allResults; + } + + // Give the page a moment to stabilize after navigation + await page.waitForTimeout(1000); + + } catch (error) { + debugLog(`Navigation failed completely: ${error.message}`); + return allResults; + } + break; + + case 'clickLoadMore': + while (true) { + let checkButton = null; + let workingSelector = null; + + for (const selector of availableSelectors) { + try { + checkButton = await page.waitForSelector(selector, { + state: 'attached', + timeout: 30000 + }); + if (checkButton) { + workingSelector = selector; + debugLog('Found working selector:', selector); + break; + } + } catch (error) { + debugLog(`Load More selector failed: ${selector}`); + } + } + + if (!workingSelector) { + debugLog('No working Load More selector found'); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + + const loadMoreButton = await page.$(workingSelector); + if (!loadMoreButton) { + debugLog('Load More button not found'); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + + const selectorIndex = availableSelectors.indexOf(workingSelector); + availableSelectors = availableSelectors.slice(selectorIndex); + + try { + try { + await loadMoreButton.click(); + } catch (error) { + await loadMoreButton.dispatchEvent('click'); + } + } catch (error) { + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + + await page.waitForTimeout(2000); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(2000); + + const currentHeight = await page.evaluate(() => document.body.scrollHeight); + if (currentHeight === previousHeight) { + debugLog('No more items loaded after Load More'); + const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(finalResults); + return allResults; + } + previousHeight = currentHeight; + + if (config.limit && allResults.length >= config.limit) { + allResults = allResults.slice(0, config.limit); break; } - } catch (error) { - console.log(`Selector failed: ${selector}`); } - } - - if (!workingSelector) { - // No more working selectors available, so scrape the remaining items - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - const loadMoreButton = await page.$(workingSelector); - if (!loadMoreButton) { - // No more "Load More" button, so scrape the remaining items - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - - const selectorIndex = availableSelectors.indexOf(workingSelector!); - availableSelectors = availableSelectors.slice(selectorIndex); - - // Click the 'Load More' button to load additional items - // await loadMoreButton.dispatchEvent('click'); - try { - await Promise.race([ - loadMoreButton.click(), - loadMoreButton.dispatchEvent('click') - ]); - } catch (error) { - console.log('Both click attempts failed'); - } - await page.waitForTimeout(2000); // Wait for new items to load - // After clicking 'Load More', scroll down to load more items - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); - await page.waitForTimeout(2000); - - // Check if more items are available - const currentHeight = await page.evaluate(() => document.body.scrollHeight); - if (currentHeight === previousHeight) { - // No more items loaded, return the scraped results - const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(finalResults); - return allResults; - } - previousHeight = currentHeight; - - if (config.limit && allResults.length >= config.limit) { - // If limit is set and reached, return the limited results - allResults = allResults.slice(0, config.limit); break; - } - } - break; - default: - const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); - allResults = allResults.concat(results); - return allResults; - } - if (config.limit && allResults.length >= config.limit) { - allResults = allResults.slice(0, config.limit); - break; - } + default: + const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); + allResults = allResults.concat(results); + return allResults; + } + + if (config.limit && allResults.length >= config.limit) { + allResults = allResults.slice(0, config.limit); + break; + } } return allResults; diff --git a/package.json b/package.json index 974dfc06..261169f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxun", - "version": "0.0.7", + "version": "0.0.8", "author": "Maxun", "license": "AGPL-3.0-or-later", "dependencies": { @@ -51,7 +51,7 @@ "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "maxun-core": "^0.0.9", + "maxun-core": "^0.0.10", "minio": "^8.0.1", "moment-timezone": "^0.5.45", "node-cron": "^3.0.3", diff --git a/public/locales/de.json b/public/locales/de.json index 14664774..613ddfef 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -32,6 +32,7 @@ "new": "Roboter erstellen", "modal": { "title": "Geben Sie die URL ein", + "login_title": "Erfordert diese Seite eine Anmeldung?", "label": "URL", "button": "Aufnahme starten" }, @@ -39,8 +40,9 @@ "delete": "Löschen", "duplicate": "Duplizieren", "notifications": { - "delete_warning": "Roboter kann nicht gelöscht werden, da zugehörige Ausführungen vorhanden sind", - "delete_success": "Roboter erfolgreich gelöscht" + "delete_warning": "Der Roboter hat zugehörige Ausführungen. Löschen Sie zuerst die Ausführungen, um den Roboter zu löschen", + "delete_success": "Roboter erfolgreich gelöscht", + "auth_success": "Roboter erfolgreich authentifiziert" } }, "mainmenu": { @@ -60,6 +62,7 @@ "delete": "Löschen", "settings": "Einstellungen", "search": "Ausführungen suchen...", + "sort_tooltip": "Zum Sortieren klicken", "notifications": { "no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.", "delete_success": "Ausführung erfolgreich gelöscht" @@ -204,7 +207,8 @@ "confirm_text_fields": "Bitte bestätigen Sie alle Textfelder", "unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.", "capture_text_discarded": "Texterfassung verworfen", - "capture_list_discarded": "Listenerfassung verworfen" + "capture_list_discarded": "Listenerfassung verworfen", + "label_required": "Beschriftung darf nicht leer sein" } }, "save_recording": { @@ -448,6 +452,7 @@ "log": "Protokoll" }, "empty_output": "Die Ausgabe ist leer.", + "loading": "Ausführung läuft. Extrahierte Daten werden nach Abschluss des Durchlaufs hier angezeigt.", "captured_data": { "title": "Erfasste Daten", "download_json": "Als JSON herunterladen", diff --git a/public/locales/en.json b/public/locales/en.json index 88622f20..85bd3148 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -32,6 +32,7 @@ "new":"Create Robot", "modal":{ "title":"Enter the URL", + "login_title": "Does this website require login?", "label":"URL", "button":"Start Recording" }, @@ -40,8 +41,9 @@ "duplicate":"Duplicate", "search":"Search Robots...", "notifications": { - "delete_warning": "Cannot delete robot as it has associated runs", - "delete_success": "Robot deleted successfully" + "delete_warning": "The robot has associated runs. First delete runs to delete the robot", + "delete_success": "Robot deleted successfully", + "auth_success": "Robot successfully authenticated" } }, "mainmenu":{ @@ -61,6 +63,7 @@ "delete":"Delete", "settings":"Settings", "search":"Search Runs...", + "sort_tooltip": "Click to sort", "notifications": { "no_runs": "No runs found. Please try again.", "delete_success": "Run deleted successfully" @@ -206,7 +209,8 @@ "confirm_text_fields": "Please confirm all text fields", "unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.", "capture_text_discarded": "Capture Text Discarded", - "capture_list_discarded": "Capture List Discarded" + "capture_list_discarded": "Capture List Discarded", + "label_required": "Label cannot be empty" } }, "save_recording": { @@ -459,6 +463,7 @@ "log": "Log" }, "empty_output": "The output is empty.", + "loading": "Run in progress. Extracted data will appear here once run completes.", "captured_data": { "title": "Captured Data", "download_json": "Download as JSON", diff --git a/public/locales/es.json b/public/locales/es.json index 7f669096..46bded05 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -32,6 +32,7 @@ "new": "Crear Robot", "modal": { "title": "Ingresa la URL", + "login_title": "¿Este sitio requiere inicio de sesión?", "label": "URL", "button": "Comenzar grabación" }, @@ -40,8 +41,9 @@ "duplicate": "Duplicar", "search": "Buscar robots...", "notifications": { - "delete_warning": "No se puede eliminar el robot ya que tiene ejecuciones asociadas", - "delete_success": "Robot eliminado exitosamente" + "delete_warning": "El robot tiene ejecuciones asociadas. Primero elimine las ejecuciones para eliminar el robot", + "delete_success": "Robot eliminado exitosamente", + "auth_success": "Robot autenticado exitosamente" } }, "mainmenu": { @@ -61,6 +63,7 @@ "delete": "Eliminar", "settings": "Ajustes", "search": "Buscar ejecuciones...", + "sort_tooltip": "Haga clic para ordenar", "notifications": { "no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.", "delete_success": "Ejecución eliminada con éxito" @@ -205,7 +208,8 @@ "confirm_text_fields": "Por favor confirme todos los campos de texto", "unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.", "capture_text_discarded": "Captura de texto descartada", - "capture_list_discarded": "Captura de lista descartada" + "capture_list_discarded": "Captura de lista descartada", + "label_required": "La etiqueta no puede estar vacía" } }, "save_recording": { @@ -449,6 +453,7 @@ "log": "Registro" }, "empty_output": "La salida está vacía.", + "loading": "Ejecución en curso. Los datos extraídos aparecerán aquí una vez que se complete la ejecución.", "captured_data": { "title": "Datos Capturados", "download_json": "Descargar como JSON", diff --git a/public/locales/ja.json b/public/locales/ja.json index 5680471d..ad78c8f5 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -32,6 +32,7 @@ "new": "ロボットを作成", "modal": { "title": "URLを入力してください", + "login_title": "このサイトはログインが必要ですか?", "label": "URL", "button": "録画を開始" }, @@ -40,8 +41,9 @@ "duplicate": "複製", "search": "ロボットを検索...", "notifications": { - "delete_warning": "関連する実行があるため、ロボットを削除できません", - "delete_success": "ロボットが正常に削除されました" + "delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください", + "delete_success": "ロボットが正常に削除されました", + "auth_success": "ロボットの認証に成功しました" } }, "mainmenu": { @@ -61,6 +63,7 @@ "delete": "削除", "settings": "設定", "search": "実行を検索...", + "sort_tooltip": "クリックして並べ替え", "notifications": { "no_runs": "実行が見つかりません。もう一度お試しください。", "delete_success": "実行が正常に削除されました" @@ -205,7 +208,8 @@ "confirm_text_fields": "すべてのテキストフィールドを確認してください", "unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。", "capture_text_discarded": "テキスト取得が破棄されました", - "capture_list_discarded": "リスト取得が破棄されました" + "capture_list_discarded": "リスト取得が破棄されました", + "label_required": "ラベルは空にできません" } }, "save_recording": { @@ -449,6 +453,7 @@ "log": "ログ" }, "empty_output": "出力は空です。", + "loading": "実行中です。実行が完了すると、抽出されたデータがここに表示されます。", "captured_data": { "title": "キャプチャされたデータ", "download_json": "JSONとしてダウンロード", diff --git a/public/locales/zh.json b/public/locales/zh.json index 536ea70c..cd1a4f9f 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -32,6 +32,7 @@ "new": "创建机器人", "modal": { "title": "输入URL", + "login_title": "此网站需要登录吗?", "label": "URL", "button": "开始录制" }, @@ -40,8 +41,9 @@ "duplicate": "复制", "search": "搜索机器人...", "notifications": { - "delete_warning": "无法删除机器人,因为它有关联的运行记录", - "delete_success": "机器人删除成功" + "delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人", + "delete_success": "机器人删除成功", + "auth_success": "机器人认证成功" } }, "mainmenu": { @@ -61,6 +63,7 @@ "delete": "删除", "settings": "设置", "search": "搜索运行记录...", + "sort_tooltip": "点击排序", "notifications": { "no_runs": "未找到运行记录。请重试。", "delete_success": "运行记录删除成功" @@ -205,7 +208,8 @@ "confirm_text_fields": "请确认所有文本字段", "unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。", "capture_text_discarded": "文本捕获已放弃", - "capture_list_discarded": "列表捕获已放弃" + "capture_list_discarded": "列表捕获已放弃", + "label_required": "标签不能为空" } }, "save_recording": { @@ -449,6 +453,7 @@ "log": "日志" }, "empty_output": "输出为空。", + "loading": "运行中。运行完成后,提取的数据将显示在此处。", "captured_data": { "title": "捕获的数据", "download_json": "下载为JSON", diff --git a/server/Dockerfile b/server/Dockerfile index becad25c..65e472eb 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ RUN mkdir -p /tmp/chromium-data-dir && \ # Install dependencies RUN apt-get update && apt-get install -y \ - libgbm-dev \ + libgbm1 \ libnss3 \ libatk1.0-0 \ libatk-bridge2.0-0 \ @@ -44,14 +44,8 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix -# Add a dbus configuration to prevent connection errors -# RUN mkdir -p /var/run/dbus - -# Make the script executable -# RUN chmod +x ./start.sh - # Expose the backend port EXPOSE ${BACKEND_PORT:-8080} # Start the backend using the start script -CMD ["npm", "run", "server"] \ No newline at end of file +CMD ["npm", "run", "server"] diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 86749313..733f4039 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -119,12 +119,13 @@ router.get("/logout", async (req, res) => { router.get( "/current-user", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { attributes: { exclude: ["password"] }, }); if (!user) { @@ -147,7 +148,7 @@ router.get( router.get( "/user/:id", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { try { const { id } = req.params; if (!id) { @@ -176,12 +177,13 @@ router.get( router.post( "/generate-api-key", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { attributes: { exclude: ["password"] }, }); @@ -216,13 +218,14 @@ router.post( router.get( "/api-key", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: "Unauthorized" }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { raw: true, attributes: ["api_key"], }); @@ -244,13 +247,14 @@ router.get( router.delete( "/delete-api-key", requireSignIn, - async (req: AuthenticatedRequest, res) => { - if (!req.user) { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; + if (!authenticatedReq.user) { return res.status(401).send({ error: "Unauthorized" }); } try { - const user = await User.findByPk(req.user.id, { raw: true }); + const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); if (!user) { return res.status(404).json({ message: "User not found" }); @@ -260,7 +264,7 @@ router.delete( return res.status(404).json({ message: "API Key not found" }); } - await User.update({ api_key: null }, { where: { id: req.user.id } }); + await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } }); capture("maxun-oss-api-key-deleted", { user_id: user.id, @@ -306,7 +310,8 @@ router.get("/google", (req, res) => { router.get( "/google/callback", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; const { code, state } = req.query; try { if (!state) { @@ -332,12 +337,12 @@ router.get( return res.status(400).json({ message: "Email not found" }); } - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).send({ error: "Unauthorized" }); } // Get the currently authenticated user (from `requireSignIn`) - let user = await User.findOne({ where: { id: req.user.id } }); + let user = await User.findOne({ where: { id: authenticatedReq.user.id } }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -392,11 +397,19 @@ router.get( httpOnly: false, maxAge: 60000, }); // 1-minute expiration - res.cookie("robot_auth_message", "Robot successfully authenticated", { + // res.cookie("robot_auth_message", "Robot successfully authenticated", { + // httpOnly: false, + // maxAge: 60000, + // }); + res.cookie('robot_auth_robotId', robotId, { httpOnly: false, maxAge: 60000, }); - res.redirect(`${process.env.PUBLIC_URL}/robots/${robotId}/integrate` as string || `http://localhost:5173/robots/${robotId}/integrate`); + + const baseUrl = process.env.PUBLIC_URL || "http://localhost:5173"; + const redirectUrl = `${baseUrl}/robots/`; + + res.redirect(redirectUrl); } catch (error: any) { res.status(500).json({ message: `Google OAuth error: ${error.message}` }); } @@ -407,12 +420,13 @@ router.get( router.post( "/gsheets/data", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; const { spreadsheetId, robotId } = req.body; - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).send({ error: "Unauthorized" }); } - const user = await User.findByPk(req.user.id, { raw: true }); + const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); if (!user) { return res.status(400).json({ message: "User not found" }); @@ -524,13 +538,14 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => { router.post( "/gsheets/remove", requireSignIn, - async (req: AuthenticatedRequest, res) => { + async (req: Request, res) => { + const authenticatedReq = req as AuthenticatedRequest; const { robotId } = req.body; if (!robotId) { return res.status(400).json({ message: "Robot ID is required" }); } - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).send({ error: "Unauthorized" }); } @@ -552,7 +567,7 @@ router.post( }); capture("maxun-oss-google-sheet-integration-removed", { - user_id: req.user.id, + user_id: authenticatedReq.user.id, robot_id: robotId, deleted_at: new Date().toISOString(), }); diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index d68a889e..f481cc45 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -12,16 +12,17 @@ interface AuthenticatedRequest extends Request { user?: { id: string }; } -router.post('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => { +router.post('/config', requireSignIn, async (req: Request, res: Response) => { const { server_url, username, password } = req.body; + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: 'Unauthorized' }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { attributes: { exclude: ['password'] }, }); @@ -57,13 +58,14 @@ router.post('/config', requireSignIn, async (req: AuthenticatedRequest, res: Res } }); -router.get('/test', requireSignIn, async (req: AuthenticatedRequest, res: Response) => { +router.get('/test', requireSignIn, async (req: Request, res: Response) => { + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: 'Unauthorized' }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { attributes: ['proxy_url', 'proxy_username', 'proxy_password'], raw: true }); @@ -98,13 +100,14 @@ router.get('/test', requireSignIn, async (req: AuthenticatedRequest, res: Respon } }); -router.get('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => { +router.get('/config', requireSignIn, async (req: Request, res: Response) => { + const authenticatedReq = req as AuthenticatedRequest; try { - if (!req.user) { + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: 'Unauthorized' }); } - const user = await User.findByPk(req.user.id, { + const user = await User.findByPk(authenticatedReq.user.id, { attributes: ['proxy_url', 'proxy_username', 'proxy_password'], raw: true, }); @@ -125,12 +128,13 @@ router.get('/config', requireSignIn, async (req: AuthenticatedRequest, res: Resp } }); -router.delete('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => { - if (!req.user) { +router.delete('/config', requireSignIn, async (req: Request, res: Response) => { + const authenticatedReq = req as AuthenticatedRequest; + if (!authenticatedReq.user) { return res.status(401).json({ ok: false, error: 'Unauthorized' }); } - const user = await User.findByPk(req.user.id); + const user = await User.findByPk(authenticatedReq.user.id); if (!user) { return res.status(404).json({ message: 'User not found' }); diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 0fdd69ce..5d2d989f 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -18,6 +18,7 @@ import { AuthenticatedRequest } from './record'; import { computeNextRun } from '../utils/schedule'; import { capture } from "../utils/analytics"; import { tryCatch } from 'bullmq'; +import { encrypt, decrypt } from '../utils/auth'; import { WorkflowFile } from 'maxun-core'; import { Page } from 'playwright'; import { airtableUpdateTasks, processAirtableUpdates } from '../workflow-management/integrations/airtable'; @@ -25,6 +26,36 @@ chromium.use(stealthPlugin()); export const router = Router(); +export const decryptWorkflowActions = async (workflow: any[],): Promise => { + // Create a deep copy to avoid mutating the original workflow + const processedWorkflow = JSON.parse(JSON.stringify(workflow)); + + // Process each step in the workflow + for (const step of processedWorkflow) { + if (!step.what) continue; + + // Process each action in the step + for (const action of step.what) { + // Only process type and press actions + if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { + // The second argument contains the encrypted value + const encryptedValue = action.args[1]; + if (typeof encryptedValue === 'string') { + try { + // Decrypt the value and update the args array + action.args[1] = await decrypt(encryptedValue); + } catch (error) { + console.error('Failed to decrypt value:', error); + // Keep the encrypted value if decryption fails + } + } + } + } + } + + return processedWorkflow; +}; + /** * Logs information about recordings API. */ @@ -56,6 +87,13 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { raw: true } ); + + if (data?.recording?.workflow) { + data.recording.workflow = await decryptWorkflowActions( + data.recording.workflow, + ); + } + return res.send(data); } catch (e) { logger.log('info', 'Error while reading robots'); @@ -117,13 +155,74 @@ function formatRunResponse(run: any) { return formattedRun; } +interface CredentialInfo { + value: string; + type: string; +} + +interface Credentials { + [key: string]: CredentialInfo; +} + +function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) { + return workflow.map(step => { + if (!step.what) return step; + + const indicesToRemove = new Set(); + step.what.forEach((action: any, index: number) => { + if (!action.action || !action.args?.[0]) return; + + if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { + indicesToRemove.add(index); + + if (step.what[index + 1]?.action === 'waitForLoadState') { + indicesToRemove.add(index + 1); + } + } + }); + + const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index)); + + Object.entries(credentials).forEach(([selector, credentialInfo]) => { + const clickIndex = filteredWhat.findIndex((action: any) => + action.action === 'click' && action.args?.[0] === selector + ); + + if (clickIndex !== -1) { + const chars = credentialInfo.value.split(''); + + chars.forEach((char, i) => { + filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { + action: 'type', + args: [ + selector, + encrypt(char), + credentialInfo.type + ] + }); + + filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { + action: 'waitForLoadState', + args: ['networkidle'] + }); + }); + } + }); + + return { + ...step, + what: filteredWhat + }; + }); +} + /** * PUT endpoint to update the name and limit of a robot. */ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { try { const { id } = req.params; - const { name, limit } = req.body; + const { name, limit, credentials } = req.body; // Validate input if (!name && limit === undefined) { @@ -142,17 +241,21 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r robot.set('recording_meta', { ...robot.recording_meta, name }); } + let workflow = [...robot.recording.workflow]; // Create a copy of the workflow + + if (credentials) { + workflow = updateTypeActionsInWorkflow(workflow, credentials); + } + // Update the limit if (limit !== undefined) { - const workflow = [...robot.recording.workflow]; // Create a copy of the workflow - // Ensure the workflow structure is valid before updating if ( workflow.length > 0 && workflow[0]?.what?.[0] ) { // Create a new workflow object with the updated limit - const updatedWorkflow = workflow.map((step, index) => { + workflow = workflow.map((step, index) => { if (index === 0) { // Assuming you want to update the first step return { ...step, @@ -174,14 +277,13 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r } return step; }); - - // Replace the workflow in the recording object - robot.set('recording', { ...robot.recording, workflow: updatedWorkflow }); } else { return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' }); } } + robot.set('recording', { ...robot.recording, workflow }); + await robot.save(); const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); diff --git a/server/src/server.ts b/server/src/server.ts index 94197cfb..9c0eff23 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -18,8 +18,12 @@ import { fork } from 'child_process'; import { capture } from "./utils/analytics"; import swaggerUi from 'swagger-ui-express'; import swaggerSpec from './swagger/config'; + import session from 'express-session'; +import Run from './models/Run'; + + const app = express(); app.use(cors({ origin: process.env.PUBLIC_URL ? process.env.PUBLIC_URL : 'http://localhost:5173', @@ -124,8 +128,23 @@ server.listen(SERVER_PORT, '0.0.0.0', async () => { } }); -process.on('SIGINT', () => { +process.on('SIGINT', async () => { console.log('Main app shutting down...'); + try { + await Run.update( + { + status: 'failed', + finishedAt: new Date().toLocaleString(), + log: 'Process interrupted during execution - worker shutdown' + }, + { + where: { status: 'running' } + } + ); + } catch (error: any) { + console.error('Error updating runs:', error); + } + if (!isProduction) { workerProcess.kill(); } diff --git a/server/src/storage/db.ts b/server/src/storage/db.ts index 2f0fcde4..cdd84655 100644 --- a/server/src/storage/db.ts +++ b/server/src/storage/db.ts @@ -29,7 +29,13 @@ export const connectDB = async () => { export const syncDB = async () => { try { //setupAssociations(); - await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run + const isDevelopment = process.env.NODE_ENV === 'development'; + // force: true will drop and recreate tables on every run + // Use `alter: true` only in development mode + await sequelize.sync({ + force: false, + alter: isDevelopment + }); console.log('Database synced successfully!'); } catch (error) { console.error('Failed to sync database:', error); diff --git a/server/src/worker.ts b/server/src/worker.ts index 3a82ee73..3010a6b2 100644 --- a/server/src/worker.ts +++ b/server/src/worker.ts @@ -67,9 +67,11 @@ async function jobCounts() { jobCounts(); -process.on('SIGINT', () => { - console.log('Worker shutting down...'); - process.exit(); -}); +// We dont need this right now + +// process.on('SIGINT', () => { +// console.log('Worker shutting down...'); +// process.exit(); +// }); export { workflowQueue, worker }; \ No newline at end of file diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 7368f0cb..6e36f287 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -39,6 +39,7 @@ interface MetaData { pairs: number; updatedAt: string; params: string[], + isLogin?: boolean; } /** @@ -97,6 +98,7 @@ export class WorkflowGenerator { pairs: 0, updatedAt: '', params: [], + isLogin: false, } /** @@ -134,9 +136,9 @@ export class WorkflowGenerator { */ private registerEventHandlers = (socket: Socket) => { socket.on('save', (data) => { - const { fileName, userId } = data; + const { fileName, userId, isLogin } = data; logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`); - this.saveNewWorkflow(fileName, userId); + this.saveNewWorkflow(fileName, userId, isLogin); }); socket.on('new-recording', () => this.workflowRecord = { workflow: [], @@ -425,6 +427,40 @@ export class WorkflowGenerator { return; } + if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) { + // Calculate the exact position within the element + const elementPos = await page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top + }; + }, selector); + + if (elementPos) { + const relativeX = coordinates.x - elementPos.x; + const relativeY = coordinates.y - elementPos.y; + + const pair: WhereWhatPair = { + where, + what: [{ + action: 'click', + args: [selector, { position: { x: relativeX, y: relativeY } }] + }] + }; + + if (selector) { + this.generatedData.lastUsedSelector = selector; + this.generatedData.lastAction = 'click'; + } + + await this.addPairToWorkflowAndNotifyClient(pair, page); + return; + } + } + //const element = await getElementMouseIsOver(page, coordinates); //logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`); if (selector) { @@ -474,6 +510,10 @@ export class WorkflowGenerator { public onKeyboardInput = async (key: string, coordinates: Coordinates, page: Page) => { let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) }; const selector = await this.generateSelector(page, coordinates, ActionType.Keydown); + + const elementInfo = await getElementInformation(page, coordinates, '', false); + const inputType = elementInfo?.attributes?.type || "text"; + if (selector) { where.selectors = [selector]; } @@ -481,7 +521,7 @@ export class WorkflowGenerator { where, what: [{ action: 'press', - args: [selector, encrypt(key)], + args: [selector, encrypt(key), inputType], }], } if (selector) { @@ -660,7 +700,7 @@ export class WorkflowGenerator { * @param fileName The name of the file. * @returns {Promise} */ - public saveNewWorkflow = async (fileName: string, userId: number) => { + public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean) => { const recording = this.optimizeWorkflow(this.workflowRecord); try { this.recordingMeta = { @@ -670,6 +710,7 @@ export class WorkflowGenerator { pairs: recording.workflow.length, updatedAt: new Date().toLocaleString(), params: this.getParams() || [], + isLogin: isLogin, } const robot = await Robot.create({ userId, @@ -991,6 +1032,7 @@ export class WorkflowGenerator { let input = { selector: '', value: '', + type: '', actionCounter: 0, }; @@ -1005,7 +1047,7 @@ export class WorkflowGenerator { // when more than one press action is present, add a type action pair.what.splice(index - input.actionCounter, input.actionCounter, { action: 'type', - args: [input.selector, encrypt(input.value)], + args: [input.selector, encrypt(input.value), input.type], }, { action: 'waitForLoadState', args: ['networkidle'], @@ -1033,13 +1075,14 @@ export class WorkflowGenerator { action: 'waitForLoadState', args: ['networkidle'], }) - input = { selector: '', value: '', actionCounter: 0 }; + input = { selector: '', value: '', type: '', actionCounter: 0 }; } } else { pushTheOptimizedAction(pair, index); input = { selector: condition.args[0], value: condition.args[1], + type: condition.args[2], actionCounter: 1, }; } @@ -1048,7 +1091,7 @@ export class WorkflowGenerator { if (input.value.length !== 0) { pushTheOptimizedAction(pair, index); // clear the input - input = { selector: '', value: '', actionCounter: 0 }; + input = { selector: '', value: '', type: '', actionCounter: 0 }; } } }); diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 2a68616d..00000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/App.tsx b/src/App.tsx index cdee8d40..13d04f83 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,89 +1,10 @@ import React from "react"; import { Routes, Route } from "react-router-dom"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; import { GlobalInfoProvider } from "./context/globalInfo"; -import { PageWrapper } from "./pages/PageWrappper"; +import { PageWrapper } from "./pages/PageWrapper"; import i18n from "./i18n"; import ThemeModeProvider from './context/theme-provider'; - -const theme = createTheme({ - palette: { - primary: { - main: "#ff00c3", - contrastText: "#ffffff", - }, - }, - components: { - MuiButton: { - styleOverrides: { - root: { - // Default styles for all buttons (optional) - textTransform: "none", - }, - containedPrimary: { - // Styles for 'contained' variant with 'primary' color - "&:hover": { - backgroundColor: "#ff66d9", - }, - }, - outlined: { - // Apply white background for all 'outlined' variant buttons - backgroundColor: "#ffffff", - "&:hover": { - backgroundColor: "#f0f0f0", // Optional lighter background on hover - }, - }, - }, - }, - MuiLink: { - styleOverrides: { - root: { - "&:hover": { - color: "#ff00c3", - }, - }, - }, - }, - MuiIconButton: { - styleOverrides: { - root: { - // '&:hover': { - // color: "#ff66d9", - // }, - }, - }, - }, - MuiTab: { - styleOverrides: { - root: { - textTransform: "none", - }, - }, - }, - MuiAlert: { - styleOverrides: { - standardInfo: { - backgroundColor: "#fce1f4", - color: "#ff00c3", - "& .MuiAlert-icon": { - color: "#ff00c3", - }, - }, - }, - }, - MuiAlertTitle: { - styleOverrides: { - root: { - "& .MuiAlert-icon": { - color: "#ffffff", - }, - }, - }, - }, - }, -}); - function App() { return ( @@ -93,16 +14,6 @@ function App() { - - // - - // - // - // } /> - // - // - - // ); } diff --git a/src/api/storage.ts b/src/api/storage.ts index 201a38cc..290f6e7f 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -5,6 +5,15 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; +interface CredentialInfo { + value: string; + type: string; +} + +interface Credentials { + [key: string]: CredentialInfo; +} + export const getStoredRecordings = async (): Promise => { try { const response = await axios.get(`${apiUrl}/storage/recordings`); @@ -19,7 +28,7 @@ export const getStoredRecordings = async (): Promise => { } }; -export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise => { +export const updateRecording = async (id: string, data: { name?: string; limit?: number, credentials?: Credentials }): Promise => { try { const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data); if (response.status === 200) { diff --git a/src/components/dashboard/NavBar.tsx b/src/components/dashboard/NavBar.tsx index 663f6900..ec5fcf7c 100644 --- a/src/components/dashboard/NavBar.tsx +++ b/src/components/dashboard/NavBar.tsx @@ -4,8 +4,33 @@ import axios from 'axios'; import styled from "styled-components"; import { stopRecording } from "../../api/recording"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar, Tooltip } from "@mui/material"; -import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language, Brightness7, Brightness4, Description } from "@mui/icons-material"; +import { + IconButton, + Menu, + MenuItem, + Typography, + Chip, + Button, + Modal, + Tabs, + Tab, + Box, + Snackbar, + Tooltip +} from "@mui/material"; +import { + AccountCircle, + Logout, + Clear, + YouTube, + X, + Update, + Close, + Language, + Description, + LightMode, + DarkMode +} from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { AuthContext } from '../../context/auth'; import { SaveRecording } from '../recorder/SaveRecording'; @@ -114,7 +139,7 @@ export const NavBar: React.FC = ({ } }} > - {darkMode ? : } + {darkMode ? : } ); @@ -200,7 +225,7 @@ export const NavBar: React.FC = ({ border: "#00000099 1px solid", '&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' } }}> - {t('navbar.upgrade.button')} Maxun + {t('navbar.upgrade.button')} +

404 - Page Not Found

+

Oops! This page does not exist.

+
Take me to the homepage +
+ ); +} \ No newline at end of file diff --git a/src/components/integration/IntegrationSettings.tsx b/src/components/integration/IntegrationSettings.tsx index 3fc62979..2f251f64 100644 --- a/src/components/integration/IntegrationSettings.tsx +++ b/src/components/integration/IntegrationSettings.tsx @@ -13,7 +13,9 @@ import axios from "axios"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRecording } from "../../api/storage"; import { apiUrl } from "../../apiConfig.js"; + import Cookies from "js-cookie"; + import { useTranslation } from "react-i18next"; import { SignalCellularConnectedNoInternet0BarSharp } from "@mui/icons-material"; @@ -35,7 +37,7 @@ export interface IntegrationSettings { } -// Helper functions to replace js-cookie functionality + const getCookie = (name: string): string | null => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); @@ -49,6 +51,7 @@ const removeCookie = (name: string): void => { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; }; + export const IntegrationSettingsModal = ({ isOpen, handleStart, @@ -285,6 +288,7 @@ export const IntegrationSettingsModal = ({ } }; + // Remove Airtable integration const removeAirtableIntegration = async () => { try { @@ -314,6 +318,7 @@ export const IntegrationSettingsModal = ({ } } catch (error) { setError("Error authenticating with Airtable"); + } }; diff --git a/src/components/pickers/DatePicker.tsx b/src/components/pickers/DatePicker.tsx index 61363984..00687115 100644 --- a/src/components/pickers/DatePicker.tsx +++ b/src/components/pickers/DatePicker.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useSocketStore } from '../../context/socket'; -import { Coordinates } from './canvas'; +import { Coordinates } from '../recorder/canvas'; interface DatePickerProps { coordinates: Coordinates; diff --git a/src/components/recorder/AddWhatCondModal.tsx b/src/components/recorder/AddWhatCondModal.tsx index a6b50fc9..4dd333b5 100644 --- a/src/components/recorder/AddWhatCondModal.tsx +++ b/src/components/recorder/AddWhatCondModal.tsx @@ -3,7 +3,6 @@ import { GenericModal } from "../ui/GenericModal"; import { modalStyle } from "./AddWhereCondModal"; import { Button, MenuItem, TextField, Typography } from "@mui/material"; import React, { useRef } from "react"; -import { Dropdown as MuiDropdown } from "../ui/DropdownMui"; import { KeyValueForm } from "./KeyValueForm"; import { ClearButton } from "../ui/buttons/ClearButton"; import { useSocketStore } from "../../context/socket"; diff --git a/src/components/recorder/Highlighter.tsx b/src/components/recorder/Highlighter.tsx index 3771b149..562fd81e 100644 --- a/src/components/recorder/Highlighter.tsx +++ b/src/components/recorder/Highlighter.tsx @@ -1,5 +1,4 @@ import React from 'react'; - import styled from "styled-components"; interface HighlighterProps { diff --git a/src/components/recorder/LeftSidePanel.tsx b/src/components/recorder/LeftSidePanel.tsx index afe56ffe..939f0329 100644 --- a/src/components/recorder/LeftSidePanel.tsx +++ b/src/components/recorder/LeftSidePanel.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow"; import { useSocketStore } from '../../context/socket'; import { WhereWhatPair, WorkflowFile } from "maxun-core"; -import { SidePanelHeader } from "./SidePanelHeader"; import { emptyWorkflow } from "../../shared/constants"; import { LeftSidePanelContent } from "./LeftSidePanelContent"; import { useBrowserDimensionsStore } from "../../context/browserDimensions"; diff --git a/src/components/recorder/LeftSidePanelContent.tsx b/src/components/recorder/LeftSidePanelContent.tsx index 4a980505..745e0e31 100644 --- a/src/components/recorder/LeftSidePanelContent.tsx +++ b/src/components/recorder/LeftSidePanelContent.tsx @@ -1,15 +1,13 @@ import React, { useCallback, useEffect, useState } from 'react'; -import Box from "@mui/material/Box"; import { Pair } from "./Pair"; import { WhereWhatPair, WorkflowFile } from "maxun-core"; import { useSocketStore } from "../../context/socket"; -import { Add } from "@mui/icons-material"; import { Socket } from "socket.io-client"; import { AddButton } from "../ui/buttons/AddButton"; import { AddPair } from "../../api/workflow"; import { GenericModal } from "../ui/GenericModal"; import { PairEditForm } from "./PairEditForm"; -import { Fab, Tooltip, Typography } from "@mui/material"; +import { Tooltip } from "@mui/material"; interface LeftSidePanelContentProps { workflow: WorkflowFile; diff --git a/src/components/recorder/PairDetail.tsx b/src/components/recorder/PairDetail.tsx index d330db1d..7ed081a9 100644 --- a/src/components/recorder/PairDetail.tsx +++ b/src/components/recorder/PairDetail.tsx @@ -1,6 +1,6 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import { WhereWhatPair } from "maxun-core"; -import { Box, Button, IconButton, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material"; +import { IconButton, Stack, TextField, Tooltip, Typography } from "@mui/material"; import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; import TreeView from '@mui/lab/TreeView'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -11,7 +11,6 @@ import { WarningText } from "../ui/texts"; import NotificationImportantIcon from '@mui/icons-material/NotificationImportant'; import { RemoveButton } from "../ui/buttons/RemoveButton"; import { AddWhereCondModal } from "./AddWhereCondModal"; -import { UpdatePair } from "../../api/workflow"; import { useSocketStore } from "../../context/socket"; import { AddWhatCondModal } from "./AddWhatCondModal"; diff --git a/src/components/recorder/RightSidePanel.tsx b/src/components/recorder/RightSidePanel.tsx index deb45ff9..f324183d 100644 --- a/src/components/recorder/RightSidePanel.tsx +++ b/src/components/recorder/RightSidePanel.tsx @@ -3,7 +3,6 @@ import { Button, Paper, Box, TextField, IconButton } from "@mui/material"; import EditIcon from '@mui/icons-material/Edit'; import TextFieldsIcon from '@mui/icons-material/TextFields'; import DocumentScannerIcon from '@mui/icons-material/DocumentScanner'; -import { SimpleBox } from "../ui/Box"; import { WorkflowFile } from "maxun-core"; import Typography from "@mui/material/Typography"; import { useGlobalInfoStore } from "../../context/globalInfo"; @@ -12,15 +11,12 @@ import { useBrowserSteps } from '../../context/browserSteps'; import { useSocketStore } from '../../context/socket'; import { ScreenshotSettings } from '../../shared/types'; import InputAdornment from '@mui/material/InputAdornment'; -import { SidePanelHeader } from './SidePanelHeader'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormControl from '@mui/material/FormControl'; import FormLabel from '@mui/material/FormLabel'; import Radio from '@mui/material/Radio'; import RadioGroup from '@mui/material/RadioGroup'; -import { emptyWorkflow } from "../../shared/constants"; import { getActiveWorkflow } from "../../api/workflow"; -import DeleteIcon from '@mui/icons-material/Delete'; import ActionDescriptionBox from '../action/ActionDescriptionBox'; import { useThemeMode } from '../../context/theme-provider'; import { useTranslation } from 'react-i18next'; diff --git a/src/components/recorder/SaveRecording.tsx b/src/components/recorder/SaveRecording.tsx index 4a2d0ba4..c6b5687b 100644 --- a/src/components/recorder/SaveRecording.tsx +++ b/src/components/recorder/SaveRecording.tsx @@ -22,7 +22,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { const [recordingName, setRecordingName] = useState(fileName); const [waitingForSave, setWaitingForSave] = useState(false); - const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); + const { browserId, setBrowserId, notify, recordings, isLogin } = useGlobalInfoStore(); const { socket } = useSocketStore(); const { state, dispatch } = useContext(AuthContext); const { user } = state; @@ -59,7 +59,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => { // releases resources and changes the view for main page by clearing the global browserId const saveRecording = async () => { if (user) { - const payload = { fileName: recordingName, userId: user.id }; + const payload = { fileName: recordingName, userId: user.id, isLogin: isLogin }; socket?.emit('save', payload); setWaitingForSave(true); console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); diff --git a/src/components/recorder/canvas.tsx b/src/components/recorder/canvas.tsx index 83e48274..cc75a6ba 100644 --- a/src/components/recorder/canvas.tsx +++ b/src/components/recorder/canvas.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { useSocketStore } from '../../context/socket'; -import { getMappedCoordinates } from "../../helpers/inputHelpers"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useActionContext } from '../../context/browserActions'; import DatePicker from '../pickers/DatePicker'; diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx index 46484cb1..e63f5e1c 100644 --- a/src/components/robot/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -1,13 +1,15 @@ -import React, { useState } from "react"; +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 { IntegrationSettings, IntegrationSettingsModal } from "../integration/IntegrationSettings"; -import { RobotSettings, RobotSettingsModal } from "./RobotSettings"; +import { IntegrationSettingsModal } from "../integration/IntegrationSettings"; +import { RobotSettingsModal } from "./RobotSettings"; import { RobotEditModal } from "./RobotEdit"; import { RobotDuplicationModal } from "./RobotDuplicate"; import { useNavigate, useLocation, useParams } from "react-router-dom"; +import { useGlobalInfoStore } from "../../context/globalInfo"; +import { useTranslation } from "react-i18next"; interface RecordingsProps { handleEditRecording: (id: string, fileName: string) => void; @@ -26,6 +28,8 @@ export const Recordings = ({ 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[]) => { setParams(params); @@ -39,6 +43,31 @@ export const Recordings = ({ navigate("/robots"); // Navigate back to the main robots page }; + useEffect(() => { + // Helper function to get and clear a cookie + const getAndClearCookie = (name: string) => { + const value = document.cookie + .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 robotId = getAndClearCookie('robot_auth_robotId'); + + if (authStatus === 'success' && robotId) { + notify(authStatus, t("recordingtable.notifications.auth_success")); + + handleNavigate(`/robots/${robotId}/integrate`, robotId, "", []);'' + } + }, []); + // Determine which modal to open based on the current route const getCurrentModal = () => { const currentPath = location.pathname; diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index 4bbcb654..484b1667 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -8,11 +8,35 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; -import { useEffect } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { WorkflowFile } from "maxun-core"; import SearchIcon from '@mui/icons-material/Search'; -import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText } from "@mui/material"; -import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, MoreHoriz } from "@mui/icons-material"; +import { + IconButton, + Button, + Box, + Typography, + TextField, + MenuItem, + Menu, + ListItemIcon, + ListItemText, + CircularProgress, + RadioGroup, + FormControlLabel, + Radio, + Checkbox, +} from "@mui/material"; +import { + Schedule, + DeleteForever, + Edit, + PlayCircle, + Settings, + Power, + ContentCopy, + MoreHoriz +} from "@mui/icons-material"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { Add } from "@mui/icons-material"; @@ -52,90 +76,162 @@ interface RecordingsTableProps { handleDuplicateRobot: (id: string, name: string, params: string[]) => void; } -export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => { +// Virtualized row component for efficient rendering +const TableRowMemoized = memo(({ row, columns, handlers }: any) => { + return ( + + {columns.map((column: Column) => { + const value: any = row[column.id]; + if (value !== undefined) { + return ( + + {value} + + ); + } else { + switch (column.id) { + case 'interpret': + return ( + + handlers.handleRunRecording(row.id, row.name, row.params || [])} /> + + ); + case 'schedule': + return ( + + handlers.handleScheduleRecording(row.id, row.name, row.params || [])} /> + + ); + case 'integrate': + return ( + + handlers.handleIntegrateRecording(row.id, row.name, row.params || [])} /> + + ); + case 'options': + return ( + + handlers.handleEditRobot(row.id, row.name, row.params || [])} + handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])} + handleDelete={() => handlers.handleDelete(row.id)} + /> + + ); + case 'settings': + return ( + + handlers.handleSettingsRecording(row.id, row.name, row.params || [])} /> + + ); + default: + return null; + } + } + })} + + ); +}); + + +export const RecordingsTable = ({ + handleEditRecording, + handleRunRecording, + handleScheduleRecording, + handleIntegrateRecording, + handleSettingsRecording, + handleEditRobot, + handleDuplicateRobot }: RecordingsTableProps) => { const { t } = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState([]); const [isModalOpen, setModalOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(true); - const columns: readonly Column[] = [ + const columns = useMemo(() => [ { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, { id: 'name', label: t('recordingtable.name'), minWidth: 80 }, - { - id: 'schedule', - label: t('recordingtable.schedule'), - minWidth: 80, - }, - { - id: 'integrate', - label: t('recordingtable.integrate'), - minWidth: 80, - }, - { - id: 'settings', - label: t('recordingtable.settings'), - minWidth: 80, - }, - { - id: 'options', - label: t('recordingtable.options'), - minWidth: 80, - }, - ]; + { id: 'schedule', label: t('recordingtable.schedule'), minWidth: 80 }, + { id: 'integrate', label: t('recordingtable.integrate'), minWidth: 80 }, + { id: 'settings', label: t('recordingtable.settings'), minWidth: 80 }, + { id: 'options', label: t('recordingtable.options'), minWidth: 80 }, + ], [t]); - const { notify, setRecordings, browserId, setBrowserId, setInitialUrl, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); + const { + notify, + setRecordings, + browserId, + setBrowserId, + setInitialUrl, + recordingUrl, + setRecordingUrl, + isLogin, + setIsLogin, + recordingName, + setRecordingName, + recordingId, + setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); - const handleChangePage = (event: unknown, newPage: number) => { + const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); - }; + }, []); const handleChangeRowsPerPage = (event: React.ChangeEvent) => { setRowsPerPage(+event.target.value); setPage(0); }; - const handleSearchChange = (event: React.ChangeEvent) => { + const handleSearchChange = useCallback((event: React.ChangeEvent) => { setSearchTerm(event.target.value); setPage(0); - }; + }, []); - const fetchRecordings = async () => { - const recordings = await getStoredRecordings(); - if (recordings) { - const parsedRows: Data[] = []; - recordings.map((recording: any, index: number) => { - if (recording && recording.recording_meta) { - parsedRows.push({ - id: index, - ...recording.recording_meta, - content: recording.recording - }); - } - }); - setRecordings(parsedRows.map((recording) => recording.name)); - setRows(parsedRows); - } else { - console.log('No recordings found.'); + const fetchRecordings = useCallback(async () => { + setIsLoading(true); + try { + const recordings = await getStoredRecordings(); + if (recordings) { + const parsedRows = recordings + .map((recording: any, index: number) => { + if (recording?.recording_meta) { + return { + id: index, + ...recording.recording_meta, + content: recording.recording + }; + } + return null; + }) + .filter(Boolean); + + setRecordings(parsedRows.map((recording) => recording.name)); + setRows(parsedRows); + } + } catch (error) { + console.error('Error fetching recordings:', error); + notify('error', t('recordingtable.notifications.fetch_error')); + } finally { + setIsLoading(false); } - } + }, [setRecordings, notify, t]); - const handleNewRecording = async () => { + const handleNewRecording = useCallback(async () => { if (browserId) { setBrowserId(null); await stopRecording(browserId); } setModalOpen(true); - }; + }, [browserId]); - const handleStartRecording = () => { + const handleStartRecording = useCallback(() => { setBrowserId('new-recording'); setRecordingName(''); setRecordingId(''); navigate('/recording'); - } + }, [navigate]); const startRecording = () => { setModalOpen(false); @@ -151,14 +247,61 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl if (rows.length === 0) { fetchRecordings(); } - }, []); + }, [fetchRecordings]); + function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; + } + + const debouncedSearchTerm = useDebounce(searchTerm, 300); // Filter rows based on search term - const filteredRows = rows.filter((row) => - row.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const filteredRows = useMemo(() => { + const searchLower = debouncedSearchTerm.toLowerCase(); + return debouncedSearchTerm + ? rows.filter(row => row.name.toLowerCase().includes(searchLower)) + : rows; + }, [rows, debouncedSearchTerm]); + const visibleRows = useMemo(() => { + const start = page * rowsPerPage; + return filteredRows.slice(start, start + rowsPerPage); + }, [filteredRows, page, rowsPerPage]); + + const handlers = useMemo(() => ({ + handleRunRecording, + handleScheduleRecording, + handleIntegrateRecording, + handleSettingsRecording, + handleEditRobot, + handleDuplicateRobot, + handleDelete: async (id: string) => { + const hasRuns = await checkRunsForRecording(id); + if (hasRuns) { + notify('warning', t('recordingtable.notifications.delete_warning')); + return; + } + + const success = await deleteRecordingFromStorage(id); + if (success) { + setRows([]); + notify('success', t('recordingtable.notifications.delete_success')); + fetchRecordings(); + } + } + }), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, notify, t]); return ( @@ -200,103 +343,42 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl - - - - - {columns.map((column) => ( - - {column.label} - + {isLoading ? ( + + + + ) : ( + +
+ + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {visibleRows.map((row) => ( + ))} - - - - {filteredRows.length !== 0 ? filteredRows - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((row) => { - return ( - - {columns.map((column) => { - // @ts-ignore - const value: any = row[column.id]; - if (value !== undefined) { - return ( - - {value} - - ); - } else { - switch (column.id) { - case 'interpret': - return ( - - handleRunRecording(row.id, row.name, row.params || [])} /> - - ); - case 'schedule': - return ( - - handleScheduleRecording(row.id, row.name, row.params || [])} /> - - ); - case 'integrate': - return ( - - handleIntegrateRecording(row.id, row.name, row.params || [])} /> - - ); - case 'options': - return ( - - handleEditRobot(row.id, row.name, row.params || [])} - handleDuplicate={() => { - handleDuplicateRobot(row.id, row.name, row.params || []); - }} - handleDelete={() => { + +
+
+ )} - checkRunsForRecording(row.id).then((result: boolean) => { - if (result) { - notify('warning', t('recordingtable.notifications.delete_warning')); - } - }) - - deleteRecordingFromStorage(row.id).then((result: boolean) => { - if (result) { - setRows([]); - notify('success', t('recordingtable.notifications.delete_success')); - fetchRecordings(); - } - }) - }} - /> - - ); - case 'settings': - return ( - - handleSettingsRecording(row.id, row.name, row.params || [])} /> - - ); - default: - return null; - } - } - })} - - ); - }) - : null} - - - setModalOpen(false)} modalStyle={modalStyle}> -
+
{t('recordingtable.modal.title')} + + setIsLogin(e.target.checked)} + color="primary" + /> + } + label={t('recordingtable.modal.login_title')} + style={{ marginBottom: '10px' }} + /> + +
- - - - ) - } + {(Object.keys(credentials).length > 0) && ( + <> + + {t('Input Texts')} + + {renderAllCredentialFields()} + + )} + + + + + + + )} ); -}; +}; \ No newline at end of file diff --git a/src/components/robot/ScheduleSettings.tsx b/src/components/robot/ScheduleSettings.tsx index 658ce594..71951646 100644 --- a/src/components/robot/ScheduleSettings.tsx +++ b/src/components/robot/ScheduleSettings.tsx @@ -190,7 +190,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia sx={dropDownStyle} > {units.map((unit) => ( - {unit} + {unit.charAt(0).toUpperCase() + unit.slice(1).toLowerCase()} ))} @@ -207,7 +207,9 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia sx={dropDownStyle} > {days.map((day) => ( - {day} + + {day.charAt(0).toUpperCase() + day.slice(1).toLowerCase()} + ))} @@ -265,7 +267,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia sx={dropDownStyle} > {validMomentTimezones.map((tz) => ( - {tz} + {tz.charAt(0).toUpperCase() + tz.slice(1).toLowerCase()} ))} diff --git a/src/components/run/InterpretationButtons.tsx b/src/components/run/InterpretationButtons.tsx index 0edcf682..a6ebb1bb 100644 --- a/src/components/run/InterpretationButtons.tsx +++ b/src/components/run/InterpretationButtons.tsx @@ -1,7 +1,6 @@ import { Box, Button, Stack, Typography, CircularProgress } from "@mui/material"; -import { PlayCircle } from "@mui/icons-material"; import React, { useCallback, useEffect, useState } from "react"; -import { interpretCurrentRecording, stopCurrentInterpretation } from "../../api/recording"; +import { interpretCurrentRecording } from "../../api/recording"; import { useSocketStore } from "../../context/socket"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { GenericModal } from "../ui/GenericModal"; diff --git a/src/components/run/RunContent.tsx b/src/components/run/RunContent.tsx index fa181319..cb3b63a6 100644 --- a/src/components/run/RunContent.tsx +++ b/src/components/run/RunContent.tsx @@ -1,4 +1,4 @@ -import { Box, Tabs, Typography, Tab, Paper, Button } from "@mui/material"; +import { Box, Tabs, Typography, Tab, Paper, Button, CircularProgress } from "@mui/material"; import Highlight from "react-highlight"; import * as React from "react"; import { Data } from "./RunsTable"; @@ -148,9 +148,15 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe : null} - {!row || !row.serializableOutput || !row.binaryOutput + {interpretationInProgress ? ( + + + {t('run_content.loading')} + + ) : (!row || !row.serializableOutput || !row.binaryOutput || (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0) - ? {t('run_content.empty_output')} : null} + ? {t('run_content.empty_output')} + : null)} {row.serializableOutput && Object.keys(row.serializableOutput).length !== 0 && diff --git a/src/components/run/RunSettings.tsx b/src/components/run/RunSettings.tsx index a35d2f28..33427d16 100644 --- a/src/components/run/RunSettings.tsx +++ b/src/components/run/RunSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from "react"; import { GenericModal } from "../ui/GenericModal"; import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material"; import { Dropdown } from "../ui/DropdownMui"; @@ -28,6 +28,23 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par }); const [showInterpreterSettings, setShowInterpreterSettings] = useState(false); + const hasRun = useRef(false); + + useEffect(() => { + if (!isOpen) { + hasRun.current = false; + return; + } + + if (!showInterpreterSettings && !hasRun.current) { + hasRun.current = true; + handleStart(settings); + } + }, [isOpen, showInterpreterSettings, settings, handleStart]); + + if (!showInterpreterSettings) { + return null; + } return ( -
+
{isTask && ( - Recording parameters: + + Recording parameters: + {params?.map((item, index) => ( setShowInterpreterSettings(!showInterpreterSettings)} />} + control={ + + setShowInterpreterSettings(!showInterpreterSettings) + } + /> + } label="Developer Mode Settings" - sx={{ margin: '20px 0px' }} + sx={{ margin: "20px 0px" }} /> {showInterpreterSettings && ( )} - +
); diff --git a/src/components/run/RunsTable.tsx b/src/components/run/RunsTable.tsx index b652aef0..92233214 100644 --- a/src/components/run/RunsTable.tsx +++ b/src/components/run/RunsTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from 'react-i18next'; import Paper from '@mui/material/Paper'; import Table from '@mui/material/Table'; @@ -9,7 +9,7 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; -import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material'; +import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; import { useNavigate } from 'react-router-dom'; @@ -17,8 +17,8 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRuns } from "../../api/storage"; import { RunSettings } from "./RunSettings"; import { CollapsibleRow } from "./ColapsibleRow"; +import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material'; -// Export columns before the component export const columns: readonly Column[] = [ { id: 'runStatus', label: 'Status', minWidth: 80 }, { id: 'name', label: 'Name', minWidth: 80 }, @@ -28,6 +28,15 @@ export const columns: readonly Column[] = [ { id: 'delete', label: 'Delete', minWidth: 80 }, ]; +type SortDirection = 'asc' | 'desc' | 'none'; + +interface AccordionSortConfig { + [robotMetaId: string]: { + field: keyof Data | null; + direction: SortDirection; + }; +} + interface Column { id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings'; label: string; @@ -70,91 +79,223 @@ export const RunsTable: React.FC = ({ const { t } = useTranslation(); const navigate = useNavigate(); - // Update column labels using translation if needed - const translatedColumns = columns.map(column => ({ - ...column, - label: t(`runstable.${column.id}`, column.label) - })); + const [accordionSortConfigs, setAccordionSortConfigs] = useState({}); + + const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => { + setAccordionSortConfigs(prevConfigs => { + const currentConfig = prevConfigs[robotMetaId] || { field: null, direction: 'none' }; + const newDirection: SortDirection = + currentConfig.field !== columnId ? 'asc' : + currentConfig.direction === 'none' ? 'asc' : + currentConfig.direction === 'asc' ? 'desc' : 'none'; + + return { + ...prevConfigs, + [robotMetaId]: { + field: newDirection === 'none' ? null : columnId, + direction: newDirection, + } + }; + }); + }, []); + + const translatedColumns = useMemo(() => + columns.map(column => ({ + ...column, + label: t(`runstable.${column.id}`, column.label) + })), + [t] + ); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [rows, setRows] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(true); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); - const handleAccordionChange = (robotMetaId: string, isExpanded: boolean) => { - if (isExpanded) { - navigate(`/runs/${robotMetaId}`); - } else { - navigate(`/runs`); - } - }; + const handleAccordionChange = useCallback((robotMetaId: string, isExpanded: boolean) => { + navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs'); + }, [navigate]); - const handleChangePage = (event: unknown, newPage: number) => { + const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); - }; + }, []); - const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent) => { setRowsPerPage(+event.target.value); setPage(0); - }; + }, []); - const handleSearchChange = (event: React.ChangeEvent) => { - setSearchTerm(event.target.value); - setPage(0); - }; + const debouncedSearch = useCallback((fn: Function, delay: number) => { + let timeoutId: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; + }, []); - const fetchRuns = async () => { - const runs = await getStoredRuns(); - if (runs) { - const parsedRows: Data[] = runs.map((run: any, index: number) => ({ - id: index, - ...run, - })); - setRows(parsedRows); - } else { - notify('error', t('runstable.notifications.no_runs')); + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + const debouncedSetSearch = debouncedSearch((value: string) => { + setSearchTerm(value); + setPage(0); + }, 300); + debouncedSetSearch(event.target.value); + }, [debouncedSearch]); + + const fetchRuns = useCallback(async () => { + try { + setIsLoading(true); + const runs = await getStoredRuns(); + if (runs) { + const parsedRows: Data[] = runs.map((run: any, index: number) => ({ + id: index, + ...run, + })); + setRows(parsedRows); + } else { + notify('error', t('runstable.notifications.no_runs')); + } + } catch (error) { + notify('error', t('runstable.notifications.fetch_error')); + } finally { + setIsLoading(false); } - }; + }, [notify, t]); useEffect(() => { - if (rows.length === 0 || rerenderRuns) { - fetchRuns(); - setRerenderRuns(false); - } - }, [rerenderRuns, rows.length, setRerenderRuns]); + let mounted = true; - const handleDelete = () => { + if (rows.length === 0 || rerenderRuns) { + fetchRuns().then(() => { + if (mounted) { + setRerenderRuns(false); + } + }); + } + + return () => { + mounted = false; + }; + }, [rerenderRuns, rows.length, setRerenderRuns, fetchRuns]); + + const handleDelete = useCallback(() => { setRows([]); notify('success', t('runstable.notifications.delete_success')); fetchRuns(); - }; + }, [notify, t, fetchRuns]); // Filter rows based on search term - const filteredRows = rows.filter((row) => - row.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const filteredRows = useMemo(() => { + let result = rows.filter((row) => + row.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + return result; + }, [rows, searchTerm]); // Group filtered rows by robot meta id - const groupedRows = filteredRows.reduce((acc, row) => { - if (!acc[row.robotMetaId]) { - acc[row.robotMetaId] = []; + const groupedRows = useMemo(() => + filteredRows.reduce((acc, row) => { + if (!acc[row.robotMetaId]) { + acc[row.robotMetaId] = []; + } + acc[row.robotMetaId].push(row); + return acc; + }, {} as Record), + [filteredRows] + ); + + const parseDateString = (dateStr: string): Date => { + try { + if (dateStr.includes('PM') || dateStr.includes('AM')) { + return new Date(dateStr); + } + + return new Date(dateStr.replace(/(\d+)\/(\d+)\//, '$2/$1/')) + } catch { + return new Date(0); } - acc[row.robotMetaId].push(row); - return acc; - }, {} as Record); + }; + + const renderTableRows = useCallback((data: Data[], robotMetaId: string) => { + const start = page * rowsPerPage; + const end = start + rowsPerPage; + + let sortedData = [...data]; + const sortConfig = accordionSortConfigs[robotMetaId]; + + if (sortConfig?.field === 'startedAt' || sortConfig?.field === 'finishedAt') { + if (sortConfig.direction !== 'none') { + sortedData.sort((a, b) => { + const dateA = parseDateString(a[sortConfig.field!]); + const dateB = parseDateString(b[sortConfig.field!]); + + return sortConfig.direction === 'asc' + ? dateA.getTime() - dateB.getTime() + : dateB.getTime() - dateA.getTime(); + }); + } + } + + return sortedData + .slice(start, end) + .map((row) => ( + + )); + }, [page, rowsPerPage, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]); + + const renderSortIcon = useCallback((column: Column, robotMetaId: string) => { + const sortConfig = accordionSortConfigs[robotMetaId]; + if (column.id !== 'startedAt' && column.id !== 'finishedAt') return null; + + if (sortConfig?.field !== column.id) { + return ( + + ); + } + + return sortConfig.direction === 'asc' + ? + : sortConfig.direction === 'desc' + ? + : ; + }, [accordionSortConfigs]); + + if (isLoading) { + return ( + + + + ); + } return ( - + {t('runstable.runs', 'Runs')} @@ -162,9 +303,14 @@ export const RunsTable: React.FC = ({ sx={{ width: '250px' }} /> + - {Object.entries(groupedRows).map(([id, data]) => ( - handleAccordionChange(id, isExpanded)}> + {Object.entries(groupedRows).map(([robotMetaId, data]) => ( + handleAccordionChange(robotMetaId, isExpanded)} + TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering + > }> {data[data.length - 1].name} @@ -177,41 +323,65 @@ export const RunsTable: React.FC = ({ { + if (column.id === 'startedAt' || column.id === 'finishedAt') { + handleSort(column.id, robotMetaId); + } + }} > - {column.label} + + + {column.label} + + {renderSortIcon(column, robotMetaId)} + + + ))} - {data - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((row) => ( - - ))} + {renderTableRows(data, robotMetaId)} ))} + ); diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index 92ce7e62..28d65b34 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -10,6 +10,8 @@ interface GlobalInfo { notification: AlertSnackbarProps; notify: (severity: 'error' | 'warning' | 'info' | 'success', message: string) => void; closeNotify: () => void; + isLogin: boolean; + setIsLogin: (isLogin: boolean) => void; recordings: string[]; setRecordings: (recordings: string[]) => void; rerenderRuns: boolean; @@ -53,6 +55,7 @@ class GlobalInfoStore implements Partial { recordingName = ''; initialUrl = 'https://'; recordingUrl = 'https://'; + isLogin = false; currentWorkflowActionsState = { hasScrapeListAction: false, hasScreenshotAction: false, @@ -75,6 +78,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); + const [isLogin, setIsLogin] = useState(globalInfoStore.isLogin); const [initialUrl, setInitialUrl] = useState(globalInfoStore.initialUrl); const [recordingUrl, setRecordingUrl] = useState(globalInfoStore.recordingUrl); const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState); @@ -127,6 +131,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { setInitialUrl, recordingUrl, setRecordingUrl, + isLogin, + setIsLogin, currentWorkflowActionsState, setCurrentWorkflowActionsState, shouldResetInterpretationLog, diff --git a/src/context/theme-provider.tsx b/src/context/theme-provider.tsx index d53ced24..75dd4b8b 100644 --- a/src/context/theme-provider.tsx +++ b/src/context/theme-provider.tsx @@ -86,6 +86,12 @@ const darkTheme = createTheme({ main: "#ff00c3", contrastText: "#ffffff", }, + error: { + main: '#f44336', + light: '#e57373', + dark: '#d32f2f', + contrastText: '#ffffff', + }, background: { default: '#121212', paper: '#1e1e1e', @@ -124,6 +130,14 @@ const darkTheme = createTheme({ backgroundColor: 'rgba(255, 0, 195, 0.08)', borderColor: '#ff66d9', }, + '&.MuiButton-outlinedError': { + borderColor: '#f44336', + color: '#f44336', + "&:hover": { + backgroundColor: 'rgba(244, 67, 54, 0.08)', + borderColor: '#d32f2f', + }, + }, }, }, }, @@ -144,6 +158,12 @@ const darkTheme = createTheme({ "&:hover": { backgroundColor: 'rgba(255, 0, 195, 0.08)', }, + '&.MuiIconButton-colorError': { + color: '#f44336', + "&:hover": { + backgroundColor: 'rgba(244, 67, 54, 0.08)', + }, + }, }, }, }, @@ -218,7 +238,7 @@ const darkTheme = createTheme({ }); const ThemeModeContext = createContext({ - toggleTheme: () => {}, + toggleTheme: () => { }, darkMode: false, }); diff --git a/src/pages/PageWrappper.tsx b/src/pages/PageWrapper.tsx similarity index 96% rename from src/pages/PageWrappper.tsx rename to src/pages/PageWrapper.tsx index d778c186..1e73f26a 100644 --- a/src/pages/PageWrappper.tsx +++ b/src/pages/PageWrapper.tsx @@ -12,7 +12,7 @@ import Login from './Login'; import Register from './Register'; import UserRoute from '../routes/userRoute'; import { Routes, Route, useNavigate, Navigate } from 'react-router-dom'; -import { Runs } from '../components/run/Runs'; +import { NotFoundPage } from '../components/dashboard/NotFound'; export const PageWrapper = () => { const [open, setOpen] = useState(false); @@ -75,6 +75,7 @@ export const PageWrapper = () => { path="/register" element={} /> + } />