Merge branch 'develop' into integration_airtable

This commit is contained in:
Rohit
2025-02-26 14:15:22 +05:30
committed by GitHub
26 changed files with 1455 additions and 660 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun-core", "name": "maxun-core",
"version": "0.0.10", "version": "0.0.12",
"description": "Core package for Maxun, responsible for data extraction", "description": "Core package for Maxun, responsible for data extraction",
"main": "build/index.js", "main": "build/index.js",
"typings": "build/index.d.ts", "typings": "build/index.d.ts",

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-await-in-loop, no-restricted-syntax */ /* eslint-disable no-await-in-loop, no-restricted-syntax */
import { Page, PageScreenshotOptions } from 'playwright'; import { ElementHandle, Page, PageScreenshotOptions } from 'playwright';
import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright';
import fetch from 'cross-fetch'; import fetch from 'cross-fetch';
import path from 'path'; import path from 'path';
@@ -286,6 +286,12 @@ export default class Interpreter extends EventEmitter {
? arrayToObject(<any>superset[key]) ? arrayToObject(<any>superset[key])
: superset[key]; : superset[key];
if (key === 'selectors' && Array.isArray(value) && Array.isArray(superset[key])) {
return value.some(selector =>
(superset[key] as any[]).includes(selector)
);
}
// Every `subset` key must exist in the `superset` and // Every `subset` key must exist in the `superset` and
// have the same value (strict equality), or subset[key] <= superset[key] // have the same value (strict equality), or subset[key] <= superset[key]
return parsedSuperset[key] return parsedSuperset[key]
@@ -548,232 +554,312 @@ export default class Interpreter extends EventEmitter {
} }
} }
private async handlePagination(page: Page, config: { listSelector: string, fields: any, limit?: number, pagination: any }) { private async handlePagination(page: Page, config: {
listSelector: string,
fields: any,
limit?: number,
pagination: any
}) {
let allResults: Record<string, any>[] = []; let allResults: Record<string, any>[] = [];
let previousHeight = 0; let previousHeight = 0;
// track unique items per page to avoid re-scraping
let scrapedItems: Set<string> = new Set<string>(); let scrapedItems: Set<string> = new Set<string>();
let visitedUrls: string[] = []; let visitedUrls: Set<string> = new Set<string>();
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second delay between retries
// Debug logging helper
const debugLog = (message: string, ...args: any[]) => { const debugLog = (message: string, ...args: any[]) => {
console.log(`[Page ${visitedUrls.length + 1}] ${message}`, ...args); console.log(`[Page ${visitedUrls.size}] [URL: ${page.url()}] ${message}`, ...args);
};
const scrapeCurrentPage = async () => {
const results = await page.evaluate((cfg) => window.scrapeList(cfg), config);
const newResults = results.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:", allResults.length);
};
const checkLimit = () => {
if (config.limit && allResults.length >= config.limit) {
allResults = allResults.slice(0, config.limit);
return true;
}
return false;
};
// Enhanced button finder with retry mechanism
const findWorkingButton = async (selectors: string[], retryCount = 0): Promise<{
button: ElementHandle | null,
workingSelector: string | null
}> => {
for (const selector of selectors) {
try {
const button = await page.waitForSelector(selector, {
state: 'attached',
timeout: 10000 // Reduced timeout for faster checks
});
if (button) {
debugLog('Found working selector:', selector);
return { button, workingSelector: selector };
}
} catch (error) {
debugLog(`Selector failed: ${selector}`);
}
}
// Implement retry mechanism when no selectors work
if (selectors.length > 0 && retryCount < MAX_RETRIES) {
debugLog(`Retry attempt ${retryCount + 1} of ${MAX_RETRIES}`);
await page.waitForTimeout(RETRY_DELAY);
return findWorkingButton(selectors, retryCount + 1);
}
return { button: null, workingSelector: null };
};
const retryOperation = async (operation: () => Promise<boolean>, retryCount = 0): Promise<boolean> => {
try {
return await operation();
} catch (error) {
if (retryCount < MAX_RETRIES) {
debugLog(`Retrying operation. Attempt ${retryCount + 1} of ${MAX_RETRIES}`);
await page.waitForTimeout(RETRY_DELAY);
return retryOperation(operation, retryCount + 1);
}
debugLog(`Operation failed after ${MAX_RETRIES} retries`);
return false;
}
}; };
let availableSelectors = config.pagination.selector.split(','); let availableSelectors = config.pagination.selector.split(',');
while (true) { try {
while (true) {
// Reduced timeout for faster performance
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
switch (config.pagination.type) { switch (config.pagination.type) {
case 'scrollDown': case 'scrollDown': {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const currentHeight = await page.evaluate(() => document.body.scrollHeight); const currentHeight = await page.evaluate(() => document.body.scrollHeight);
if (currentHeight === previousHeight) { if (currentHeight === previousHeight) {
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(finalResults); allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }
previousHeight = currentHeight; previousHeight = currentHeight;
break; break;
}
case 'scrollUp': case 'scrollUp': {
await page.evaluate(() => window.scrollTo(0, 0)); await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop); const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
if (currentTopHeight === 0) { if (currentTopHeight === 0) {
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(finalResults); allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }
previousHeight = currentTopHeight; previousHeight = currentTopHeight;
break; break;
}
case 'clickNext': case 'clickNext': {
debugLog("Current URL:", page.url()); const currentUrl = page.url();
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); visitedUrls.add(currentUrl);
// Filter out already scraped items await scrapeCurrentPage();
const newResults = pageResults.filter(item => { if (checkLimit()) return allResults;
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);
}
await page.waitForLoadState('networkidle', { timeout: 30000 }); const { button, workingSelector } = await findWorkingButton(availableSelectors);
await page.waitForTimeout(2000); if (!button || !workingSelector) {
// Final retry for navigation when no selectors work
let checkButton = null; const success = await retryOperation(async () => {
let workingSelector = null;
// Try each selector with explicit waiting
for (const selector of availableSelectors) {
try { try {
checkButton = await page.waitForSelector(selector, { await page.evaluate(() => window.history.forward());
state: 'attached', const newUrl = page.url();
timeout: 30000 return !visitedUrls.has(newUrl);
}); } catch {
if (checkButton) { return false;
workingSelector = selector;
debugLog('Found working selector:', selector);
break;
}
} catch (error) {
debugLog(`Selector failed: ${selector} - ${error.message}`);
} }
} });
if (!success) return allResults;
break;
}
if (!workingSelector) { availableSelectors = availableSelectors.slice(
debugLog('No working selector found after trying all options'); availableSelectors.indexOf(workingSelector)
return allResults; );
}
const nextButton = await page.$(workingSelector); let retryCount = 0;
if (!nextButton) { let navigationSuccess = false;
debugLog('Next button not found');
return allResults;
}
const selectorIndex = availableSelectors.indexOf(workingSelector);
availableSelectors = availableSelectors.slice(selectorIndex);
while (retryCount < MAX_RETRIES && !navigationSuccess) {
try { try {
// Store current URL to check if navigation succeeded
const previousUrl = page.url();
visitedUrls.push(previousUrl);
// Try both click methods in sequence
try { try {
await Promise.all([ await Promise.all([
page.waitForNavigation({ page.waitForNavigation({
waitUntil: 'networkidle', waitUntil: 'networkidle',
timeout: 15000 timeout: 15000
}), }),
nextButton.click() button.click()
]); ]);
} catch (error) { navigationSuccess = true;
// If we're still on the same URL, try dispatch event } catch (error) {
if (page.url() === previousUrl) { debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`);
await Promise.all([
page.waitForNavigation({ // If regular click fails, try dispatchEvent
waitUntil: 'networkidle', if (page.url() === currentUrl) {
timeout: 15000 try {
}), await Promise.all([
nextButton.dispatchEvent('click') page.waitForNavigation({
]); waitUntil: 'networkidle',
timeout: 15000
}),
button.dispatchEvent('click')
]);
navigationSuccess = true;
} catch (dispatchError) {
debugLog(`DispatchEvent failed on attempt ${retryCount + 1}.`);
}
} else {
navigationSuccess = true;
} }
} }
await page.waitForLoadState('domcontentloaded'); const newUrl = page.url();
await page.waitForLoadState('networkidle', { timeout: 30000 }); if (visitedUrls.has(newUrl)) {
debugLog(`Detected navigation to previously visited URL ${newUrl} on attempt ${retryCount + 1}`);
const currentUrl = page.url(); navigationSuccess = false;
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 if (navigationSuccess) {
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
}
} catch (error) { } catch (error) {
debugLog(`Navigation failed completely: ${error.message}`); debugLog(`Navigation attempt ${retryCount + 1} failed completely.`);
navigationSuccess = false;
}
if (!navigationSuccess) {
retryCount++;
if (retryCount < MAX_RETRIES) {
debugLog(`Retrying navigation - attempt ${retryCount + 1} of ${MAX_RETRIES}`);
await page.waitForTimeout(RETRY_DELAY);
}
}
}
if (!navigationSuccess) {
debugLog(`Navigation failed after ${MAX_RETRIES} attempts`);
return allResults;
}
break;
}
case 'clickLoadMore': {
while (true) {
// Find working button with retry mechanism, consistent with clickNext
const { button: loadMoreButton, workingSelector } = await findWorkingButton(availableSelectors);
if (!workingSelector || !loadMoreButton) {
debugLog('No working Load More selector found after retries');
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }
break;
// Update available selectors to start from the working one
case 'clickLoadMore': availableSelectors = availableSelectors.slice(
while (true) { availableSelectors.indexOf(workingSelector)
let checkButton = null; );
let workingSelector = null;
// Implement retry mechanism for clicking the button
for (const selector of availableSelectors) { let retryCount = 0;
try { let clickSuccess = false;
checkButton = await page.waitForSelector(selector, {
state: 'attached', while (retryCount < MAX_RETRIES && !clickSuccess) {
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 {
try { try {
await loadMoreButton.click(); await loadMoreButton.click();
clickSuccess = true;
} catch (error) { } catch (error) {
await loadMoreButton.dispatchEvent('click'); debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`);
// If regular click fails, try dispatchEvent
try {
await loadMoreButton.dispatchEvent('click');
clickSuccess = true;
} catch (dispatchError) {
debugLog(`DispatchEvent failed on attempt ${retryCount + 1}.`);
throw dispatchError; // Propagate error to trigger retry
}
}
if (clickSuccess) {
await page.waitForTimeout(1000);
} }
} catch (error) { } catch (error) {
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config); debugLog(`Click attempt ${retryCount + 1} failed completely.`);
allResults = allResults.concat(finalResults); retryCount++;
return allResults;
} if (retryCount < MAX_RETRIES) {
debugLog(`Retrying click - attempt ${retryCount + 1} of ${MAX_RETRIES}`);
await page.waitForTimeout(2000); await page.waitForTimeout(RETRY_DELAY);
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;
} }
} }
break;
if (!clickSuccess) {
default: debugLog(`Load More clicking failed after ${MAX_RETRIES} attempts`);
const results = await page.evaluate((cfg) => window.scrapeList(cfg), config); const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
allResults = allResults.concat(results); allResults = allResults.concat(finalResults);
return allResults; return allResults;
} }
if (config.limit && allResults.length >= config.limit) { // Wait for content to load and check scroll height
allResults = allResults.slice(0, config.limit); 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;
}
}
break; break;
}
default: {
await scrapeCurrentPage();
return allResults;
}
} }
if (checkLimit()) break;
}
} catch (error) {
debugLog(`Fatal error: ${error.message}`);
return allResults;
} }
return allResults; return allResults;

View File

@@ -1,6 +1,6 @@
{ {
"name": "maxun", "name": "maxun",
"version": "0.0.8", "version": "0.0.10",
"author": "Maxun", "author": "Maxun",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
@@ -51,7 +51,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",
"loglevel-plugin-remote": "^0.6.8", "loglevel-plugin-remote": "^0.6.8",
"maxun-core": "^0.0.10", "maxun-core": "^0.0.12",
"minio": "^8.0.1", "minio": "^8.0.1",
"moment-timezone": "^0.5.45", "moment-timezone": "^0.5.45",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",

View File

@@ -8,7 +8,16 @@
"register_prompt": "Noch keinen Account?", "register_prompt": "Noch keinen Account?",
"register_link": "Registrieren", "register_link": "Registrieren",
"welcome_notification": "Willkommen bei Maxun!", "welcome_notification": "Willkommen bei Maxun!",
"error_notification": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." "validation": {
"required_fields": "E-Mail und Passwort sind erforderlich",
"password_length": "Das Passwort muss mindestens 6 Zeichen lang sein"
},
"error": {
"user_not_found": "Benutzer existiert nicht",
"invalid_credentials": "Ungültige E-Mail oder Passwort",
"server_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es später erneut",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut"
}
}, },
"register": { "register": {
"title": "Konto registrieren", "title": "Konto registrieren",
@@ -19,7 +28,16 @@
"register_prompt": "Bereits ein Konto?", "register_prompt": "Bereits ein Konto?",
"login_link": "Einloggen", "login_link": "Einloggen",
"welcome_notification": "Willkommen bei Maxun!", "welcome_notification": "Willkommen bei Maxun!",
"error_notification": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut." "validation": {
"email_required": "E-Mail-Adresse ist erforderlich",
"password_requirements": "Das Passwort muss mindestens 6 Zeichen lang sein"
},
"error": {
"user_exists": "Ein Benutzer mit dieser E-Mail existiert bereits",
"creation_failed": "Konto konnte nicht erstellt werden",
"server_error": "Serverfehler aufgetreten",
"generic": "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut"
}
}, },
"recordingtable": { "recordingtable": {
"run": "Ausführen", "run": "Ausführen",
@@ -32,7 +50,7 @@
"new": "Roboter erstellen", "new": "Roboter erstellen",
"modal": { "modal": {
"title": "Geben Sie die URL ein", "title": "Geben Sie die URL ein",
"login_title": "Erfordert diese Seite eine Anmeldung?", "login_title": "Ist für diese Website eine Anmeldung erforderlich?",
"label": "URL", "label": "URL",
"button": "Aufnahme starten" "button": "Aufnahme starten"
}, },
@@ -469,6 +487,19 @@
}, },
"navbar": { "navbar": {
"project_name": "Maxun", "project_name": "Maxun",
"notifications": {
"success": {
"logout": "Erfolgreich abgemeldet"
},
"errors": {
"logout": {
"unauthorized": "Sie sind nicht berechtigt, diese Aktion durchzuführen",
"server": "Serverfehler beim Abmelden",
"network": "Netzwerkfehler beim Abmelden",
"unknown": "Ein unerwarteter Fehler ist beim Abmelden aufgetreten"
}
}
},
"upgrade": { "upgrade": {
"button": "Upgrade", "button": "Upgrade",
"modal": { "modal": {

View File

@@ -8,10 +8,19 @@
"register_prompt": "Don't have an account?", "register_prompt": "Don't have an account?",
"register_link": "Register", "register_link": "Register",
"welcome_notification": "Welcome to Maxun!", "welcome_notification": "Welcome to Maxun!",
"error_notification": "Login Failed. Please try again." "validation": {
"required_fields": "Email and password are required",
"password_length": "Password must be at least 6 characters"
},
"error": {
"user_not_found": "User does not exist",
"invalid_credentials": "Invalid email or password",
"server_error": "Login failed. Please try again later",
"generic": "An error occurred. Please try again"
}
}, },
"register": { "register": {
"title": "Register Account", "title": "Create An Account",
"email": "Enter Work Email", "email": "Enter Work Email",
"password": "Password", "password": "Password",
"button": "Register", "button": "Register",
@@ -19,7 +28,16 @@
"register_prompt": "Already have an account?", "register_prompt": "Already have an account?",
"login_link": "Login", "login_link": "Login",
"welcome_notification": "Welcome to Maxun!", "welcome_notification": "Welcome to Maxun!",
"error_notification": "Registeration Failed. Please try again." "validation": {
"email_required": "Email is required",
"password_requirements": "Password must be at least 6 characters"
},
"error": {
"user_exists": "User with this email already exists",
"creation_failed": "Could not create account",
"server_error": "Server error occurred",
"generic": "Registration failed. Please try again"
}
}, },
"recordingtable":{ "recordingtable":{
"run": "Run", "run": "Run",
@@ -32,7 +50,7 @@
"new":"Create Robot", "new":"Create Robot",
"modal":{ "modal":{
"title":"Enter the URL", "title":"Enter the URL",
"login_title": "Does this website require login?", "login_title": "Does this website require logging in?",
"label":"URL", "label":"URL",
"button":"Start Recording" "button":"Start Recording"
}, },
@@ -480,6 +498,19 @@
}, },
"navbar": { "navbar": {
"project_name": "Maxun", "project_name": "Maxun",
"notifications": {
"success": {
"logout": "Logged out successfully"
},
"errors": {
"logout": {
"unauthorized": "You are not authorized to perform this action",
"server": "Server error occurred during logout",
"network": "Network error while logging out",
"unknown": "An unexpected error occurred during logout"
}
}
},
"upgrade": { "upgrade": {
"button": "Upgrade", "button": "Upgrade",
"modal": { "modal": {

View File

@@ -8,7 +8,16 @@
"register_prompt": "¿No tienes una cuenta?", "register_prompt": "¿No tienes una cuenta?",
"register_link": "Registrarse", "register_link": "Registrarse",
"welcome_notification": "¡Bienvenido a Maxun!", "welcome_notification": "¡Bienvenido a Maxun!",
"error_notification": "Error al iniciar sesión. Por favor, inténtalo de nuevo." "validation": {
"required_fields": "El correo electrónico y la contraseña son obligatorios",
"password_length": "La contraseña debe tener al menos 6 caracteres"
},
"error": {
"user_not_found": "El usuario no existe",
"invalid_credentials": "Correo electrónico o contraseña inválidos",
"server_error": "Error al iniciar sesión. Por favor, inténtelo de nuevo más tarde",
"generic": "Se produjo un error. Por favor, inténtelo de nuevo"
}
}, },
"register": { "register": {
"title": "Crear cuenta", "title": "Crear cuenta",
@@ -19,7 +28,16 @@
"register_prompt": "¿Ya tienes una cuenta?", "register_prompt": "¿Ya tienes una cuenta?",
"login_link": "Iniciar sesión", "login_link": "Iniciar sesión",
"welcome_notification": "¡Bienvenido a Maxun!", "welcome_notification": "¡Bienvenido a Maxun!",
"error_notification": "Error en el registro. Por favor, inténtalo de nuevo." "validation": {
"email_required": "El correo electrónico es obligatorio",
"password_requirements": "La contraseña debe tener al menos 6 caracteres"
},
"error": {
"user_exists": "Ya existe un usuario con este correo electrónico",
"creation_failed": "No se pudo crear la cuenta",
"server_error": "Ocurrió un error en el servidor",
"generic": "Error en el registro. Por favor, inténtelo de nuevo"
}
}, },
"recordingtable": { "recordingtable": {
"run": "Ejecutar", "run": "Ejecutar",
@@ -32,7 +50,7 @@
"new": "Crear Robot", "new": "Crear Robot",
"modal": { "modal": {
"title": "Ingresa la URL", "title": "Ingresa la URL",
"login_title": "¿Este sitio requiere inicio de sesión?", "login_title": "¿Este sitio web requiere iniciar sesión?",
"label": "URL", "label": "URL",
"button": "Comenzar grabación" "button": "Comenzar grabación"
}, },
@@ -470,6 +488,19 @@
}, },
"navbar": { "navbar": {
"project_name": "Maxun", "project_name": "Maxun",
"notifications": {
"success": {
"logout": "Sesión cerrada exitosamente"
},
"errors": {
"logout": {
"unauthorized": "No estás autorizado para realizar esta acción",
"server": "Error del servidor durante el cierre de sesión",
"network": "Error de red al cerrar sesión",
"unknown": "Ocurrió un error inesperado al cerrar sesión"
}
}
},
"upgrade": { "upgrade": {
"button": "Actualizar", "button": "Actualizar",
"modal": { "modal": {

View File

@@ -8,7 +8,16 @@
"register_prompt": "アカウントをお持ちでないですか?", "register_prompt": "アカウントをお持ちでないですか?",
"register_link": "登録する", "register_link": "登録する",
"welcome_notification": "Maxunへようこそ", "welcome_notification": "Maxunへようこそ",
"error_notification": "ログインに失敗しました。もう一度お試しください。" "validation": {
"required_fields": "メールアドレスとパスワードは必須です",
"password_length": "パスワードは6文字以上である必要があります"
},
"error": {
"user_not_found": "ユーザーが存在しません",
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
"server_error": "ログインに失敗しました。後でもう一度お試しください",
"generic": "エラーが発生しました。もう一度お試しください"
}
}, },
"register": { "register": {
"title": "アカウントを登録する", "title": "アカウントを登録する",
@@ -19,7 +28,16 @@
"register_prompt": "既にアカウントをお持ちですか?", "register_prompt": "既にアカウントをお持ちですか?",
"login_link": "ログイン", "login_link": "ログイン",
"welcome_notification": "Maxunへようこそ", "welcome_notification": "Maxunへようこそ",
"error_notification": "登録に失敗しました。もう一度お試しください。" "validation": {
"email_required": "メールアドレスは必須です",
"password_requirements": "パスワードは6文字以上である必要があります"
},
"error": {
"user_exists": "このメールアドレスは既に登録されています",
"creation_failed": "アカウントを作成できませんでした",
"server_error": "サーバーエラーが発生しました",
"generic": "登録に失敗しました。もう一度お試しください"
}
}, },
"recordingtable": { "recordingtable": {
"run": "実行", "run": "実行",
@@ -123,11 +141,11 @@
"generate_button": "APIキーを生成", "generate_button": "APIキーを生成",
"notifications": { "notifications": {
"fetch_error": "APIキーの取得に失敗しました - ${error}", "fetch_error": "APIキーの取得に失敗しました - ${error}",
"generate_success": "APIキーが正常に生成されました", "generate_success": "APIキーの生成に成功しました",
"generate_error": "APIキーの生成に失敗しました - ${error}", "generate_error": "APIキーの生成に失敗しました - ${error}",
"delete_success": "APIキーが正常に削除されました", "delete_success": "APIキーの削除に成功しました",
"delete_error": "APIキーの削除に失敗しました - ${error}", "delete_error": "APIキーの削除に失敗しました - ${error}",
"copy_success": "APIキーコピーされました" "copy_success": "APIキーコピーに成功しました"
} }
}, },
"action_description": { "action_description": {
@@ -470,6 +488,19 @@
}, },
"navbar": { "navbar": {
"project_name": "Maxun", "project_name": "Maxun",
"notifications": {
"success": {
"logout": "ログアウトに成功しました"
},
"errors": {
"logout": {
"unauthorized": "この操作を実行する権限がありません",
"server": "ログアウト中にサーバーエラーが発生しました",
"network": "ログアウト中にネットワークエラーが発生しました",
"unknown": "ログアウト中に予期せぬエラーが発生しました"
}
}
},
"upgrade": { "upgrade": {
"button": "アップグレード", "button": "アップグレード",
"modal": { "modal": {

View File

@@ -8,7 +8,16 @@
"register_prompt": "还没有账号?", "register_prompt": "还没有账号?",
"register_link": "注册", "register_link": "注册",
"welcome_notification": "欢迎使用 Maxun", "welcome_notification": "欢迎使用 Maxun",
"error_notification": "登录失败。请重试。" "validation": {
"required_fields": "邮箱和密码为必填项",
"password_length": "密码必须至少6个字符"
},
"error": {
"user_not_found": "用户不存在",
"invalid_credentials": "邮箱或密码无效",
"server_error": "登录失败,请稍后重试",
"generic": "发生错误,请重试"
}
}, },
"register": { "register": {
"title": "注册账号", "title": "注册账号",
@@ -19,7 +28,16 @@
"register_prompt": "已有账号?", "register_prompt": "已有账号?",
"login_link": "登录", "login_link": "登录",
"welcome_notification": "欢迎使用 Maxun", "welcome_notification": "欢迎使用 Maxun",
"error_notification": "注册失败。请重试。" "validation": {
"email_required": "邮箱是必填项",
"password_requirements": "密码必须至少6个字符"
},
"error": {
"user_exists": "该邮箱已被注册",
"creation_failed": "无法创建账户",
"server_error": "服务器错误",
"generic": "注册失败,请重试"
}
}, },
"recordingtable": { "recordingtable": {
"run": "运行", "run": "运行",
@@ -123,11 +141,11 @@
"generate_button": "生成API密钥", "generate_button": "生成API密钥",
"notifications": { "notifications": {
"fetch_error": "获取API密钥失败 - ${error}", "fetch_error": "获取API密钥失败 - ${error}",
"generate_success": "API密钥生成成功", "generate_success": "成功生成API密钥",
"generate_error": "生成API密钥失败 - ${error}", "generate_error": "生成API密钥失败 - ${error}",
"delete_success": "API密钥删除成功", "delete_success": "成功删除API密钥",
"delete_error": "删除API密钥失败 - ${error}", "delete_error": "删除API密钥失败 - ${error}",
"copy_success": "API密钥复制成功" "copy_success": "成功复制API密钥"
} }
}, },
"action_description": { "action_description": {
@@ -470,6 +488,19 @@
}, },
"navbar": { "navbar": {
"project_name": "Maxun", "project_name": "Maxun",
"notifications": {
"success": {
"logout": "退出登录成功"
},
"errors": {
"logout": {
"unauthorized": "您没有执行此操作的权限",
"server": "退出登录时发生服务器错误",
"network": "退出登录时发生网络错误",
"unknown": "退出登录时发生未知错误"
}
}
},
"upgrade": { "upgrade": {
"button": "升级", "button": "升级",
"modal": { "modal": {

View File

@@ -28,29 +28,47 @@ router.post("/register", async (req, res) => {
try { try {
const { email, password } = req.body; const { email, password } = req.body;
if (!email) return res.status(400).send("Email is required"); if (!email) {
if (!password || password.length < 6) return res.status(400).json({
return res error: "VALIDATION_ERROR",
.status(400) code: "register.validation.email_required"
.send("Password is required and must be at least 6 characters"); });
}
if (!password || password.length < 6) {
return res.status(400).json({
error: "VALIDATION_ERROR",
code: "register.validation.password_requirements"
});
}
let userExist = await User.findOne({ raw: true, where: { email } }); let userExist = await User.findOne({ raw: true, where: { email } });
if (userExist) return res.status(400).send("User already exists"); if (userExist) {
return res.status(400).json({
error: "USER_EXISTS",
code: "register.error.user_exists"
});
}
const hashedPassword = await hashPassword(password); const hashedPassword = await hashPassword(password);
let user: any; let user: any;
try { try {
user = await User.create({ email, password: hashedPassword }); user = await User.create({ email, password: hashedPassword });
} catch (error: any) { } catch (error: any) {
console.log(`Could not create user - ${error}`); console.log(`Could not create user - ${error}`);
return res.status(500).send(`Could not create user - ${error.message}`); return res.status(500).json({
error: "DATABASE_ERROR",
code: "register.error.creation_failed"
});
} }
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
console.log("JWT_SECRET is not defined in the environment"); console.log("JWT_SECRET is not defined in the environment");
return res.status(500).send("Internal Server Error"); return res.status(500).json({
error: "SERVER_ERROR",
code: "register.error.server_error"
});
} }
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string); const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string);
@@ -58,36 +76,60 @@ router.post("/register", async (req, res) => {
res.cookie("token", token, { res.cookie("token", token, {
httpOnly: true, httpOnly: true,
}); });
capture("maxun-oss-user-registered", { capture("maxun-oss-user-registered", {
email: user.email, email: user.email,
userId: user.id, userId: user.id,
registeredAt: new Date().toISOString(), registeredAt: new Date().toISOString(),
}); });
console.log(`User registered`); console.log(`User registered`);
res.json(user); res.json(user);
} catch (error: any) { } catch (error: any) {
console.log(`Could not register user - ${error}`); console.log(`Could not register user - ${error}`);
res.status(500).send(`Could not register user - ${error.message}`); return res.status(500).json({
error: "SERVER_ERROR",
code: "register.error.generic"
});
} }
}); });
router.post("/login", async (req, res) => { router.post("/login", async (req, res) => {
try { try {
const { email, password } = req.body; const { email, password } = req.body;
if (!email || !password) if (!email || !password) {
return res.status(400).send("Email and password are required"); return res.status(400).json({
if (password.length < 6) error: "VALIDATION_ERROR",
return res.status(400).send("Password must be at least 6 characters"); code: "login.validation.required_fields"
});
}
if (password.length < 6) {
return res.status(400).json({
error: "VALIDATION_ERROR",
code: "login.validation.password_length"
});
}
let user = await User.findOne({ raw: true, where: { email } }); let user = await User.findOne({ raw: true, where: { email } });
if (!user) return res.status(400).send("User does not exist"); if (!user) {
return res.status(404).json({
error: "USER_NOT_FOUND",
code: "login.error.user_not_found"
});
}
const match = await comparePassword(password, user.password); const match = await comparePassword(password, user.password);
if (!match) return res.status(400).send("Invalid email or password"); if (!match) {
return res.status(401).json({
error: "INVALID_CREDENTIALS",
code: "login.error.invalid_credentials"
});
}
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string); const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string);
// return user and token to client, exclude hashed password
if (user) { if (user) {
user.password = undefined as unknown as string; user.password = undefined as unknown as string;
} }
@@ -101,30 +143,43 @@ router.post("/login", async (req, res) => {
}); });
res.json(user); res.json(user);
} catch (error: any) { } catch (error: any) {
res.status(400).send(`Could not login user - ${error.message}`); console.error(`Login error: ${error.message}`);
console.log(`Could not login user - ${error}`); res.status(500).json({
error: "SERVER_ERROR",
code: "login.error.server_error"
});
} }
}); });
router.get("/logout", async (req, res) => { router.get("/logout", async (req, res) => {
try { try {
res.clearCookie("token"); res.clearCookie("token");
return res.json({ message: "Logout successful" }); return res.status(200).json({
} catch (error: any) { ok: true,
res.status(500).send(`Could not logout user - ${error.message}`); message: "Logged out successfully",
code: "success"
});
} catch (error) {
console.error('Logout error:', error);
return res.status(500).json({
ok: false,
message: "Error during logout",
code: "server",
error: process.env.NODE_ENV === 'development' ? error : undefined
});
}
} }
}); );
router.get( router.get(
"/current-user", "/current-user",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
try { try {
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).json({ ok: false, error: "Unauthorized" }); return res.status(401).json({ ok: false, error: "Unauthorized" });
} }
const user = await User.findByPk(authenticatedReq.user.id, { const user = await User.findByPk(req.user.id, {
attributes: { exclude: ["password"] }, attributes: { exclude: ["password"] },
}); });
if (!user) { if (!user) {
@@ -147,7 +202,7 @@ router.get(
router.get( router.get(
"/user/:id", "/user/:id",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
if (!id) { if (!id) {
@@ -176,13 +231,12 @@ router.get(
router.post( router.post(
"/generate-api-key", "/generate-api-key",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
try { try {
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).json({ ok: false, error: "Unauthorized" }); return res.status(401).json({ ok: false, error: "Unauthorized" });
} }
const user = await User.findByPk(authenticatedReq.user.id, { const user = await User.findByPk(req.user.id, {
attributes: { exclude: ["password"] }, attributes: { exclude: ["password"] },
}); });
@@ -217,28 +271,41 @@ router.post(
router.get( router.get(
"/api-key", "/api-key",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
try { try {
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).json({ ok: false, error: "Unauthorized" }); return res.status(401).json({
ok: false,
error: "Unauthorized",
code: "unauthorized"
});
} }
const user = await User.findByPk(authenticatedReq.user.id, { const user = await User.findByPk(req.user.id, {
raw: true, raw: true,
attributes: ["api_key"], attributes: ["api_key"],
}); });
if (!user) { if (!user) {
return res.status(404).json({ message: "User not found" }); return res.status(404).json({
ok: false,
error: "User not found",
code: "not_found"
});
} }
return res.status(200).json({ return res.status(200).json({
ok: true,
message: "API key fetched successfully", message: "API key fetched successfully",
api_key: user.api_key || null, api_key: user.api_key || null,
}); });
} catch (error) { } catch (error) {
return res.status(500).json({ message: "Error fetching API key", error }); console.error('API Key fetch error:', error);
return res.status(500).json({
ok: false,
error: "Error fetching API key",
code: "server",
});
} }
} }
); );
@@ -246,14 +313,13 @@ router.get(
router.delete( router.delete(
"/delete-api-key", "/delete-api-key",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest; if (!req.user) {
if (!authenticatedReq.user) {
return res.status(401).send({ error: "Unauthorized" }); return res.status(401).send({ error: "Unauthorized" });
} }
try { try {
const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); const user = await User.findByPk(req.user.id, { raw: true });
if (!user) { if (!user) {
return res.status(404).json({ message: "User not found" }); return res.status(404).json({ message: "User not found" });
@@ -263,7 +329,7 @@ router.delete(
return res.status(404).json({ message: "API Key not found" }); return res.status(404).json({ message: "API Key not found" });
} }
await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } }); await User.update({ api_key: null }, { where: { id: req.user.id } });
capture("maxun-oss-api-key-deleted", { capture("maxun-oss-api-key-deleted", {
user_id: user.id, user_id: user.id,
@@ -309,8 +375,7 @@ router.get("/google", (req, res) => {
router.get( router.get(
"/google/callback", "/google/callback",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
const { code, state } = req.query; const { code, state } = req.query;
try { try {
if (!state) { if (!state) {
@@ -336,12 +401,12 @@ router.get(
return res.status(400).json({ message: "Email not found" }); return res.status(400).json({ message: "Email not found" });
} }
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).send({ error: "Unauthorized" }); return res.status(401).send({ error: "Unauthorized" });
} }
// Get the currently authenticated user (from `requireSignIn`) // Get the currently authenticated user (from `requireSignIn`)
let user = await User.findOne({ where: { id: authenticatedReq.user.id } }); let user = await User.findOne({ where: { id: req.user.id } });
if (!user) { if (!user) {
return res.status(400).json({ message: "User not found" }); return res.status(400).json({ message: "User not found" });
@@ -419,13 +484,12 @@ router.get(
router.post( router.post(
"/gsheets/data", "/gsheets/data",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
const { spreadsheetId, robotId } = req.body; const { spreadsheetId, robotId } = req.body;
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).send({ error: "Unauthorized" }); return res.status(401).send({ error: "Unauthorized" });
} }
const user = await User.findByPk(authenticatedReq.user.id, { raw: true }); const user = await User.findByPk(req.user.id, { raw: true });
if (!user) { if (!user) {
return res.status(400).json({ message: "User not found" }); return res.status(400).json({ message: "User not found" });
@@ -537,14 +601,13 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => {
router.post( router.post(
"/gsheets/remove", "/gsheets/remove",
requireSignIn, requireSignIn,
async (req: Request, res) => { async (req: AuthenticatedRequest, res) => {
const authenticatedReq = req as AuthenticatedRequest;
const { robotId } = req.body; const { robotId } = req.body;
if (!robotId) { if (!robotId) {
return res.status(400).json({ message: "Robot ID is required" }); return res.status(400).json({ message: "Robot ID is required" });
} }
if (!authenticatedReq.user) { if (!req.user) {
return res.status(401).send({ error: "Unauthorized" }); return res.status(401).send({ error: "Unauthorized" });
} }
@@ -566,7 +629,7 @@ router.post(
}); });
capture("maxun-oss-google-sheet-integration-removed", { capture("maxun-oss-google-sheet-integration-removed", {
user_id: authenticatedReq.user.id, user_id: req.user.id,
robot_id: robotId, robot_id: robotId,
deleted_at: new Date().toISOString(), deleted_at: new Date().toISOString(),
}); });
@@ -866,4 +929,6 @@ router.get("/airtable/tables", async (req: Request, res) => {
} catch (error: any) { } catch (error: any) {
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
} }
}); });

View File

@@ -26,35 +26,43 @@ chromium.use(stealthPlugin());
export const router = Router(); export const router = Router();
export const decryptWorkflowActions = async (workflow: any[],): Promise<any[]> => { export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise<any[]> => {
// Create a deep copy to avoid mutating the original workflow const processedWorkflow = JSON.parse(JSON.stringify(workflow));
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
// Process each step in the workflow processedWorkflow.forEach((pair: any) => {
for (const step of processedWorkflow) { pair.what.forEach((action: any) => {
if (!step.what) continue; // Handle limit validation for scrapeList action
if (action.action === 'scrapeList' && checkLimit && Array.isArray(action.args) && action.args.length > 0) {
// Process each action in the step const scrapeConfig = action.args[0];
for (const action of step.what) { if (scrapeConfig && typeof scrapeConfig === 'object' && 'limit' in scrapeConfig) {
// Only process type and press actions if (typeof scrapeConfig.limit === 'number' && scrapeConfig.limit > 5) {
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) { scrapeConfig.limit = 5;
// 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
} }
} }
} }
}
} // Handle decryption for type and press actions
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) {
try {
const encryptedValue = action.args[1];
if (typeof encryptedValue === 'string') {
const decryptedValue = decrypt(encryptedValue);
action.args[1] = decryptedValue;
} else {
logger.log('error', 'Encrypted value is not a string');
action.args[1] = '';
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log('error', `Failed to decrypt input value: ${errorMessage}`);
action.args[1] = '';
}
}
});
});
return processedWorkflow; return processedWorkflow;
}; }
/** /**
* Logs information about recordings API. * Logs information about recordings API.
@@ -89,7 +97,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
); );
if (data?.recording?.workflow) { if (data?.recording?.workflow) {
data.recording.workflow = await decryptWorkflowActions( data.recording.workflow = await processWorkflowActions(
data.recording.workflow, data.recording.workflow,
); );
} }
@@ -164,54 +172,82 @@ interface Credentials {
[key: string]: CredentialInfo; [key: string]: CredentialInfo;
} }
function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) { function handleWorkflowActions(workflow: any[], credentials: Credentials) {
return workflow.map(step => { return workflow.map(step => {
if (!step.what) return step; if (!step.what) return step;
const indicesToRemove = new Set<number>(); const newWhat: any[] = [];
step.what.forEach((action: any, index: number) => { const processedSelectors = new Set<string>();
if (!action.action || !action.args?.[0]) return;
for (let i = 0; i < step.what.length; i++) {
const action = step.what[i];
if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) { if (!action?.action || !action?.args?.[0]) {
indicesToRemove.add(index); newWhat.push(action);
continue;
if (step.what[index + 1]?.action === 'waitForLoadState') {
indicesToRemove.add(index + 1);
}
} }
});
const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index)); const selector = action.args[0];
const credential = credentials[selector];
Object.entries(credentials).forEach(([selector, credentialInfo]) => { if (!credential) {
const clickIndex = filteredWhat.findIndex((action: any) => newWhat.push(action);
action.action === 'click' && action.args?.[0] === selector continue;
); }
if (clickIndex !== -1) { if (action.action === 'click') {
const chars = credentialInfo.value.split(''); newWhat.push(action);
chars.forEach((char, i) => { if (!processedSelectors.has(selector) &&
filteredWhat.splice(clickIndex + 1 + (i * 2), 0, { i + 1 < step.what.length &&
(step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) {
newWhat.push({
action: 'type', action: 'type',
args: [ args: [selector, encrypt(credential.value), credential.type]
selector,
encrypt(char),
credentialInfo.type
]
}); });
filteredWhat.splice(clickIndex + 2 + (i * 2), 0, { newWhat.push({
action: 'waitForLoadState', action: 'waitForLoadState',
args: ['networkidle'] args: ['networkidle']
}); });
processedSelectors.add(selector);
while (i + 1 < step.what.length &&
(step.what[i + 1].action === 'type' ||
step.what[i + 1].action === 'press' ||
step.what[i + 1].action === 'waitForLoadState')) {
i++;
}
}
} else if ((action.action === 'type' || action.action === 'press') &&
!processedSelectors.has(selector)) {
newWhat.push({
action: 'type',
args: [selector, encrypt(credential.value), credential.type]
}); });
newWhat.push({
action: 'waitForLoadState',
args: ['networkidle']
});
processedSelectors.add(selector);
// Skip subsequent type/press/waitForLoadState actions for this selector
while (i + 1 < step.what.length &&
(step.what[i + 1].action === 'type' ||
step.what[i + 1].action === 'press' ||
step.what[i + 1].action === 'waitForLoadState')) {
i++;
}
} }
}); }
return { return {
...step, ...step,
what: filteredWhat what: newWhat
}; };
}); });
} }
@@ -244,7 +280,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
let workflow = [...robot.recording.workflow]; // Create a copy of the workflow let workflow = [...robot.recording.workflow]; // Create a copy of the workflow
if (credentials) { if (credentials) {
workflow = updateTypeActionsInWorkflow(workflow, credentials); workflow = handleWorkflowActions(workflow, credentials);
} }
// Update the limit // Update the limit
@@ -282,9 +318,23 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
} }
} }
robot.set('recording', { ...robot.recording, workflow }); const updates: any = {
recording: {
...robot.recording,
workflow
}
};
await robot.save(); if (name) {
updates.recording_meta = {
...robot.recording_meta,
name
};
}
await Robot.update(updates, {
where: { 'recording_meta.id': id }
});
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
@@ -502,6 +552,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
return res.send({ return res.send({
browserId: id, browserId: id,
runId: plainRun.runId, runId: plainRun.runId,
robotMetaId: recording.recording_meta.id,
}); });
} catch (e) { } catch (e) {
const { message } = e as Error; const { message } = e as Error;

View File

@@ -22,7 +22,7 @@ import { getBestSelectorForAction } from "../utils";
import { browserPool } from "../../server"; import { browserPool } from "../../server";
import { uuid } from "uuidv4"; import { uuid } from "uuidv4";
import { capture } from "../../utils/analytics" import { capture } from "../../utils/analytics"
import { encrypt } from "../../utils/auth"; import { decrypt, encrypt } from "../../utils/auth";
interface PersistedGeneratedData { interface PersistedGeneratedData {
lastUsedSelector: string; lastUsedSelector: string;
@@ -42,6 +42,13 @@ interface MetaData {
isLogin?: boolean; isLogin?: boolean;
} }
interface InputState {
selector: string;
value: string;
type: string;
cursorPosition: number;
}
/** /**
* Workflow generator is used to transform the user's interactions into an automatically * Workflow generator is used to transform the user's interactions into an automatically
* generated correct workflows, using the ability of internal state persistence and * generated correct workflows, using the ability of internal state persistence and
@@ -428,26 +435,86 @@ export class WorkflowGenerator {
} }
if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) { if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) {
// Calculate the exact position within the element const positionAndCursor = await page.evaluate(
const elementPos = await page.evaluate((selector) => { ({ selector, coords }) => {
const element = document.querySelector(selector); const getCursorPosition = (element: any, clickX: any) => {
if (!element) return null; const text = element.value;
const rect = element.getBoundingClientRect();
return { const mirror = document.createElement('div');
x: rect.left,
y: rect.top const style = window.getComputedStyle(element);
}; mirror.style.cssText = `
}, selector); font: ${style.font};
line-height: ${style.lineHeight};
padding: ${style.padding};
border: ${style.border};
box-sizing: ${style.boxSizing};
white-space: ${style.whiteSpace};
overflow-wrap: ${style.overflowWrap};
position: absolute;
top: -9999px;
left: -9999px;
width: ${element.offsetWidth}px;
`;
document.body.appendChild(mirror);
const paddingLeft = parseFloat(style.paddingLeft);
const borderLeft = parseFloat(style.borderLeftWidth);
const adjustedClickX = clickX - (paddingLeft + borderLeft);
let bestIndex = 0;
let bestDiff = Infinity;
for (let i = 0; i <= text.length; i++) {
const textBeforeCursor = text.substring(0, i);
const span = document.createElement('span');
span.textContent = textBeforeCursor;
mirror.innerHTML = '';
mirror.appendChild(span);
const textWidth = span.getBoundingClientRect().width;
const diff = Math.abs(adjustedClickX - textWidth);
if (diff < bestDiff) {
bestIndex = i;
bestDiff = diff;
}
}
document.body.removeChild(mirror);
return bestIndex;
};
if (elementPos) { const element = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement;
const relativeX = coordinates.x - elementPos.x; if (!element) return null;
const relativeY = coordinates.y - elementPos.y;
const rect = element.getBoundingClientRect();
const relativeX = coords.x - rect.left;
return {
rect: {
x: rect.left,
y: rect.top
},
cursorIndex: getCursorPosition(element, relativeX)
};
},
{ selector, coords: coordinates }
);
if (positionAndCursor) {
const relativeX = coordinates.x - positionAndCursor.rect.x;
const relativeY = coordinates.y - positionAndCursor.rect.y;
const pair: WhereWhatPair = { const pair: WhereWhatPair = {
where, where,
what: [{ what: [{
action: 'click', action: 'click',
args: [selector, { position: { x: relativeX, y: relativeY } }] args: [selector, { position: { x: relativeX, y: relativeY } }, { cursorIndex: positionAndCursor.cursorIndex }],
}] }]
}; };
@@ -759,8 +826,7 @@ export class WorkflowGenerator {
selectors?.id, selectors?.id,
selectors?.hrefSelector, selectors?.hrefSelector,
selectors?.accessibilitySelector, selectors?.accessibilitySelector,
selectors?.attrSelector, selectors?.attrSelector
selectors?.generalSelector
] ]
.filter(selector => selector !== null && selector !== undefined) .filter(selector => selector !== null && selector !== undefined)
.join(','); .join(',');
@@ -1027,77 +1093,107 @@ export class WorkflowGenerator {
* @param workflow The workflow to be optimized. * @param workflow The workflow to be optimized.
*/ */
private optimizeWorkflow = (workflow: WorkflowFile) => { private optimizeWorkflow = (workflow: WorkflowFile) => {
const inputStates = new Map<string, InputState>();
// replace a sequence of press actions by a single fill action
let input = { for (const pair of workflow.workflow) {
selector: '', let currentIndex = 0;
value: '',
type: '', while (currentIndex < pair.what.length) {
actionCounter: 0, const condition = pair.what[currentIndex];
};
if (condition.action === 'click' && condition.args?.[2]?.cursorIndex !== undefined) {
const pushTheOptimizedAction = (pair: WhereWhatPair, index: number) => { const selector = condition.args[0];
if (input.value.length === 1) { const cursorIndex = condition.args[2].cursorIndex;
// when only one press action is present, keep it and add a waitForLoadState action
pair.what.splice(index + 1, 0, { let state = inputStates.get(selector) || {
action: 'waitForLoadState', selector,
args: ['networkidle'], value: '',
}) type: 'text',
} else { cursorPosition: -1
// when more than one press action is present, add a type action };
pair.what.splice(index - input.actionCounter, input.actionCounter, {
action: 'type', state.cursorPosition = cursorIndex;
args: [input.selector, encrypt(input.value), input.type], inputStates.set(selector, state);
}, {
action: 'waitForLoadState', pair.what.splice(currentIndex, 1);
args: ['networkidle'], continue;
}); }
if (condition.action === 'press' && condition.args?.[1]) {
const [selector, encryptedKey, type] = condition.args;
const key = decrypt(encryptedKey);
let state = inputStates.get(selector);
if (!state) {
state = {
selector,
value: '',
type: type || 'text',
cursorPosition: -1
};
} else {
state.type = type || state.type;
}
if (key.length === 1) {
if (state.cursorPosition === -1) {
state.value += key;
} else {
state.value =
state.value.slice(0, state.cursorPosition) +
key +
state.value.slice(state.cursorPosition);
state.cursorPosition++;
}
} else if (key === 'Backspace') {
if (state.cursorPosition > 0) {
state.value =
state.value.slice(0, state.cursorPosition - 1) +
state.value.slice(state.cursorPosition);
state.cursorPosition--;
} else if (state.cursorPosition === -1 && state.value.length > 0) {
state.value = state.value.slice(0, -1);
}
} else if (key === 'Delete') {
if (state.cursorPosition >= 0 && state.cursorPosition < state.value.length) {
state.value =
state.value.slice(0, state.cursorPosition) +
state.value.slice(state.cursorPosition + 1);
} else if (state.cursorPosition === -1 && state.value.length > 0) {
state.value = state.value.slice(0, -1);
}
}
inputStates.set(selector, state);
pair.what.splice(currentIndex, 1);
continue;
}
currentIndex++;
} }
} }
for (const [selector, state] of inputStates.entries()) {
for (const pair of workflow.workflow) { if (state.value) {
pair.what.forEach((condition, index) => { for (let i = workflow.workflow.length - 1; i >= 0; i--) {
if (condition.action === 'press') { const pair = workflow.workflow[i];
if (condition.args && condition.args[1]) {
if (!input.selector) { pair.what.push({
input.selector = condition.args[0]; action: 'type',
} args: [selector, encrypt(state.value), state.type]
if (input.selector === condition.args[0]) { }, {
input.actionCounter++; action: 'waitForLoadState',
if (condition.args[1].length === 1) { args: ['networkidle']
input.value = input.value + condition.args[1]; });
} else if (condition.args[1] === 'Backspace') {
input.value = input.value.slice(0, -1); break;
} else if (condition.args[1] !== 'Shift') {
pushTheOptimizedAction(pair, index);
pair.what.splice(index + 1, 0, {
action: 'waitForLoadState',
args: ['networkidle'],
})
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,
};
}
}
} else {
if (input.value.length !== 0) {
pushTheOptimizedAction(pair, index);
// clear the input
input = { selector: '', value: '', type: '', actionCounter: 0 };
}
} }
}); }
} }
return workflow; return workflow;
} };
/** /**
* Returns workflow params from the stored metadata. * Returns workflow params from the stored metadata.

View File

@@ -1092,12 +1092,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => {
newPath.splice(i, 1); newPath.splice(i, 1);
const newPathKey = selector(newPath); const newPathKey = selector(newPath);
if (scope.visited.has(newPathKey)) { if (scope.visited.has(newPathKey)) {
return; continue;
} }
if (unique(newPath) && same(newPath, input)) { try {
yield newPath; if (unique(newPath) && same(newPath, input)) {
scope.visited.set(newPathKey, true); yield newPath;
yield* optimize(newPath, input, scope); scope.visited.set(newPathKey, true);
yield* optimize(newPath, input, scope);
}
} catch (e: any) {
continue;
} }
} }
} }
@@ -1654,6 +1658,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates
} }
} }
if (element.parentElement) {
// Look for identical siblings
const siblings = Array.from(element.parentElement.children);
const identicalSiblings = siblings.filter(sibling => {
if (sibling === element) return false;
let siblingSelector = sibling.tagName.toLowerCase();
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
if (siblingClassName) {
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
if (validSiblingClasses.length > 0) {
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
}
}
return siblingSelector === selector;
});
if (identicalSiblings.length > 0) {
const position = siblings.indexOf(element) + 1;
selector += `:nth-child(${position})`;
}
}
return selector; return selector;
} }
@@ -1894,6 +1923,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates
} }
} }
if (element.parentElement) {
// Look for identical siblings
const siblings = Array.from(element.parentElement.children);
const identicalSiblings = siblings.filter(sibling => {
if (sibling === element) return false;
let siblingSelector = sibling.tagName.toLowerCase();
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
if (siblingClassName) {
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
if (validSiblingClasses.length > 0) {
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
}
}
return siblingSelector === selector;
});
if (identicalSiblings.length > 0) {
const position = siblings.indexOf(element) + 1;
selector += `:nth-child(${position})`;
}
}
return selector; return selector;
} }
@@ -2025,6 +2079,31 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro
} }
} }
if (element.parentElement) {
// Look for identical siblings
const siblings = Array.from(element.parentElement.children);
const identicalSiblings = siblings.filter(sibling => {
if (sibling === element) return false;
let siblingSelector = sibling.tagName.toLowerCase();
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
if (siblingClassName) {
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
if (validSiblingClasses.length > 0) {
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
}
}
return siblingSelector === selector;
});
if (identicalSiblings.length > 0) {
const position = siblings.indexOf(element) + 1;
selector += `:nth-child(${position})`;
}
}
return selector; return selector;
} }

View File

@@ -161,7 +161,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
} }
} catch (error: any) { } catch (error: any) {
console.log(error); console.log(error);
return { browserId: '', runId: '' }; return { browserId: '', runId: '', robotMetaId: '' };
} }
} }

View File

@@ -14,7 +14,7 @@ import {
Tooltip, Tooltip,
Paper, Paper,
} from '@mui/material'; } from '@mui/material';
import { ContentCopy, Visibility, Delete } from '@mui/icons-material'; import { ContentCopy, Visibility, VisibilityOff, Delete } from '@mui/icons-material';
import styled from 'styled-components'; import styled from 'styled-components';
import axios from 'axios'; import axios from 'axios';
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
@@ -137,7 +137,7 @@ const ApiKeyManager = () => {
</Tooltip> </Tooltip>
<Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}> <Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
<IconButton onClick={() => setShowKey(!showKey)}> <IconButton onClick={() => setShowKey(!showKey)}>
<Visibility /> {showKey ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={t('apikey.actions.delete')}> <Tooltip title={t('apikey.actions.delete')}>

View File

@@ -263,7 +263,12 @@ export const BrowserWindow = () => {
} }
if (getList === true && !listSelector) { if (getList === true && !listSelector) {
setListSelector(highlighterData.selector); let cleanedSelector = highlighterData.selector;
if (cleanedSelector.includes('nth-child')) {
cleanedSelector = cleanedSelector.replace(/:nth-child\(\d+\)/g, '');
}
setListSelector(cleanedSelector);
notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success')); notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success'));
setCurrentListId(Date.now()); setCurrentListId(Date.now());
setFields({}); setFields({});
@@ -275,13 +280,25 @@ export const BrowserWindow = () => {
// Add fields to the list // Add fields to the list
if (options.length === 1) { if (options.length === 1) {
const attribute = options[0].value; const attribute = options[0].value;
let currentSelector = highlighterData.selector;
if (currentSelector.includes('>')) {
const [firstPart, ...restParts] = currentSelector.split('>').map(p => p.trim());
const listSelectorRightPart = listSelector.split('>').pop()?.trim().replace(/:nth-child\(\d+\)/g, '');
if (firstPart.includes('nth-child') &&
firstPart.replace(/:nth-child\(\d+\)/g, '') === listSelectorRightPart) {
currentSelector = `${firstPart.replace(/:nth-child\(\d+\)/g, '')} > ${restParts.join(' > ')}`;
}
}
const newField: TextStep = { const newField: TextStep = {
id: Date.now(), id: Date.now(),
type: 'text', type: 'text',
label: `Label ${Object.keys(fields).length + 1}`, label: `Label ${Object.keys(fields).length + 1}`,
data: data, data: data,
selectorObj: { selectorObj: {
selector: highlighterData.selector, selector: currentSelector,
tag: highlighterData.elementInfo?.tagName, tag: highlighterData.elementInfo?.tagName,
shadow: highlighterData.elementInfo?.isShadowRoot, shadow: highlighterData.elementInfo?.isShadowRoot,
attribute attribute

View File

@@ -24,6 +24,7 @@ import {
Clear, Clear,
YouTube, YouTube,
X, X,
GitHub,
Update, Update,
Close, Close,
Language, Language,
@@ -107,11 +108,39 @@ export const NavBar: React.FC<NavBarProps> = ({
}; };
const logout = async () => { const logout = async () => {
dispatch({ type: "LOGOUT" }); try {
window.localStorage.removeItem("user"); const { data } = await axios.get(`${apiUrl}/auth/logout`);
const { data } = await axios.get(`${apiUrl}/auth/logout`); if (data.ok) {
notify("success", data.message); dispatch({ type: "LOGOUT" });
navigate("/login"); window.localStorage.removeItem("user");
notify('success', t('navbar.notifications.success.logout'));
navigate("/login");
}
} catch (error: any) {
const status = error.response?.status;
let errorKey = 'unknown';
switch (status) {
case 401:
errorKey = 'unauthorized';
break;
case 500:
errorKey = 'server';
break;
default:
if (error.message?.includes('Network Error')) {
errorKey = 'network';
}
}
notify(
'error',
t(`navbar.notifications.errors.logout.${errorKey}`, {
error: error.response?.data?.message || error.message
})
);
navigate("/login");
}
}; };
const goToMainMenu = async () => { const goToMainMenu = async () => {
@@ -204,7 +233,9 @@ export const NavBar: React.FC<NavBarProps> = ({
<div style={{ <div style={{
display: 'flex', display: 'flex',
justifyContent: 'flex-start', justifyContent: 'flex-start',
}}> cursor: 'pointer'
}}
onClick={() => navigate('/')}>
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} /> <img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
<div style={{ padding: '11px' }}><ProjectName mode={darkMode ? 'dark' : 'light'}>{t('navbar.project_name')}</ProjectName></div> <div style={{ padding: '11px' }}><ProjectName mode={darkMode ? 'dark' : 'light'}>{t('navbar.project_name')}</ProjectName></div>
<Chip <Chip
@@ -219,14 +250,16 @@ export const NavBar: React.FC<NavBarProps> = ({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{!isRecording ? ( {!isRecording ? (
<> <>
<Button variant="outlined" onClick={handleUpdateOpen} sx={{ <IconButton onClick={handleUpdateOpen} sx={{
marginRight: '40px', display: 'flex',
color: "#00000099", alignItems: 'center',
border: "#00000099 1px solid", borderRadius: '5px',
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' } padding: '8px',
marginRight: '20px',
}}> }}>
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')} <Update sx={{ marginRight: '5px' }} />
</Button> <Typography variant="body1">{t('navbar.upgrade.button')}</Typography>
</IconButton>
<Modal open={open} onClose={handleUpdateClose}> <Modal open={open} onClose={handleUpdateClose}>
<Box <Box
sx={{ sx={{
@@ -318,7 +351,14 @@ export const NavBar: React.FC<NavBarProps> = ({
)} )}
</Box> </Box>
</Modal> </Modal>
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe> {/* <iframe
src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large"
// frameBorder="0"
// scrolling="0"
// width="170"
// height="30"
// title="GitHub">
// </iframe>*/}
<IconButton onClick={handleMenuOpen} sx={{ <IconButton onClick={handleMenuOpen} sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -335,22 +375,31 @@ export const NavBar: React.FC<NavBarProps> = ({
onClose={handleMenuClose} onClose={handleMenuClose}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: 'bottom',
horizontal: 'right', horizontal: 'center',
}} }}
transformOrigin={{ transformOrigin={{
vertical: 'top', vertical: 'top',
horizontal: 'right', horizontal: 'center',
}} }}
PaperProps={{ sx: { width: '180px' } }} PaperProps={{ sx: { width: '180px' } }}
> >
<MenuItem onClick={() => { handleMenuClose(); logout(); }}> <MenuItem onClick={() => { handleMenuClose(); logout(); }}>
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')} <Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
</MenuItem> </MenuItem>
<MenuItem onClick={handleLangMenuOpen}>
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
</MenuItem>
<hr />
<MenuItem onClick={() => { <MenuItem onClick={() => {
window.open('https://docs.maxun.dev', '_blank'); window.open('https://docs.maxun.dev', '_blank');
}}> }}>
<Description sx={{ marginRight: '5px' }} /> Docs <Description sx={{ marginRight: '5px' }} /> Docs
</MenuItem> </MenuItem>
<MenuItem onClick={() => {
window.open('https://github.com/getmaxun/maxun', '_blank');
}}>
<GitHub sx={{ marginRight: '5px' }} /> GitHub
</MenuItem>
<MenuItem onClick={() => { <MenuItem onClick={() => {
window.open('https://discord.gg/5GbPjBUkws', '_blank'); window.open('https://discord.gg/5GbPjBUkws', '_blank');
}}> }}>
@@ -366,20 +415,17 @@ export const NavBar: React.FC<NavBarProps> = ({
}}> }}>
<X sx={{ marginRight: '5px' }} /> Twitter (X) <X sx={{ marginRight: '5px' }} /> Twitter (X)
</MenuItem> </MenuItem>
<MenuItem onClick={handleLangMenuOpen}>
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
</MenuItem>
<Menu <Menu
anchorEl={langAnchorEl} anchorEl={langAnchorEl}
open={Boolean(langAnchorEl)} open={Boolean(langAnchorEl)}
onClose={handleMenuClose} onClose={handleMenuClose}
anchorOrigin={{ anchorOrigin={{
vertical: "bottom", vertical: "bottom",
horizontal: "right", horizontal: "center",
}} }}
transformOrigin={{ transformOrigin={{
vertical: "top", vertical: "top",
horizontal: "right", horizontal: "center",
}} }}
> >
<MenuItem <MenuItem
@@ -471,11 +517,11 @@ export const NavBar: React.FC<NavBarProps> = ({
onClose={handleMenuClose} onClose={handleMenuClose}
anchorOrigin={{ anchorOrigin={{
vertical: "bottom", vertical: "bottom",
horizontal: "right", horizontal: "center",
}} }}
transformOrigin={{ transformOrigin={{
vertical: "top", vertical: "top",
horizontal: "right", horizontal: "center",
}} }}
> >
<MenuItem <MenuItem

View File

@@ -148,7 +148,6 @@ export const RecordingsTable = ({
const [rows, setRows] = React.useState<Data[]>([]); const [rows, setRows] = React.useState<Data[]>([]);
const [isModalOpen, setModalOpen] = React.useState(false); const [isModalOpen, setModalOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState(''); const [searchTerm, setSearchTerm] = React.useState('');
const [isLoading, setIsLoading] = React.useState(true);
const columns = useMemo(() => [ const columns = useMemo(() => [
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
@@ -169,6 +168,8 @@ export const RecordingsTable = ({
setRecordingUrl, setRecordingUrl,
isLogin, isLogin,
setIsLogin, setIsLogin,
rerenderRobots,
setRerenderRobots,
recordingName, recordingName,
setRecordingName, setRecordingName,
recordingId, recordingId,
@@ -189,32 +190,45 @@ export const RecordingsTable = ({
setPage(0); setPage(0);
}, []); }, []);
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);
}
};
const fetchRecordings = useCallback(async () => { const fetchRecordings = useCallback(async () => {
setIsLoading(true);
try { try {
const recordings = await getStoredRecordings(); const recordings = await getStoredRecordings();
if (recordings) { if (recordings) {
const parsedRows = recordings const parsedRows = recordings
.map((recording: any, index: number) => { .map((recording: any, index: number) => {
if (recording?.recording_meta) { if (recording?.recording_meta) {
return { const parsedDate = parseDateString(recording.recording_meta.createdAt);
id: index,
...recording.recording_meta, return {
content: recording.recording id: index,
}; ...recording.recording_meta,
} content: recording.recording,
return null; parsedDate
}) };
.filter(Boolean); }
return null;
})
.filter(Boolean)
.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
setRecordings(parsedRows.map((recording) => recording.name)); setRecordings(parsedRows.map((recording) => recording.name));
setRows(parsedRows); setRows(parsedRows);
} }
} catch (error) { } catch (error) {
console.error('Error fetching recordings:', error); console.error('Error fetching recordings:', error);
notify('error', t('recordingtable.notifications.fetch_error')); notify('error', t('recordingtable.notifications.fetch_error'));
} finally {
setIsLoading(false);
} }
}, [setRecordings, notify, t]); }, [setRecordings, notify, t]);
@@ -249,6 +263,14 @@ export const RecordingsTable = ({
} }
}, [fetchRecordings]); }, [fetchRecordings]);
useEffect(() => {
if (rerenderRobots) {
fetchRecordings().then(() => {
setRerenderRobots(false);
});
}
}, [rerenderRobots, fetchRecordings, setRerenderRobots]);
function useDebounce<T>(value: T, delay: number): T { function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value); const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
@@ -343,39 +365,32 @@ export const RecordingsTable = ({
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
{isLoading ? ( <TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
<Box display="flex" justifyContent="center" alignItems="center" height="50%"> <Table stickyHeader aria-label="sticky table">
<CircularProgress /> <TableHead>
</Box> <TableRow>
) : ( {columns.map((column) => (
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}> <MemoizedTableCell
<Table stickyHeader aria-label="sticky table"> key={column.id}
<TableHead> style={{ minWidth: column.minWidth }}
<TableRow> >
{columns.map((column) => ( {column.label}
<MemoizedTableCell </MemoizedTableCell>
key={column.id}
// align={column.align}
style={{ minWidth: column.minWidth }}
>
{column.label}
</MemoizedTableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{visibleRows.map((row) => (
<TableRowMemoized
key={row.id}
row={row}
columns={columns}
handlers={handlers}
/>
))} ))}
</TableBody> </TableRow>
</Table> </TableHead>
</TableContainer> <TableBody>
)} {visibleRows.map((row) => (
<TableRowMemoized
key={row.id}
row={row}
columns={columns}
handlers={handlers}
/>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination <TablePagination
rowsPerPageOptions={[10, 25, 50, 100]} rowsPerPageOptions={[10, 25, 50, 100]}

View File

@@ -55,9 +55,9 @@ interface RobotSettingsProps {
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>(''); const [targetUrl, setTargetUrl] = useState<string | undefined>('');
const { recordingId, notify } = useGlobalInfoStore(); const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -96,13 +96,11 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
const success = await duplicateRecording(robot.recording_meta.id, targetUrl); const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
if (success) { if (success) {
setRerenderRobots(true);
notify('success', t('robot_duplication.notifications.duplicate_success')); notify('success', t('robot_duplication.notifications.duplicate_success'));
handleStart(robot); handleStart(robot);
handleClose(); handleClose();
setTimeout(() => {
window.location.reload();
}, 1000);
} else { } else {
notify('error', t('robot_duplication.notifications.duplicate_error')); notify('error', t('robot_duplication.notifications.duplicate_error'));
} }

View File

@@ -7,6 +7,7 @@ import { modalStyle } from "../recorder/AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo'; import { useGlobalInfoStore } from '../../context/globalInfo';
import { getStoredRecording, updateRecording } from '../../api/storage'; import { getStoredRecording, updateRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core'; import { WhereWhatPair } from 'maxun-core';
import { useNavigate } from 'react-router-dom';
interface RobotMeta { interface RobotMeta {
name: string; name: string;
@@ -75,9 +76,9 @@ interface GroupedCredentials {
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [credentials, setCredentials] = useState<Credentials>({}); const [credentials, setCredentials] = useState<Credentials>({});
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [credentialGroups, setCredentialGroups] = useState<GroupedCredentials>({ const [credentialGroups, setCredentialGroups] = useState<GroupedCredentials>({
passwords: [], passwords: [],
emails: [], emails: [],
@@ -123,81 +124,113 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
} }
}, [robot]); }, [robot]);
const extractInitialCredentials = (workflow: any[]): Credentials => { function extractInitialCredentials(workflow: any[]): Credentials {
const credentials: Credentials = {}; const credentials: Credentials = {};
// Helper function to check if a character is printable
const isPrintableCharacter = (char: string): boolean => { const isPrintableCharacter = (char: string): boolean => {
return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); return char.length === 1 && !!char.match(/^[\x20-\x7E]$/);
}; };
// Process each step in the workflow
workflow.forEach(step => { workflow.forEach(step => {
if (!step.what) return; if (!step.what) return;
// Keep track of the current input field being processed
let currentSelector = ''; let currentSelector = '';
let currentValue = ''; let currentValue = '';
let currentType = ''; let currentType = '';
let i = 0;
// Process actions in sequence to maintain correct text state
step.what.forEach((action: any) => { while (i < step.what.length) {
if ( const action = step.what[i];
(action.action === 'type' || action.action === 'press') &&
action.args?.length >= 2 && if (!action.action || !action.args?.[0]) {
typeof action.args[1] === 'string' i++;
) { continue;
const selector: string = action.args[0]; }
const character: string = action.args[1];
const inputType: string = action.args[2] || ''; const selector = action.args[0];
// Detect `input[type="password"]` // Handle full word type actions first
if (!currentType && inputType.toLowerCase() === 'password') { if (action.action === 'type' &&
currentType = 'password'; 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++;
// If we're dealing with a new selector, store the previous one continue;
if (currentSelector && selector !== currentSelector) { }
if (!credentials[currentSelector]) {
// 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] = { credentials[currentSelector] = {
value: currentValue, value: currentValue,
type: currentType type: currentType || 'text'
}; };
} else {
credentials[currentSelector].value = currentValue;
} }
}
// Update current tracking variables
if (selector !== currentSelector) {
currentSelector = selector; currentSelector = selector;
currentValue = credentials[selector]?.value || ''; currentValue = credentials[selector]?.value || '';
currentType = inputType || credentials[selector]?.type || ''; currentType = action.args[2] || credentials[selector]?.type || 'text';
} }
// Handle different types of key actions const character = action.args[1];
if (character === 'Backspace') {
// Remove the last character when backspace is pressed if (isPrintableCharacter(character)) {
currentValue = currentValue.slice(0, -1);
} else if (isPrintableCharacter(character)) {
// Add the character to the current value
currentValue += character; currentValue += character;
} else if (character === 'Backspace') {
currentValue = currentValue.slice(0, -1);
} }
// Note: We ignore other special keys like 'Shift', 'Enter', etc.
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++;
} }
}); }
// Store the final state of the last processed selector if (currentSelector && currentValue) {
if (currentSelector) {
credentials[currentSelector] = { credentials[currentSelector] = {
value: currentValue, value: currentValue,
type: currentType type: currentType || 'text'
}; };
} }
}); });
return credentials; return credentials;
}; }
const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => { const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => {
return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => { return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => {
@@ -366,13 +399,11 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
const success = await updateRecording(robot.recording_meta.id, payload); const success = await updateRecording(robot.recording_meta.id, payload);
if (success) { if (success) {
setRerenderRobots(true);
notify('success', t('robot_edit.notifications.update_success')); notify('success', t('robot_edit.notifications.update_success'));
handleStart(robot); handleStart(robot);
handleClose(); handleClose();
setTimeout(() => {
window.location.reload();
}, 1000);
} else { } else {
notify('error', t('robot_edit.notifications.update_failed')); notify('error', t('robot_edit.notifications.update_failed'));
} }

View File

@@ -54,8 +54,8 @@ interface RobotSettingsProps {
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => { export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null); const [userEmail, setUserEmail] = useState<string | null>(null);
const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore(); const { recordingId, notify } = useGlobalInfoStore();
useEffect(() => { useEffect(() => {

View File

@@ -35,8 +35,9 @@ interface CollapsibleRowProps {
currentLog: string; currentLog: string;
abortRunHandler: () => void; abortRunHandler: () => void;
runningRecordingName: string; runningRecordingName: string;
urlRunId: string | null;
} }
export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => { export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName, urlRunId }: CollapsibleRowProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [open, setOpen] = useState(isOpen); const [open, setOpen] = useState(isOpen);
@@ -62,14 +63,18 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
abortRunHandler(); abortRunHandler();
} }
useEffect(() => {
setOpen(urlRunId === row.runId || isOpen);
}, [urlRunId, row.runId, isOpen]);
const handleRowExpand = () => { const handleRowExpand = () => {
const newOpen = !open; const newOpen = !open;
setOpen(newOpen); setOpen(newOpen);
if (newOpen) { navigate(
navigate(`/runs/${row.robotMetaId}/run/${row.runId}`); newOpen
} else { ? `/runs/${row.robotMetaId}/run/${row.runId}`
navigate(`/runs/${row.robotMetaId}`); : `/runs/${row.robotMetaId}`
} );
//scrollToLogBottom(); //scrollToLogBottom();
}; };

View File

@@ -12,7 +12,7 @@ import TableRow from '@mui/material/TableRow';
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material'; import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import { useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRuns } from "../../api/storage"; import { getStoredRuns } from "../../api/storage";
import { RunSettings } from "./RunSettings"; import { RunSettings } from "./RunSettings";
@@ -70,6 +70,13 @@ interface RunsTableProps {
runningRecordingName: string; runningRecordingName: string;
} }
interface PaginationState {
[robotMetaId: string]: {
page: number;
rowsPerPage: number;
};
}
export const RunsTable: React.FC<RunsTableProps> = ({ export const RunsTable: React.FC<RunsTableProps> = ({
currentInterpretationLog, currentInterpretationLog,
abortRunHandler, abortRunHandler,
@@ -78,7 +85,24 @@ export const RunsTable: React.FC<RunsTableProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const getUrlParams = () => {
const match = location.pathname.match(/\/runs\/([^\/]+)(?:\/run\/([^\/]+))?/);
return {
robotMetaId: match?.[1] || null,
urlRunId: match?.[2] || null
};
};
const { robotMetaId: urlRobotMetaId, urlRunId } = getUrlParams();
const isAccordionExpanded = useCallback((currentRobotMetaId: string) => {
return currentRobotMetaId === urlRobotMetaId;
}, [urlRobotMetaId]);
const [accordionPage, setAccordionPage] = useState(0);
const [accordionsPerPage, setAccordionsPerPage] = useState(10);
const [accordionSortConfigs, setAccordionSortConfigs] = useState<AccordionSortConfig>({}); const [accordionSortConfigs, setAccordionSortConfigs] = useState<AccordionSortConfig>({});
const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => { const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => {
@@ -107,11 +131,10 @@ export const RunsTable: React.FC<RunsTableProps> = ({
[t] [t]
); );
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState<Data[]>([]); const [rows, setRows] = useState<Data[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
@@ -119,15 +142,50 @@ export const RunsTable: React.FC<RunsTableProps> = ({
navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs'); navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs');
}, [navigate]); }, [navigate]);
const handleChangePage = useCallback((event: unknown, newPage: number) => { const handleAccordionPageChange = useCallback((event: unknown, newPage: number) => {
setPage(newPage); setAccordionPage(newPage);
}, []);
const handleAccordionsPerPageChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setAccordionsPerPage(+event.target.value);
setAccordionPage(0);
}, []); }, []);
const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleChangePage = useCallback((robotMetaId: string, newPage: number) => {
setRowsPerPage(+event.target.value); setPaginationStates(prev => ({
setPage(0); ...prev,
[robotMetaId]: {
...prev[robotMetaId],
page: newPage
}
}));
}, []); }, []);
const handleChangeRowsPerPage = useCallback((robotMetaId: string, newRowsPerPage: number) => {
setPaginationStates(prev => ({
...prev,
[robotMetaId]: {
page: 0, // Reset to first page when changing rows per page
rowsPerPage: newRowsPerPage
}
}));
}, []);
const getPaginationState = useCallback((robotMetaId: string) => {
const defaultState = { page: 0, rowsPerPage: 10 };
if (!paginationStates[robotMetaId]) {
setTimeout(() => {
setPaginationStates(prev => ({
...prev,
[robotMetaId]: defaultState
}));
}, 0);
return defaultState;
}
return paginationStates[robotMetaId];
}, [paginationStates]);
const debouncedSearch = useCallback((fn: Function, delay: number) => { const debouncedSearch = useCallback((fn: Function, delay: number) => {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
return (...args: any[]) => { return (...args: any[]) => {
@@ -139,14 +197,20 @@ export const RunsTable: React.FC<RunsTableProps> = ({
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const debouncedSetSearch = debouncedSearch((value: string) => { const debouncedSetSearch = debouncedSearch((value: string) => {
setSearchTerm(value); setSearchTerm(value);
setPage(0); setAccordionPage(0);
setPaginationStates(prev => {
const reset = Object.keys(prev).reduce((acc, robotId) => ({
...acc,
[robotId]: { ...prev[robotId], page: 0 }
}), {});
return reset;
});
}, 300); }, 300);
debouncedSetSearch(event.target.value); debouncedSetSearch(event.target.value);
}, [debouncedSearch]); }, [debouncedSearch]);
const fetchRuns = useCallback(async () => { const fetchRuns = useCallback(async () => {
try { try {
setIsLoading(true);
const runs = await getStoredRuns(); const runs = await getStoredRuns();
if (runs) { if (runs) {
const parsedRows: Data[] = runs.map((run: any, index: number) => ({ const parsedRows: Data[] = runs.map((run: any, index: number) => ({
@@ -159,8 +223,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
} }
} catch (error) { } catch (error) {
notify('error', t('runstable.notifications.fetch_error')); notify('error', t('runstable.notifications.fetch_error'));
} finally {
setIsLoading(false);
} }
}, [notify, t]); }, [notify, t]);
@@ -194,18 +256,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
return result; return result;
}, [rows, searchTerm]); }, [rows, searchTerm]);
// Group filtered rows by robot meta id
const groupedRows = useMemo(() =>
filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = [];
}
acc[row.robotMetaId].push(row);
return acc;
}, {} as Record<string, Data[]>),
[filteredRows]
);
const parseDateString = (dateStr: string): Date => { const parseDateString = (dateStr: string): Date => {
try { try {
if (dateStr.includes('PM') || dateStr.includes('AM')) { if (dateStr.includes('PM') || dateStr.includes('AM')) {
@@ -218,7 +268,37 @@ export const RunsTable: React.FC<RunsTableProps> = ({
} }
}; };
const groupedRows = useMemo(() => {
const groupedData = filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = [];
}
acc[row.robotMetaId].push(row);
return acc;
}, {} as Record<string, Data[]>);
Object.keys(groupedData).forEach(robotId => {
groupedData[robotId].sort((a, b) =>
parseDateString(b.startedAt).getTime() - parseDateString(a.startedAt).getTime()
);
});
const robotEntries = Object.entries(groupedData).map(([robotId, runs]) => ({
robotId,
runs,
latestRunDate: parseDateString(runs[0].startedAt).getTime()
}));
robotEntries.sort((a, b) => b.latestRunDate - a.latestRunDate);
return robotEntries.reduce((acc, { robotId, runs }) => {
acc[robotId] = runs;
return acc;
}, {} as Record<string, Data[]>);
}, [filteredRows]);
const renderTableRows = useCallback((data: Data[], robotMetaId: string) => { const renderTableRows = useCallback((data: Data[], robotMetaId: string) => {
const { page, rowsPerPage } = getPaginationState(robotMetaId);
const start = page * rowsPerPage; const start = page * rowsPerPage;
const end = start + rowsPerPage; const end = start + rowsPerPage;
@@ -245,13 +325,14 @@ export const RunsTable: React.FC<RunsTableProps> = ({
key={`row-${row.id}`} key={`row-${row.id}`}
row={row} row={row}
handleDelete={handleDelete} handleDelete={handleDelete}
isOpen={runId === row.runId && runningRecordingName === row.name} isOpen={urlRunId === row.runId || (runId === row.runId && runningRecordingName === row.name)}
currentLog={currentInterpretationLog} currentLog={currentInterpretationLog}
abortRunHandler={abortRunHandler} abortRunHandler={abortRunHandler}
runningRecordingName={runningRecordingName} runningRecordingName={runningRecordingName}
urlRunId={urlRunId}
/> />
)); ));
}, [page, rowsPerPage, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]); }, [paginationStates, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]);
const renderSortIcon = useCallback((column: Column, robotMetaId: string) => { const renderSortIcon = useCallback((column: Column, robotMetaId: string) => {
const sortConfig = accordionSortConfigs[robotMetaId]; const sortConfig = accordionSortConfigs[robotMetaId];
@@ -279,14 +360,6 @@ export const RunsTable: React.FC<RunsTableProps> = ({
: <UnfoldMore fontSize="small" />; : <UnfoldMore fontSize="small" />;
}, [accordionSortConfigs]); }, [accordionSortConfigs]);
if (isLoading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="50vh">
<CircularProgress />
</Box>
);
}
return ( return (
<React.Fragment> <React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
@@ -305,82 +378,99 @@ export const RunsTable: React.FC<RunsTableProps> = ({
</Box> </Box>
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}> <TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
{Object.entries(groupedRows).map(([robotMetaId, data]) => ( {Object.entries(groupedRows)
<Accordion .slice(
key={robotMetaId} accordionPage * accordionsPerPage,
onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)} accordionPage * accordionsPerPage + accordionsPerPage
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering )
> .map(([robotMetaId, data]) => (
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <Accordion
<Typography variant="h6">{data[data.length - 1].name}</Typography> key={robotMetaId}
</AccordionSummary> onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)}
<AccordionDetails> TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
<Table stickyHeader aria-label="sticky table"> >
<TableHead> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<TableRow> <Typography variant="h6">{data[data.length - 1].name}</Typography>
<TableCell /> </AccordionSummary>
{translatedColumns.map((column) => ( <AccordionDetails>
<TableCell <Table stickyHeader aria-label="sticky table">
key={column.id} <TableHead>
align={column.align} <TableRow>
style={{ <TableCell />
minWidth: column.minWidth, {translatedColumns.map((column) => (
cursor: column.id === 'startedAt' || column.id === 'finishedAt' ? 'pointer' : 'default' <TableCell
}} key={column.id}
onClick={() => { align={column.align}
if (column.id === 'startedAt' || column.id === 'finishedAt') { style={{
handleSort(column.id, robotMetaId); minWidth: column.minWidth,
} cursor: column.id === 'startedAt' || column.id === 'finishedAt' ? 'pointer' : 'default'
}} }}
> onClick={() => {
<Tooltip if (column.id === 'startedAt' || column.id === 'finishedAt') {
title={ handleSort(column.id, robotMetaId);
(column.id === 'startedAt' || column.id === 'finishedAt')
? t('runstable.sort_tooltip')
: ''
}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
'&:hover': {
'& .sort-icon': {
opacity: 1
}
} }
}}> }}
{column.label} >
<Box className="sort-icon" sx={{ <Tooltip
display: 'flex', title={
alignItems: 'center', (column.id === 'startedAt' || column.id === 'finishedAt')
opacity: accordionSortConfigs[robotMetaId]?.field === column.id ? 1 : 0.3, ? t('runstable.sort_tooltip')
transition: 'opacity 0.2s' : ''
}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
'&:hover': {
'& .sort-icon': {
opacity: 1
}
}
}}> }}>
{renderSortIcon(column, robotMetaId)} {column.label}
<Box className="sort-icon" sx={{
display: 'flex',
alignItems: 'center',
opacity: accordionSortConfigs[robotMetaId]?.field === column.id ? 1 : 0.3,
transition: 'opacity 0.2s'
}}>
{renderSortIcon(column, robotMetaId)}
</Box>
</Box> </Box>
</Box> </Tooltip>
</Tooltip> </TableCell>
</TableCell> ))}
))} </TableRow>
</TableRow> </TableHead>
</TableHead> <TableBody>
<TableBody> {renderTableRows(data, robotMetaId)}
{renderTableRows(data, robotMetaId)} </TableBody>
</TableBody> </Table>
</Table>
</AccordionDetails> <TablePagination
</Accordion> component="div"
))} count={data.length}
rowsPerPage={getPaginationState(robotMetaId).rowsPerPage}
page={getPaginationState(robotMetaId).page}
onPageChange={(_, newPage) => handleChangePage(robotMetaId, newPage)}
onRowsPerPageChange={(event) =>
handleChangeRowsPerPage(robotMetaId, +event.target.value)
}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
</AccordionDetails>
</Accordion>
))}
</TableContainer> </TableContainer>
<TablePagination <TablePagination
component="div" component="div"
count={filteredRows.length} count={Object.keys(groupedRows).length}
rowsPerPage={rowsPerPage} page={accordionPage}
page={page} rowsPerPage={accordionsPerPage}
onPageChange={handleChangePage} onPageChange={handleAccordionPageChange}
onRowsPerPageChange={handleChangeRowsPerPage} onRowsPerPageChange={handleAccordionsPerPageChange}
rowsPerPageOptions={[10, 25, 50, 100]} rowsPerPageOptions={[10, 25, 50, 100]}
/> />
</React.Fragment> </React.Fragment>

View File

@@ -1,6 +1,44 @@
import React, { createContext, useContext, useState } from "react"; import React, { createContext, useContext, useState } from "react";
import { AlertSnackbarProps } from "../components/ui/AlertSnackbar"; import { AlertSnackbarProps } from "../components/ui/AlertSnackbar";
import { WhereWhatPair } from "maxun-core";
interface RobotMeta {
name: string;
id: string;
createdAt: string;
pairs: number;
updatedAt: string;
params: any[];
}
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 GlobalInfo { interface GlobalInfo {
browserId: string | null; browserId: string | null;
@@ -16,6 +54,8 @@ interface GlobalInfo {
setRecordings: (recordings: string[]) => void; setRecordings: (recordings: string[]) => void;
rerenderRuns: boolean; rerenderRuns: boolean;
setRerenderRuns: (rerenderRuns: boolean) => void; setRerenderRuns: (rerenderRuns: boolean) => void;
rerenderRobots: boolean;
setRerenderRobots: (rerenderRuns: boolean) => void;
recordingLength: number; recordingLength: number;
setRecordingLength: (recordingLength: number) => void; setRecordingLength: (recordingLength: number) => void;
recordingId: string | null; recordingId: string | null;
@@ -52,6 +92,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
recordingId = null; recordingId = null;
recordings: string[] = []; recordings: string[] = [];
rerenderRuns = false; rerenderRuns = false;
rerenderRobots = false;
recordingName = ''; recordingName = '';
initialUrl = 'https://'; initialUrl = 'https://';
recordingUrl = 'https://'; recordingUrl = 'https://';
@@ -75,6 +116,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
const [notification, setNotification] = useState<AlertSnackbarProps>(globalInfoStore.notification); const [notification, setNotification] = useState<AlertSnackbarProps>(globalInfoStore.notification);
const [recordings, setRecordings] = useState<string[]>(globalInfoStore.recordings); const [recordings, setRecordings] = useState<string[]>(globalInfoStore.recordings);
const [rerenderRuns, setRerenderRuns] = useState<boolean>(globalInfoStore.rerenderRuns); const [rerenderRuns, setRerenderRuns] = useState<boolean>(globalInfoStore.rerenderRuns);
const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots);
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength); const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId); const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName); const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
@@ -121,6 +163,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
setRecordings, setRecordings,
rerenderRuns, rerenderRuns,
setRerenderRuns, setRerenderRuns,
rerenderRobots,
setRerenderRobots,
recordingLength, recordingLength,
setRecordingLength, setRecordingLength,
recordingId, recordingId,

View File

@@ -53,8 +53,14 @@ const Login = () => {
notify("success", t('login.welcome_notification')); notify("success", t('login.welcome_notification'));
window.localStorage.setItem("user", JSON.stringify(data)); window.localStorage.setItem("user", JSON.stringify(data));
navigate("/"); navigate("/");
} catch (err) { } catch (err: any) {
notify("error", t('login.error_notification')); const errorResponse = err.response?.data;
const errorMessage = errorResponse?.code
? t(errorResponse.code)
: t('login.error.generic');
notify("error", errorMessage);
setLoading(false); setLoading(false);
} }
}; };

View File

@@ -15,6 +15,7 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings";
import { IntegrationSettings } from "../components/integration/IntegrationSettings"; import { IntegrationSettings } from "../components/integration/IntegrationSettings";
import { RobotSettings } from "../components/robot/RobotSettings"; import { RobotSettings } from "../components/robot/RobotSettings";
import { apiUrl } from "../apiConfig"; import { apiUrl } from "../apiConfig";
import { useNavigate } from 'react-router-dom';
interface MainPageProps { interface MainPageProps {
handleEditRecording: (id: string, fileName: string) => void; handleEditRecording: (id: string, fileName: string) => void;
@@ -24,6 +25,7 @@ interface MainPageProps {
export interface CreateRunResponse { export interface CreateRunResponse {
browserId: string; browserId: string;
runId: string; runId: string;
robotMetaId: string;
} }
export interface ScheduleRunResponse { export interface ScheduleRunResponse {
@@ -40,12 +42,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState(''); const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState('');
const [ids, setIds] = React.useState<CreateRunResponse>({ const [ids, setIds] = React.useState<CreateRunResponse>({
browserId: '', browserId: '',
runId: '' runId: '',
robotMetaId: ''
}); });
let aborted = false; let aborted = false;
const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore(); const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore();
const navigate = useNavigate();
const abortRunHandler = (runId: string) => { const abortRunHandler = (runId: string) => {
aborted = true; aborted = true;
@@ -88,8 +92,9 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
}, [currentInterpretationLog]) }, [currentInterpretationLog])
const handleRunRecording = useCallback((settings: RunSettings) => { const handleRunRecording = useCallback((settings: RunSettings) => {
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId }: CreateRunResponse) => { createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => {
setIds({ browserId, runId }); setIds({ browserId, runId, robotMetaId });
navigate(`/runs/${robotMetaId}/run/${runId}`);
const socket = const socket =
io(`${apiUrl}/${browserId}`, { io(`${apiUrl}/${browserId}`, {
transports: ["websocket"], transports: ["websocket"],

View File

@@ -48,7 +48,13 @@ const Register = () => {
window.localStorage.setItem("user", JSON.stringify(data)); window.localStorage.setItem("user", JSON.stringify(data));
navigate("/"); navigate("/");
} catch (error:any) { } catch (error:any) {
notify("error", error.response.data || t('register.error_notification')); const errorResponse = error.response?.data;
const errorMessage = errorResponse?.code
? t(errorResponse.code)
: t('register.error.generic');
notify("error", errorMessage);
setLoading(false); setLoading(false);
} }
}; };