@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxun-core",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"description": "Core package for Maxun, responsible for data extraction",
|
||||
"main": "build/index.js",
|
||||
"typings": "build/index.d.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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 fetch from 'cross-fetch';
|
||||
import path from 'path';
|
||||
@@ -548,232 +548,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 previousHeight = 0;
|
||||
// track unique items per page to avoid re-scraping
|
||||
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[]) => {
|
||||
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(',');
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
while (true) {
|
||||
// Reduced timeout for faster performance
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
||||
|
||||
switch (config.pagination.type) {
|
||||
case 'scrollDown':
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(2000);
|
||||
case 'scrollDown': {
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
if (currentHeight === previousHeight) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
if (currentHeight === previousHeight) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
previousHeight = currentHeight;
|
||||
break;
|
||||
previousHeight = currentHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'scrollUp':
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(2000);
|
||||
case 'scrollUp': {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
|
||||
if (currentTopHeight === 0) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
|
||||
if (currentTopHeight === 0) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
previousHeight = currentTopHeight;
|
||||
break;
|
||||
previousHeight = currentTopHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'clickNext':
|
||||
debugLog("Current URL:", page.url());
|
||||
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
|
||||
// Filter out already scraped items
|
||||
const newResults = pageResults.filter(item => {
|
||||
const uniqueKey = JSON.stringify(item);
|
||||
if (scrapedItems.has(uniqueKey)) return false;
|
||||
scrapedItems.add(uniqueKey);
|
||||
return true;
|
||||
});
|
||||
|
||||
allResults = allResults.concat(newResults);
|
||||
debugLog("Results collected so far:", allResults.length);
|
||||
|
||||
if (config.limit && allResults.length >= config.limit) {
|
||||
return allResults.slice(0, config.limit);
|
||||
}
|
||||
case 'clickNext': {
|
||||
const currentUrl = page.url();
|
||||
visitedUrls.add(currentUrl);
|
||||
|
||||
await scrapeCurrentPage();
|
||||
if (checkLimit()) return allResults;
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
let checkButton = null;
|
||||
let workingSelector = null;
|
||||
|
||||
// Try each selector with explicit waiting
|
||||
for (const selector of availableSelectors) {
|
||||
const { button, workingSelector } = await findWorkingButton(availableSelectors);
|
||||
if (!button || !workingSelector) {
|
||||
// Final retry for navigation when no selectors work
|
||||
const success = await retryOperation(async () => {
|
||||
try {
|
||||
checkButton = await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout: 30000
|
||||
});
|
||||
if (checkButton) {
|
||||
workingSelector = selector;
|
||||
debugLog('Found working selector:', selector);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog(`Selector failed: ${selector} - ${error.message}`);
|
||||
await page.evaluate(() => window.history.forward());
|
||||
const newUrl = page.url();
|
||||
return !visitedUrls.has(newUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) return allResults;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!workingSelector) {
|
||||
debugLog('No working selector found after trying all options');
|
||||
return allResults;
|
||||
}
|
||||
availableSelectors = availableSelectors.slice(
|
||||
availableSelectors.indexOf(workingSelector)
|
||||
);
|
||||
|
||||
const nextButton = await page.$(workingSelector);
|
||||
if (!nextButton) {
|
||||
debugLog('Next button not found');
|
||||
return allResults;
|
||||
}
|
||||
|
||||
const selectorIndex = availableSelectors.indexOf(workingSelector);
|
||||
availableSelectors = availableSelectors.slice(selectorIndex);
|
||||
let retryCount = 0;
|
||||
let navigationSuccess = false;
|
||||
|
||||
while (retryCount < MAX_RETRIES && !navigationSuccess) {
|
||||
try {
|
||||
// Store current URL to check if navigation succeeded
|
||||
const previousUrl = page.url();
|
||||
visitedUrls.push(previousUrl);
|
||||
|
||||
// Try both click methods in sequence
|
||||
try {
|
||||
await Promise.all([
|
||||
page.waitForNavigation({
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 15000
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 15000
|
||||
}),
|
||||
nextButton.click()
|
||||
button.click()
|
||||
]);
|
||||
} catch (error) {
|
||||
// If we're still on the same URL, try dispatch event
|
||||
if (page.url() === previousUrl) {
|
||||
await Promise.all([
|
||||
page.waitForNavigation({
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 15000
|
||||
}),
|
||||
nextButton.dispatchEvent('click')
|
||||
]);
|
||||
navigationSuccess = true;
|
||||
} catch (error) {
|
||||
debugLog(`Regular click failed on attempt ${retryCount + 1}. Trying DispatchEvent`);
|
||||
|
||||
// If regular click fails, try dispatchEvent
|
||||
if (page.url() === currentUrl) {
|
||||
try {
|
||||
await Promise.all([
|
||||
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');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||
|
||||
const currentUrl = page.url();
|
||||
if (visitedUrls.includes(currentUrl)) {
|
||||
debugLog(`Navigation failed/Detected navigation to previously visited URL: ${currentUrl}`);
|
||||
return allResults;
|
||||
const newUrl = page.url();
|
||||
if (visitedUrls.has(newUrl)) {
|
||||
debugLog(`Detected navigation to previously visited URL ${newUrl} on attempt ${retryCount + 1}`);
|
||||
navigationSuccess = false;
|
||||
}
|
||||
|
||||
// Give the page a moment to stabilize after navigation
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
if (navigationSuccess) {
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clickLoadMore':
|
||||
while (true) {
|
||||
let checkButton = null;
|
||||
let workingSelector = null;
|
||||
|
||||
for (const selector of availableSelectors) {
|
||||
try {
|
||||
checkButton = await page.waitForSelector(selector, {
|
||||
state: 'attached',
|
||||
timeout: 30000
|
||||
});
|
||||
if (checkButton) {
|
||||
workingSelector = selector;
|
||||
debugLog('Found working selector:', selector);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog(`Load More selector failed: ${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!workingSelector) {
|
||||
debugLog('No working Load More selector found');
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
const loadMoreButton = await page.$(workingSelector);
|
||||
if (!loadMoreButton) {
|
||||
debugLog('Load More button not found');
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
const selectorIndex = availableSelectors.indexOf(workingSelector);
|
||||
availableSelectors = availableSelectors.slice(selectorIndex);
|
||||
|
||||
|
||||
// Update available selectors to start from the working one
|
||||
availableSelectors = availableSelectors.slice(
|
||||
availableSelectors.indexOf(workingSelector)
|
||||
);
|
||||
|
||||
// Implement retry mechanism for clicking the button
|
||||
let retryCount = 0;
|
||||
let clickSuccess = false;
|
||||
|
||||
while (retryCount < MAX_RETRIES && !clickSuccess) {
|
||||
try {
|
||||
try {
|
||||
await loadMoreButton.click();
|
||||
clickSuccess = true;
|
||||
} 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) {
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
if (currentHeight === previousHeight) {
|
||||
debugLog('No more items loaded after Load More');
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
previousHeight = currentHeight;
|
||||
|
||||
if (config.limit && allResults.length >= config.limit) {
|
||||
allResults = allResults.slice(0, config.limit);
|
||||
break;
|
||||
debugLog(`Click attempt ${retryCount + 1} failed completely.`);
|
||||
retryCount++;
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
debugLog(`Retrying click - attempt ${retryCount + 1} of ${MAX_RETRIES}`);
|
||||
await page.waitForTimeout(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
const results = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(results);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
if (config.limit && allResults.length >= config.limit) {
|
||||
allResults = allResults.slice(0, config.limit);
|
||||
|
||||
if (!clickSuccess) {
|
||||
debugLog(`Load More clicking failed after ${MAX_RETRIES} attempts`);
|
||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||
allResults = allResults.concat(finalResults);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
// Wait for content to load and check scroll height
|
||||
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;
|
||||
}
|
||||
|
||||
default: {
|
||||
await scrapeCurrentPage();
|
||||
return allResults;
|
||||
}
|
||||
}
|
||||
|
||||
if (checkLimit()) break;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog(`Fatal error: ${error.message}`);
|
||||
return allResults;
|
||||
}
|
||||
|
||||
return allResults;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maxun",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.9",
|
||||
"author": "Maxun",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.8.0",
|
||||
"loglevel-plugin-remote": "^0.6.8",
|
||||
"maxun-core": "^0.0.10",
|
||||
"maxun-core": "^0.0.11",
|
||||
"minio": "^8.0.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
"register_prompt": "Noch keinen Account?",
|
||||
"register_link": "Registrieren",
|
||||
"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": {
|
||||
"title": "Konto registrieren",
|
||||
@@ -19,7 +28,16 @@
|
||||
"register_prompt": "Bereits ein Konto?",
|
||||
"login_link": "Einloggen",
|
||||
"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": {
|
||||
"run": "Ausführen",
|
||||
@@ -40,7 +58,7 @@
|
||||
"delete": "Löschen",
|
||||
"duplicate": "Duplizieren",
|
||||
"notifications": {
|
||||
"delete_warning": "Roboter kann nicht gelöscht werden, da zugehörige Ausführungen vorhanden sind",
|
||||
"delete_warning": "Der Roboter hat zugehörige Ausführungen. Löschen Sie zuerst die Ausführungen, um den Roboter zu löschen",
|
||||
"delete_success": "Roboter erfolgreich gelöscht",
|
||||
"auth_success": "Roboter erfolgreich authentifiziert"
|
||||
}
|
||||
@@ -62,6 +80,7 @@
|
||||
"delete": "Löschen",
|
||||
"settings": "Einstellungen",
|
||||
"search": "Ausführungen suchen...",
|
||||
"sort_tooltip": "Zum Sortieren klicken",
|
||||
"notifications": {
|
||||
"no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.",
|
||||
"delete_success": "Ausführung erfolgreich gelöscht"
|
||||
@@ -451,6 +470,7 @@
|
||||
"log": "Protokoll"
|
||||
},
|
||||
"empty_output": "Die Ausgabe ist leer.",
|
||||
"loading": "Ausführung läuft. Extrahierte Daten werden nach Abschluss des Durchlaufs hier angezeigt.",
|
||||
"captured_data": {
|
||||
"title": "Erfasste Daten",
|
||||
"download_json": "Als JSON herunterladen",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
"register_prompt": "Don't have an account?",
|
||||
"register_link": "Register",
|
||||
"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": {
|
||||
"title": "Register Account",
|
||||
@@ -19,7 +28,16 @@
|
||||
"register_prompt": "Already have an account?",
|
||||
"login_link": "Login",
|
||||
"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":{
|
||||
"run": "Run",
|
||||
@@ -41,7 +59,7 @@
|
||||
"duplicate":"Duplicate",
|
||||
"search":"Search Robots...",
|
||||
"notifications": {
|
||||
"delete_warning": "Cannot delete robot as it has associated runs",
|
||||
"delete_warning": "The robot has associated runs. First delete runs to delete the robot",
|
||||
"delete_success": "Robot deleted successfully",
|
||||
"auth_success": "Robot successfully authenticated"
|
||||
}
|
||||
@@ -63,6 +81,7 @@
|
||||
"delete":"Delete",
|
||||
"settings":"Settings",
|
||||
"search":"Search Runs...",
|
||||
"sort_tooltip": "Click to sort",
|
||||
"notifications": {
|
||||
"no_runs": "No runs found. Please try again.",
|
||||
"delete_success": "Run deleted successfully"
|
||||
@@ -462,6 +481,7 @@
|
||||
"log": "Log"
|
||||
},
|
||||
"empty_output": "The output is empty.",
|
||||
"loading": "Run in progress. Extracted data will appear here once run completes.",
|
||||
"captured_data": {
|
||||
"title": "Captured Data",
|
||||
"download_json": "Download as JSON",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
"register_prompt": "¿No tienes una cuenta?",
|
||||
"register_link": "Registrarse",
|
||||
"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": {
|
||||
"title": "Crear cuenta",
|
||||
@@ -19,7 +28,16 @@
|
||||
"register_prompt": "¿Ya tienes una cuenta?",
|
||||
"login_link": "Iniciar sesión",
|
||||
"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": {
|
||||
"run": "Ejecutar",
|
||||
@@ -41,7 +59,7 @@
|
||||
"duplicate": "Duplicar",
|
||||
"search": "Buscar robots...",
|
||||
"notifications": {
|
||||
"delete_warning": "No se puede eliminar el robot ya que tiene ejecuciones asociadas",
|
||||
"delete_warning": "El robot tiene ejecuciones asociadas. Primero elimine las ejecuciones para eliminar el robot",
|
||||
"delete_success": "Robot eliminado exitosamente",
|
||||
"auth_success": "Robot autenticado exitosamente"
|
||||
}
|
||||
@@ -63,6 +81,7 @@
|
||||
"delete": "Eliminar",
|
||||
"settings": "Ajustes",
|
||||
"search": "Buscar ejecuciones...",
|
||||
"sort_tooltip": "Haga clic para ordenar",
|
||||
"notifications": {
|
||||
"no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.",
|
||||
"delete_success": "Ejecución eliminada con éxito"
|
||||
@@ -452,6 +471,7 @@
|
||||
"log": "Registro"
|
||||
},
|
||||
"empty_output": "La salida está vacía.",
|
||||
"loading": "Ejecución en curso. Los datos extraídos aparecerán aquí una vez que se complete la ejecución.",
|
||||
"captured_data": {
|
||||
"title": "Datos Capturados",
|
||||
"download_json": "Descargar como JSON",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
"register_prompt": "アカウントをお持ちでないですか?",
|
||||
"register_link": "登録する",
|
||||
"welcome_notification": "Maxunへようこそ!",
|
||||
"error_notification": "ログインに失敗しました。もう一度お試しください。"
|
||||
"validation": {
|
||||
"required_fields": "メールアドレスとパスワードは必須です",
|
||||
"password_length": "パスワードは6文字以上である必要があります"
|
||||
},
|
||||
"error": {
|
||||
"user_not_found": "ユーザーが存在しません",
|
||||
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
|
||||
"server_error": "ログインに失敗しました。後でもう一度お試しください",
|
||||
"generic": "エラーが発生しました。もう一度お試しください"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "アカウントを登録する",
|
||||
@@ -19,7 +28,16 @@
|
||||
"register_prompt": "既にアカウントをお持ちですか?",
|
||||
"login_link": "ログイン",
|
||||
"welcome_notification": "Maxunへようこそ!",
|
||||
"error_notification": "登録に失敗しました。もう一度お試しください。"
|
||||
"validation": {
|
||||
"email_required": "メールアドレスは必須です",
|
||||
"password_requirements": "パスワードは6文字以上である必要があります"
|
||||
},
|
||||
"error": {
|
||||
"user_exists": "このメールアドレスは既に登録されています",
|
||||
"creation_failed": "アカウントを作成できませんでした",
|
||||
"server_error": "サーバーエラーが発生しました",
|
||||
"generic": "登録に失敗しました。もう一度お試しください"
|
||||
}
|
||||
},
|
||||
"recordingtable": {
|
||||
"run": "実行",
|
||||
@@ -41,7 +59,7 @@
|
||||
"duplicate": "複製",
|
||||
"search": "ロボットを検索...",
|
||||
"notifications": {
|
||||
"delete_warning": "関連する実行があるため、ロボットを削除できません",
|
||||
"delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください",
|
||||
"delete_success": "ロボットが正常に削除されました",
|
||||
"auth_success": "ロボットの認証に成功しました"
|
||||
}
|
||||
@@ -63,6 +81,7 @@
|
||||
"delete": "削除",
|
||||
"settings": "設定",
|
||||
"search": "実行を検索...",
|
||||
"sort_tooltip": "クリックして並べ替え",
|
||||
"notifications": {
|
||||
"no_runs": "実行が見つかりません。もう一度お試しください。",
|
||||
"delete_success": "実行が正常に削除されました"
|
||||
@@ -452,6 +471,7 @@
|
||||
"log": "ログ"
|
||||
},
|
||||
"empty_output": "出力は空です。",
|
||||
"loading": "実行中です。実行が完了すると、抽出されたデータがここに表示されます。",
|
||||
"captured_data": {
|
||||
"title": "キャプチャされたデータ",
|
||||
"download_json": "JSONとしてダウンロード",
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
"register_prompt": "还没有账号?",
|
||||
"register_link": "注册",
|
||||
"welcome_notification": "欢迎使用 Maxun!",
|
||||
"error_notification": "登录失败。请重试。"
|
||||
"validation": {
|
||||
"required_fields": "邮箱和密码为必填项",
|
||||
"password_length": "密码必须至少6个字符"
|
||||
},
|
||||
"error": {
|
||||
"user_not_found": "用户不存在",
|
||||
"invalid_credentials": "邮箱或密码无效",
|
||||
"server_error": "登录失败,请稍后重试",
|
||||
"generic": "发生错误,请重试"
|
||||
}
|
||||
},
|
||||
"register": {
|
||||
"title": "注册账号",
|
||||
@@ -19,7 +28,16 @@
|
||||
"register_prompt": "已有账号?",
|
||||
"login_link": "登录",
|
||||
"welcome_notification": "欢迎使用 Maxun!",
|
||||
"error_notification": "注册失败。请重试。"
|
||||
"validation": {
|
||||
"email_required": "邮箱是必填项",
|
||||
"password_requirements": "密码必须至少6个字符"
|
||||
},
|
||||
"error": {
|
||||
"user_exists": "该邮箱已被注册",
|
||||
"creation_failed": "无法创建账户",
|
||||
"server_error": "服务器错误",
|
||||
"generic": "注册失败,请重试"
|
||||
}
|
||||
},
|
||||
"recordingtable": {
|
||||
"run": "运行",
|
||||
@@ -41,7 +59,7 @@
|
||||
"duplicate": "复制",
|
||||
"search": "搜索机器人...",
|
||||
"notifications": {
|
||||
"delete_warning": "无法删除机器人,因为它有关联的运行记录",
|
||||
"delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人",
|
||||
"delete_success": "机器人删除成功",
|
||||
"auth_success": "机器人认证成功"
|
||||
}
|
||||
@@ -63,6 +81,7 @@
|
||||
"delete": "删除",
|
||||
"settings": "设置",
|
||||
"search": "搜索运行记录...",
|
||||
"sort_tooltip": "点击排序",
|
||||
"notifications": {
|
||||
"no_runs": "未找到运行记录。请重试。",
|
||||
"delete_success": "运行记录删除成功"
|
||||
@@ -452,6 +471,7 @@
|
||||
"log": "日志"
|
||||
},
|
||||
"empty_output": "输出为空。",
|
||||
"loading": "运行中。运行完成后,提取的数据将显示在此处。",
|
||||
"captured_data": {
|
||||
"title": "捕获的数据",
|
||||
"download_json": "下载为JSON",
|
||||
|
||||
@@ -17,62 +17,110 @@ router.post("/register", async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email) return res.status(400).send("Email is required");
|
||||
if (!password || password.length < 6)
|
||||
return res
|
||||
.status(400)
|
||||
.send("Password is required and must be at least 6 characters");
|
||||
// Validation checks with translation codes
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "register.validation.email_required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "register.validation.password_requirements"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
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);
|
||||
|
||||
// Create user
|
||||
let user: any;
|
||||
|
||||
try {
|
||||
user = await User.create({ email, password: hashedPassword });
|
||||
} catch (error: any) {
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
// Check JWT secret
|
||||
if (!process.env.JWT_SECRET) {
|
||||
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"
|
||||
});
|
||||
}
|
||||
|
||||
// Success path
|
||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string);
|
||||
user.password = undefined as unknown as string;
|
||||
res.cookie("token", token, {
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
capture("maxun-oss-user-registered", {
|
||||
email: user.email,
|
||||
userId: user.id,
|
||||
registeredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(`User registered`);
|
||||
res.json(user);
|
||||
|
||||
} catch (error: any) {
|
||||
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) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password)
|
||||
return res.status(400).send("Email and password are required");
|
||||
if (password.length < 6)
|
||||
return res.status(400).send("Password must be at least 6 characters");
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
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 } });
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -90,8 +138,11 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error: any) {
|
||||
res.status(400).send(`Could not login user - ${error.message}`);
|
||||
console.log(`Could not login user - ${error}`);
|
||||
console.error(`Login error: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: "SERVER_ERROR",
|
||||
code: "login.error.server_error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,12 +158,13 @@ router.get("/logout", async (req, res) => {
|
||||
router.get(
|
||||
"/current-user",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
try {
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
attributes: { exclude: ["password"] },
|
||||
});
|
||||
if (!user) {
|
||||
@@ -135,7 +187,7 @@ router.get(
|
||||
router.get(
|
||||
"/user/:id",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -164,12 +216,13 @@ router.get(
|
||||
router.post(
|
||||
"/generate-api-key",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
try {
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
attributes: { exclude: ["password"] },
|
||||
});
|
||||
|
||||
@@ -204,13 +257,14 @@ router.post(
|
||||
router.get(
|
||||
"/api-key",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
try {
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
raw: true,
|
||||
attributes: ["api_key"],
|
||||
});
|
||||
@@ -232,13 +286,14 @@ router.get(
|
||||
router.delete(
|
||||
"/delete-api-key",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
if (!req.user) {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, { raw: true });
|
||||
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
@@ -248,7 +303,7 @@ router.delete(
|
||||
return res.status(404).json({ message: "API Key not found" });
|
||||
}
|
||||
|
||||
await User.update({ api_key: null }, { where: { id: req.user.id } });
|
||||
await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } });
|
||||
|
||||
capture("maxun-oss-api-key-deleted", {
|
||||
user_id: user.id,
|
||||
@@ -294,7 +349,8 @@ router.get("/google", (req, res) => {
|
||||
router.get(
|
||||
"/google/callback",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
const { code, state } = req.query;
|
||||
try {
|
||||
if (!state) {
|
||||
@@ -320,12 +376,12 @@ router.get(
|
||||
return res.status(400).json({ message: "Email not found" });
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
// Get the currently authenticated user (from `requireSignIn`)
|
||||
let user = await User.findOne({ where: { id: req.user.id } });
|
||||
let user = await User.findOne({ where: { id: authenticatedReq.user.id } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ message: "User not found" });
|
||||
@@ -403,12 +459,13 @@ router.get(
|
||||
router.post(
|
||||
"/gsheets/data",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
const { spreadsheetId, robotId } = req.body;
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(req.user.id, { raw: true });
|
||||
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ message: "User not found" });
|
||||
@@ -520,13 +577,14 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => {
|
||||
router.post(
|
||||
"/gsheets/remove",
|
||||
requireSignIn,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
const { robotId } = req.body;
|
||||
if (!robotId) {
|
||||
return res.status(400).json({ message: "Robot ID is required" });
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
@@ -548,7 +606,7 @@ router.post(
|
||||
});
|
||||
|
||||
capture("maxun-oss-google-sheet-integration-removed", {
|
||||
user_id: req.user.id,
|
||||
user_id: authenticatedReq.user.id,
|
||||
robot_id: robotId,
|
||||
deleted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -12,16 +12,17 @@ interface AuthenticatedRequest extends Request {
|
||||
user?: { id: string };
|
||||
}
|
||||
|
||||
router.post('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => {
|
||||
router.post('/config', requireSignIn, async (req: Request, res: Response) => {
|
||||
const { server_url, username, password } = req.body;
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
|
||||
try {
|
||||
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
attributes: { exclude: ['password'] },
|
||||
});
|
||||
|
||||
@@ -57,13 +58,14 @@ router.post('/config', requireSignIn, async (req: AuthenticatedRequest, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/test', requireSignIn, async (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/test', requireSignIn, async (req: Request, res: Response) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
try {
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
attributes: ['proxy_url', 'proxy_username', 'proxy_password'],
|
||||
raw: true
|
||||
});
|
||||
@@ -98,13 +100,14 @@ router.get('/test', requireSignIn, async (req: AuthenticatedRequest, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => {
|
||||
router.get('/config', requireSignIn, async (req: Request, res: Response) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
try {
|
||||
if (!req.user) {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
attributes: ['proxy_url', 'proxy_username', 'proxy_password'],
|
||||
raw: true,
|
||||
});
|
||||
@@ -125,12 +128,13 @@ router.get('/config', requireSignIn, async (req: AuthenticatedRequest, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) {
|
||||
router.delete('/config', requireSignIn, async (req: Request, res: Response) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user.id);
|
||||
const user = await User.findByPk(authenticatedReq.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
@@ -501,6 +501,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
|
||||
return res.send({
|
||||
browserId: id,
|
||||
runId: plainRun.runId,
|
||||
robotMetaId: recording.recording_meta.id,
|
||||
});
|
||||
} catch (e) {
|
||||
const { message } = e as Error;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { fork } from 'child_process';
|
||||
import { capture } from "./utils/analytics";
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import swaggerSpec from './swagger/config';
|
||||
import Run from './models/Run';
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
@@ -113,8 +114,23 @@ server.listen(SERVER_PORT, '0.0.0.0', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Main app shutting down...');
|
||||
try {
|
||||
await Run.update(
|
||||
{
|
||||
status: 'failed',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
log: 'Process interrupted during execution - worker shutdown'
|
||||
},
|
||||
{
|
||||
where: { status: 'running' }
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Error updating runs:', error);
|
||||
}
|
||||
|
||||
if (!isProduction) {
|
||||
workerProcess.kill();
|
||||
}
|
||||
|
||||
@@ -67,9 +67,11 @@ async function jobCounts() {
|
||||
|
||||
jobCounts();
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Worker shutting down...');
|
||||
process.exit();
|
||||
});
|
||||
// We dont need this right now
|
||||
|
||||
// process.on('SIGINT', () => {
|
||||
// console.log('Worker shutting down...');
|
||||
// process.exit();
|
||||
// });
|
||||
|
||||
export { workflowQueue, worker };
|
||||
@@ -759,8 +759,7 @@ export class WorkflowGenerator {
|
||||
selectors?.id,
|
||||
selectors?.hrefSelector,
|
||||
selectors?.accessibilitySelector,
|
||||
selectors?.attrSelector,
|
||||
selectors?.generalSelector
|
||||
selectors?.attrSelector
|
||||
]
|
||||
.filter(selector => selector !== null && selector !== undefined)
|
||||
.join(',');
|
||||
|
||||
@@ -1654,6 +1654,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;
|
||||
}
|
||||
|
||||
@@ -1894,6 +1919,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;
|
||||
}
|
||||
|
||||
@@ -2025,6 +2075,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;
|
||||
}
|
||||
|
||||
|
||||
89
src/App.tsx
89
src/App.tsx
@@ -1,89 +1,10 @@
|
||||
import React from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import { GlobalInfoProvider } from "./context/globalInfo";
|
||||
import { PageWrapper } from "./pages/PageWrapper";
|
||||
import i18n from "./i18n";
|
||||
import ThemeModeProvider from './context/theme-provider';
|
||||
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#ff00c3",
|
||||
contrastText: "#ffffff",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
// Default styles for all buttons (optional)
|
||||
textTransform: "none",
|
||||
},
|
||||
containedPrimary: {
|
||||
// Styles for 'contained' variant with 'primary' color
|
||||
"&:hover": {
|
||||
backgroundColor: "#ff66d9",
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
// Apply white background for all 'outlined' variant buttons
|
||||
backgroundColor: "#ffffff",
|
||||
"&:hover": {
|
||||
backgroundColor: "#f0f0f0", // Optional lighter background on hover
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiLink: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"&:hover": {
|
||||
color: "#ff00c3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
// '&:hover': {
|
||||
// color: "#ff66d9",
|
||||
// },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
standardInfo: {
|
||||
backgroundColor: "#fce1f4",
|
||||
color: "#ff00c3",
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#ff00c3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlertTitle: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
"& .MuiAlert-icon": {
|
||||
color: "#ffffff",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeModeProvider>
|
||||
@@ -93,16 +14,6 @@ function App() {
|
||||
</Routes>
|
||||
</GlobalInfoProvider>
|
||||
</ThemeModeProvider>
|
||||
|
||||
// <ThemeProvider theme={theme}>
|
||||
|
||||
// <GlobalInfoProvider>
|
||||
// <Routes>
|
||||
// <Route path="/*" element={<PageWrapper />} />
|
||||
// </Routes>
|
||||
// </GlobalInfoProvider>
|
||||
|
||||
// </ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
return { browserId: '', runId: '' };
|
||||
return { browserId: '', runId: '', robotMetaId: '' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,7 +263,12 @@ export const BrowserWindow = () => {
|
||||
}
|
||||
|
||||
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'));
|
||||
setCurrentListId(Date.now());
|
||||
setFields({});
|
||||
@@ -275,13 +280,25 @@ export const BrowserWindow = () => {
|
||||
// Add fields to the list
|
||||
if (options.length === 1) {
|
||||
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 = {
|
||||
id: Date.now(),
|
||||
type: 'text',
|
||||
label: `Label ${Object.keys(fields).length + 1}`,
|
||||
data: data,
|
||||
selectorObj: {
|
||||
selector: highlighterData.selector,
|
||||
selector: currentSelector,
|
||||
tag: highlighterData.elementInfo?.tagName,
|
||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||
attribute
|
||||
|
||||
@@ -4,8 +4,34 @@ import axios from 'axios';
|
||||
import styled from "styled-components";
|
||||
import { stopRecording } from "../../api/recording";
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar, Tooltip } from "@mui/material";
|
||||
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language, Description, LightMode, DarkMode } from "@mui/icons-material";
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Chip,
|
||||
Button,
|
||||
Modal,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Snackbar,
|
||||
Tooltip
|
||||
} from "@mui/material";
|
||||
import {
|
||||
AccountCircle,
|
||||
Logout,
|
||||
Clear,
|
||||
YouTube,
|
||||
X,
|
||||
GitHub,
|
||||
Update,
|
||||
Close,
|
||||
Language,
|
||||
Description,
|
||||
LightMode,
|
||||
DarkMode
|
||||
} from "@mui/icons-material";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AuthContext } from '../../context/auth';
|
||||
import { SaveRecording } from '../recorder/SaveRecording';
|
||||
@@ -195,12 +221,12 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
{!isRecording ? (
|
||||
<>
|
||||
<Button variant="outlined" onClick={handleUpdateOpen} sx={{
|
||||
marginRight: '40px',
|
||||
marginRight: '25px',
|
||||
color: "#00000099",
|
||||
border: "#00000099 1px solid",
|
||||
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
|
||||
}}>
|
||||
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')} Maxun
|
||||
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')}
|
||||
</Button>
|
||||
<Modal open={open} onClose={handleUpdateClose}>
|
||||
<Box
|
||||
@@ -293,7 +319,14 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
</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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -310,22 +343,31 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
PaperProps={{ sx: { width: '180px' } }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
|
||||
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLangMenuOpen}>
|
||||
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
|
||||
</MenuItem>
|
||||
<hr />
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://docs.maxun.dev', '_blank');
|
||||
}}>
|
||||
<Description sx={{ marginRight: '5px' }} /> Docs
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://github.com/getmaxun/maxun', '_blank');
|
||||
}}>
|
||||
<GitHub sx={{ marginRight: '5px' }} /> GitHub
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
window.open('https://discord.gg/5GbPjBUkws', '_blank');
|
||||
}}>
|
||||
@@ -341,20 +383,17 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
}}>
|
||||
<X sx={{ marginRight: '5px' }} /> Twitter (X)
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLangMenuOpen}>
|
||||
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
|
||||
</MenuItem>
|
||||
<Menu
|
||||
anchorEl={langAnchorEl}
|
||||
open={Boolean(langAnchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
@@ -446,11 +485,11 @@ export const NavBar: React.FC<NavBarProps> = ({
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
|
||||
@@ -8,7 +8,7 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { useEffect } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo } from "react";
|
||||
import { WorkflowFile } from "maxun-core";
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import {
|
||||
@@ -76,6 +76,64 @@ interface RecordingsTableProps {
|
||||
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
|
||||
}
|
||||
|
||||
// Virtualized row component for efficient rendering
|
||||
const TableRowMemoized = memo(({ row, columns, handlers }: any) => {
|
||||
return (
|
||||
<TableRow hover role="checkbox" tabIndex={-1}>
|
||||
{columns.map((column: Column) => {
|
||||
const value: any = row[column.id];
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
{value}
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
} else {
|
||||
switch (column.id) {
|
||||
case 'interpret':
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedInterpretButton handleInterpret={() => handlers.handleRunRecording(row.id, row.name, row.params || [])} />
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
case 'schedule':
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedScheduleButton handleSchedule={() => handlers.handleScheduleRecording(row.id, row.name, row.params || [])} />
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
case 'integrate':
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedIntegrateButton handleIntegrate={() => handlers.handleIntegrateRecording(row.id, row.name, row.params || [])} />
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
case 'options':
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedOptionsButton
|
||||
handleEdit={() => handlers.handleEditRobot(row.id, row.name, row.params || [])}
|
||||
handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])}
|
||||
handleDelete={() => handlers.handleDelete(row.id)}
|
||||
/>
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<MemoizedTableCell key={column.id} align={column.align}>
|
||||
<MemoizedSettingsButton handleSettings={() => handlers.handleSettingsRecording(row.id, row.name, row.params || [])} />
|
||||
</MemoizedTableCell>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
export const RecordingsTable = ({
|
||||
handleEditRecording,
|
||||
handleRunRecording,
|
||||
@@ -91,30 +149,14 @@ export const RecordingsTable = ({
|
||||
const [isModalOpen, setModalOpen] = React.useState(false);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
|
||||
const columns: readonly Column[] = [
|
||||
const columns = useMemo(() => [
|
||||
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
||||
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
|
||||
{
|
||||
id: 'schedule',
|
||||
label: t('recordingtable.schedule'),
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'integrate',
|
||||
label: t('recordingtable.integrate'),
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: t('recordingtable.settings'),
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'options',
|
||||
label: t('recordingtable.options'),
|
||||
minWidth: 80,
|
||||
},
|
||||
];
|
||||
{ id: 'schedule', label: t('recordingtable.schedule'), minWidth: 80 },
|
||||
{ id: 'integrate', label: t('recordingtable.integrate'), minWidth: 80 },
|
||||
{ id: 'settings', label: t('recordingtable.settings'), minWidth: 80 },
|
||||
{ id: 'options', label: t('recordingtable.options'), minWidth: 80 },
|
||||
], [t]);
|
||||
|
||||
const {
|
||||
notify,
|
||||
@@ -126,60 +168,84 @@ export const RecordingsTable = ({
|
||||
setRecordingUrl,
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
rerenderRobots,
|
||||
setRerenderRobots,
|
||||
recordingName,
|
||||
setRecordingName,
|
||||
recordingId,
|
||||
setRecordingId } = useGlobalInfoStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChangePage = (event: unknown, newPage: number) => {
|
||||
const handleChangePage = useCallback((event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(event.target.value);
|
||||
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 = async () => {
|
||||
const recordings = await getStoredRecordings();
|
||||
if (recordings) {
|
||||
const parsedRows: Data[] = [];
|
||||
recordings.map((recording: any, index: number) => {
|
||||
if (recording && recording.recording_meta) {
|
||||
parsedRows.push({
|
||||
id: index,
|
||||
...recording.recording_meta,
|
||||
content: recording.recording
|
||||
});
|
||||
}
|
||||
});
|
||||
setRecordings(parsedRows.map((recording) => recording.name));
|
||||
setRows(parsedRows);
|
||||
} else {
|
||||
console.log('No recordings found.');
|
||||
const fetchRecordings = useCallback(async () => {
|
||||
try {
|
||||
const recordings = await getStoredRecordings();
|
||||
if (recordings) {
|
||||
const parsedRows = recordings
|
||||
.map((recording: any, index: number) => {
|
||||
if (recording?.recording_meta) {
|
||||
const parsedDate = parseDateString(recording.recording_meta.createdAt);
|
||||
|
||||
return {
|
||||
id: index,
|
||||
...recording.recording_meta,
|
||||
content: recording.recording,
|
||||
parsedDate
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.parsedDate.getTime() - a.parsedDate.getTime());
|
||||
|
||||
setRecordings(parsedRows.map((recording) => recording.name));
|
||||
setRows(parsedRows);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recordings:', error);
|
||||
notify('error', t('recordingtable.notifications.fetch_error'));
|
||||
}
|
||||
}
|
||||
}, [setRecordings, notify, t]);
|
||||
|
||||
const handleNewRecording = async () => {
|
||||
const handleNewRecording = useCallback(async () => {
|
||||
if (browserId) {
|
||||
setBrowserId(null);
|
||||
await stopRecording(browserId);
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
const handleStartRecording = () => {
|
||||
const handleStartRecording = useCallback(() => {
|
||||
setBrowserId('new-recording');
|
||||
setRecordingName('');
|
||||
setRecordingId('');
|
||||
navigate('/recording');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const startRecording = () => {
|
||||
setModalOpen(false);
|
||||
@@ -195,14 +261,69 @@ export const RecordingsTable = ({
|
||||
if (rows.length === 0) {
|
||||
fetchRecordings();
|
||||
}
|
||||
}, []);
|
||||
}, [fetchRecordings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rerenderRobots) {
|
||||
fetchRecordings().then(() => {
|
||||
setRerenderRobots(false);
|
||||
});
|
||||
}
|
||||
}, [rerenderRobots, fetchRecordings, setRerenderRobots]);
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Filter rows based on search term
|
||||
const filteredRows = rows.filter((row) =>
|
||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const filteredRows = useMemo(() => {
|
||||
const searchLower = debouncedSearchTerm.toLowerCase();
|
||||
return debouncedSearchTerm
|
||||
? rows.filter(row => row.name.toLowerCase().includes(searchLower))
|
||||
: rows;
|
||||
}, [rows, debouncedSearchTerm]);
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
const start = page * rowsPerPage;
|
||||
return filteredRows.slice(start, start + rowsPerPage);
|
||||
}, [filteredRows, page, rowsPerPage]);
|
||||
|
||||
const handlers = useMemo(() => ({
|
||||
handleRunRecording,
|
||||
handleScheduleRecording,
|
||||
handleIntegrateRecording,
|
||||
handleSettingsRecording,
|
||||
handleEditRobot,
|
||||
handleDuplicateRobot,
|
||||
handleDelete: async (id: string) => {
|
||||
const hasRuns = await checkRunsForRecording(id);
|
||||
if (hasRuns) {
|
||||
notify('warning', t('recordingtable.notifications.delete_warning'));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await deleteRecordingFromStorage(id);
|
||||
if (success) {
|
||||
setRows([]);
|
||||
notify('success', t('recordingtable.notifications.delete_success'));
|
||||
fetchRecordings();
|
||||
}
|
||||
}
|
||||
}), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, notify, t]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
@@ -244,109 +365,35 @@ export const RecordingsTable = ({
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
{rows.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="50%">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
||||
<Table stickyHeader aria-label="sticky table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
style={{ minWidth: column.minWidth }}
|
||||
>
|
||||
{column.label}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredRows.length !== 0 ? filteredRows
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => {
|
||||
return (
|
||||
<TableRow hover role="checkbox" tabIndex={-1} key={row.id}>
|
||||
{columns.map((column) => {
|
||||
// @ts-ignore
|
||||
const value: any = row[column.id];
|
||||
if (value !== undefined) {
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
{value}
|
||||
</TableCell>
|
||||
);
|
||||
} else {
|
||||
switch (column.id) {
|
||||
case 'interpret':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<InterpretButton handleInterpret={() => handleRunRecording(row.id, row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
case 'schedule':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<ScheduleButton handleSchedule={() => handleScheduleRecording(row.id, row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
case 'integrate':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.id, row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
case 'options':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<OptionsButton
|
||||
handleEdit={() => handleEditRobot(row.id, row.name, row.params || [])}
|
||||
handleDuplicate={() => {
|
||||
handleDuplicateRobot(row.id, row.name, row.params || []);
|
||||
}}
|
||||
handleDelete={() => {
|
||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
||||
<Table stickyHeader aria-label="sticky table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<MemoizedTableCell
|
||||
key={column.id}
|
||||
style={{ minWidth: column.minWidth }}
|
||||
>
|
||||
{column.label}
|
||||
</MemoizedTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<TableRowMemoized
|
||||
key={row.id}
|
||||
row={row}
|
||||
columns={columns}
|
||||
handlers={handlers}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
checkRunsForRecording(row.id).then((result: boolean) => {
|
||||
if (result) {
|
||||
notify('warning', t('recordingtable.notifications.delete_warning'));
|
||||
}
|
||||
})
|
||||
|
||||
deleteRecordingFromStorage(row.id).then((result: boolean) => {
|
||||
if (result) {
|
||||
setRows([]);
|
||||
notify('success', t('recordingtable.notifications.delete_success'));
|
||||
fetchRecordings();
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<SettingsButton handleSettings={() => handleSettingsRecording(row.id, row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
component="div"
|
||||
count={filteredRows.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
@@ -511,6 +558,15 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableCell = memo(TableCell);
|
||||
|
||||
// Memoized action buttons
|
||||
const MemoizedInterpretButton = memo(InterpretButton);
|
||||
const MemoizedScheduleButton = memo(ScheduleButton);
|
||||
const MemoizedIntegrateButton = memo(IntegrateButton);
|
||||
const MemoizedSettingsButton = memo(SettingsButton);
|
||||
const MemoizedOptionsButton = memo(OptionsButton);
|
||||
|
||||
const modalStyle = {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
|
||||
@@ -55,9 +55,9 @@ interface RobotSettingsProps {
|
||||
|
||||
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
||||
const { recordingId, notify } = useGlobalInfoStore();
|
||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||
const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -96,13 +96,11 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
|
||||
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
||||
|
||||
if (success) {
|
||||
setRerenderRobots(true);
|
||||
|
||||
notify('success', t('robot_duplication.notifications.duplicate_success'));
|
||||
handleStart(robot);
|
||||
handleClose();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
notify('error', t('robot_duplication.notifications.duplicate_error'));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { modalStyle } from "../recorder/AddWhereCondModal";
|
||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||
import { getStoredRecording, updateRecording } from '../../api/storage';
|
||||
import { WhereWhatPair } from 'maxun-core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RobotMeta {
|
||||
name: string;
|
||||
@@ -75,9 +76,9 @@ interface GroupedCredentials {
|
||||
|
||||
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||
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>({
|
||||
passwords: [],
|
||||
emails: [],
|
||||
@@ -366,13 +367,11 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
||||
const success = await updateRecording(robot.recording_meta.id, payload);
|
||||
|
||||
if (success) {
|
||||
setRerenderRobots(true);
|
||||
|
||||
notify('success', t('robot_edit.notifications.update_success'));
|
||||
handleStart(robot);
|
||||
handleClose();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
notify('error', t('robot_edit.notifications.update_failed'));
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ interface RobotSettingsProps {
|
||||
|
||||
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||
const { recordingId, notify } = useGlobalInfoStore();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -35,8 +35,9 @@ interface CollapsibleRowProps {
|
||||
currentLog: string;
|
||||
abortRunHandler: () => void;
|
||||
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 navigate = useNavigate();
|
||||
const [open, setOpen] = useState(isOpen);
|
||||
@@ -62,14 +63,18 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
|
||||
abortRunHandler();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(urlRunId === row.runId || isOpen);
|
||||
}, [urlRunId, row.runId, isOpen]);
|
||||
|
||||
const handleRowExpand = () => {
|
||||
const newOpen = !open;
|
||||
setOpen(newOpen);
|
||||
if (newOpen) {
|
||||
navigate(`/runs/${row.robotMetaId}/run/${row.runId}`);
|
||||
} else {
|
||||
navigate(`/runs/${row.robotMetaId}`);
|
||||
}
|
||||
navigate(
|
||||
newOpen
|
||||
? `/runs/${row.robotMetaId}/run/${row.runId}`
|
||||
: `/runs/${row.robotMetaId}`
|
||||
);
|
||||
//scrollToLogBottom();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Tabs, Typography, Tab, Paper, Button } from "@mui/material";
|
||||
import { Box, Tabs, Typography, Tab, Paper, Button, CircularProgress } from "@mui/material";
|
||||
import Highlight from "react-highlight";
|
||||
import * as React from "react";
|
||||
import { Data } from "./RunsTable";
|
||||
@@ -148,9 +148,15 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
||||
</Button> : null}
|
||||
</TabPanel>
|
||||
<TabPanel value='output' sx={{ width: '700px' }}>
|
||||
{!row || !row.serializableOutput || !row.binaryOutput
|
||||
{interpretationInProgress ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CircularProgress size={22} sx={{ marginRight: '10px' }} />
|
||||
{t('run_content.loading')}
|
||||
</Box>
|
||||
) : (!row || !row.serializableOutput || !row.binaryOutput
|
||||
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
|
||||
? <Typography>{t('run_content.empty_output')}</Typography> : null}
|
||||
? <Typography>{t('run_content.empty_output')}</Typography>
|
||||
: null)}
|
||||
|
||||
{row.serializableOutput &&
|
||||
Object.keys(row.serializableOutput).length !== 0 &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { GenericModal } from "../ui/GenericModal";
|
||||
import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material";
|
||||
import { Dropdown } from "../ui/DropdownMui";
|
||||
@@ -28,6 +28,23 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
||||
});
|
||||
|
||||
const [showInterpreterSettings, setShowInterpreterSettings] = useState(false);
|
||||
const hasRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
hasRun.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showInterpreterSettings && !hasRun.current) {
|
||||
hasRun.current = true;
|
||||
handleStart(settings);
|
||||
}
|
||||
}, [isOpen, showInterpreterSettings, settings, handleStart]);
|
||||
|
||||
if (!showInterpreterSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
@@ -35,18 +52,22 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
||||
onClose={handleClose}
|
||||
modalStyle={modalStyle}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: '65px',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
marginLeft: "65px",
|
||||
}}
|
||||
>
|
||||
{isTask && (
|
||||
<React.Fragment>
|
||||
<Typography sx={{ margin: '20px 0px' }}>Recording parameters:</Typography>
|
||||
<Typography sx={{ margin: "20px 0px" }}>
|
||||
Recording parameters:
|
||||
</Typography>
|
||||
{params?.map((item, index) => (
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
sx={{ marginBottom: "15px" }}
|
||||
key={`param-${index}`}
|
||||
type="string"
|
||||
label={item}
|
||||
@@ -65,15 +86,22 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={<Switch checked={showInterpreterSettings} onChange={() => setShowInterpreterSettings(!showInterpreterSettings)} />}
|
||||
control={
|
||||
<Switch
|
||||
checked={showInterpreterSettings}
|
||||
onChange={() =>
|
||||
setShowInterpreterSettings(!showInterpreterSettings)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Developer Mode Settings"
|
||||
sx={{ margin: '20px 0px' }}
|
||||
sx={{ margin: "20px 0px" }}
|
||||
/>
|
||||
|
||||
{showInterpreterSettings && (
|
||||
<React.Fragment>
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
sx={{ marginBottom: "15px" }}
|
||||
type="number"
|
||||
label="Max Concurrency"
|
||||
required
|
||||
@@ -86,7 +114,7 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
||||
defaultValue={settings.maxConcurrency}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
sx={{ marginBottom: "15px" }}
|
||||
type="number"
|
||||
label="Max Repeats"
|
||||
required
|
||||
@@ -115,7 +143,13 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
<Button variant="contained" onClick={() => handleStart(settings)} sx={{ marginTop: '20px' }}>Run Robot</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => handleStart(settings)}
|
||||
sx={{ marginTop: "20px" }}
|
||||
>
|
||||
Run Robot
|
||||
</Button>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Table from '@mui/material/Table';
|
||||
@@ -9,14 +9,15 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TablePagination from '@mui/material/TablePagination';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress } from '@mui/material';
|
||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { getStoredRuns } from "../../api/storage";
|
||||
import { RunSettings } from "./RunSettings";
|
||||
import { CollapsibleRow } from "./ColapsibleRow";
|
||||
import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material';
|
||||
|
||||
export const columns: readonly Column[] = [
|
||||
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
||||
@@ -27,6 +28,15 @@ export const columns: readonly Column[] = [
|
||||
{ id: 'delete', label: 'Delete', minWidth: 80 },
|
||||
];
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | 'none';
|
||||
|
||||
interface AccordionSortConfig {
|
||||
[robotMetaId: string]: {
|
||||
field: keyof Data | null;
|
||||
direction: SortDirection;
|
||||
};
|
||||
}
|
||||
|
||||
interface Column {
|
||||
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
|
||||
label: string;
|
||||
@@ -60,6 +70,13 @@ interface RunsTableProps {
|
||||
runningRecordingName: string;
|
||||
}
|
||||
|
||||
interface PaginationState {
|
||||
[robotMetaId: string]: {
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
currentInterpretationLog,
|
||||
abortRunHandler,
|
||||
@@ -68,91 +85,290 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const translatedColumns = columns.map(column => ({
|
||||
...column,
|
||||
label: t(`runstable.${column.id}`, column.label)
|
||||
}));
|
||||
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 handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => {
|
||||
setAccordionSortConfigs(prevConfigs => {
|
||||
const currentConfig = prevConfigs[robotMetaId] || { field: null, direction: 'none' };
|
||||
const newDirection: SortDirection =
|
||||
currentConfig.field !== columnId ? 'asc' :
|
||||
currentConfig.direction === 'none' ? 'asc' :
|
||||
currentConfig.direction === 'asc' ? 'desc' : 'none';
|
||||
|
||||
return {
|
||||
...prevConfigs,
|
||||
[robotMetaId]: {
|
||||
field: newDirection === 'none' ? null : columnId,
|
||||
direction: newDirection,
|
||||
}
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const translatedColumns = useMemo(() =>
|
||||
columns.map(column => ({
|
||||
...column,
|
||||
label: t(`runstable.${column.id}`, column.label)
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [rows, setRows] = useState<Data[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [paginationStates, setPaginationStates] = useState<PaginationState>({});
|
||||
|
||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||
|
||||
const handleAccordionChange = (robotMetaId: string, isExpanded: boolean) => {
|
||||
if (isExpanded) {
|
||||
navigate(`/runs/${robotMetaId}`);
|
||||
} else {
|
||||
navigate(`/runs`);
|
||||
const handleAccordionChange = useCallback((robotMetaId: string, isExpanded: boolean) => {
|
||||
navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs');
|
||||
}, [navigate]);
|
||||
|
||||
const handleAccordionPageChange = useCallback((event: unknown, newPage: number) => {
|
||||
setAccordionPage(newPage);
|
||||
}, []);
|
||||
|
||||
const handleAccordionsPerPageChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAccordionsPerPage(+event.target.value);
|
||||
setAccordionPage(0);
|
||||
}, []);
|
||||
|
||||
const handleChangePage = useCallback((robotMetaId: string, newPage: number) => {
|
||||
setPaginationStates(prev => ({
|
||||
...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 handleChangePage = (event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
const debouncedSearch = useCallback((fn: Function, delay: number) => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const debouncedSetSearch = debouncedSearch((value: string) => {
|
||||
setSearchTerm(value);
|
||||
setAccordionPage(0);
|
||||
setPaginationStates(prev => {
|
||||
const reset = Object.keys(prev).reduce((acc, robotId) => ({
|
||||
...acc,
|
||||
[robotId]: { ...prev[robotId], page: 0 }
|
||||
}), {});
|
||||
return reset;
|
||||
});
|
||||
}, 300);
|
||||
debouncedSetSearch(event.target.value);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const fetchRuns = async () => {
|
||||
const runs = await getStoredRuns();
|
||||
if (runs) {
|
||||
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
||||
id: index,
|
||||
...run,
|
||||
}));
|
||||
setRows(parsedRows);
|
||||
} else {
|
||||
notify('error', t('runstable.notifications.no_runs'));
|
||||
const fetchRuns = useCallback(async () => {
|
||||
try {
|
||||
const runs = await getStoredRuns();
|
||||
if (runs) {
|
||||
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
||||
id: index,
|
||||
...run,
|
||||
}));
|
||||
setRows(parsedRows);
|
||||
} else {
|
||||
notify('error', t('runstable.notifications.no_runs'));
|
||||
}
|
||||
} catch (error) {
|
||||
notify('error', t('runstable.notifications.fetch_error'));
|
||||
}
|
||||
};
|
||||
}, [notify, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0 || rerenderRuns) {
|
||||
fetchRuns();
|
||||
setRerenderRuns(false);
|
||||
}
|
||||
}, [rerenderRuns, rows.length, setRerenderRuns]);
|
||||
let mounted = true;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (rows.length === 0 || rerenderRuns) {
|
||||
fetchRuns().then(() => {
|
||||
if (mounted) {
|
||||
setRerenderRuns(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [rerenderRuns, rows.length, setRerenderRuns, fetchRuns]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setRows([]);
|
||||
notify('success', t('runstable.notifications.delete_success'));
|
||||
fetchRuns();
|
||||
};
|
||||
}, [notify, t, fetchRuns]);
|
||||
|
||||
// Filter rows based on search term
|
||||
const filteredRows = rows.filter((row) =>
|
||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
const filteredRows = useMemo(() => {
|
||||
let result = rows.filter((row) =>
|
||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return result;
|
||||
}, [rows, searchTerm]);
|
||||
|
||||
// Group filtered rows by robot meta id
|
||||
const groupedRows = filteredRows.reduce((acc, row) => {
|
||||
if (!acc[row.robotMetaId]) {
|
||||
acc[row.robotMetaId] = [];
|
||||
const parseDateString = (dateStr: string): Date => {
|
||||
try {
|
||||
if (dateStr.includes('PM') || dateStr.includes('AM')) {
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
return new Date(dateStr.replace(/(\d+)\/(\d+)\//, '$2/$1/'))
|
||||
} catch {
|
||||
return new Date(0);
|
||||
}
|
||||
acc[row.robotMetaId].push(row);
|
||||
return acc;
|
||||
}, {} as Record<string, Data[]>);
|
||||
};
|
||||
|
||||
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 { page, rowsPerPage } = getPaginationState(robotMetaId);
|
||||
const start = page * rowsPerPage;
|
||||
const end = start + rowsPerPage;
|
||||
|
||||
let sortedData = [...data];
|
||||
const sortConfig = accordionSortConfigs[robotMetaId];
|
||||
|
||||
if (sortConfig?.field === 'startedAt' || sortConfig?.field === 'finishedAt') {
|
||||
if (sortConfig.direction !== 'none') {
|
||||
sortedData.sort((a, b) => {
|
||||
const dateA = parseDateString(a[sortConfig.field!]);
|
||||
const dateB = parseDateString(b[sortConfig.field!]);
|
||||
|
||||
return sortConfig.direction === 'asc'
|
||||
? dateA.getTime() - dateB.getTime()
|
||||
: dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sortedData
|
||||
.slice(start, end)
|
||||
.map((row) => (
|
||||
<CollapsibleRow
|
||||
key={`row-${row.id}`}
|
||||
row={row}
|
||||
handleDelete={handleDelete}
|
||||
isOpen={urlRunId === row.runId || (runId === row.runId && runningRecordingName === row.name)}
|
||||
currentLog={currentInterpretationLog}
|
||||
abortRunHandler={abortRunHandler}
|
||||
runningRecordingName={runningRecordingName}
|
||||
urlRunId={urlRunId}
|
||||
/>
|
||||
));
|
||||
}, [paginationStates, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]);
|
||||
|
||||
const renderSortIcon = useCallback((column: Column, robotMetaId: string) => {
|
||||
const sortConfig = accordionSortConfigs[robotMetaId];
|
||||
if (column.id !== 'startedAt' && column.id !== 'finishedAt') return null;
|
||||
|
||||
if (sortConfig?.field !== column.id) {
|
||||
return (
|
||||
<UnfoldMore
|
||||
fontSize="small"
|
||||
sx={{
|
||||
opacity: 0.3,
|
||||
transition: 'opacity 0.2s',
|
||||
'.MuiTableCell-root:hover &': {
|
||||
opacity: 1
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return sortConfig.direction === 'asc'
|
||||
? <ArrowUpward fontSize="small" />
|
||||
: sortConfig.direction === 'desc'
|
||||
? <ArrowDownward fontSize="small" />
|
||||
: <UnfoldMore fontSize="small" />;
|
||||
}, [accordionSortConfigs]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<Typography variant="h6" component="h2">
|
||||
{t('runstable.runs', 'Runs')}
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={t('runstable.search', 'Search runs...')}
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
||||
@@ -160,14 +376,19 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
sx={{ width: '250px' }}
|
||||
/>
|
||||
</Box>
|
||||
{rows.length === 0 ? (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="50%">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
{Object.entries(groupedRows).map(([id, data]) => (
|
||||
<Accordion key={id} onChange={(event, isExpanded) => handleAccordionChange(id, isExpanded)}>
|
||||
|
||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
{Object.entries(groupedRows)
|
||||
.slice(
|
||||
accordionPage * accordionsPerPage,
|
||||
accordionPage * accordionsPerPage + accordionsPerPage
|
||||
)
|
||||
.map(([robotMetaId, data]) => (
|
||||
<Accordion
|
||||
key={robotMetaId}
|
||||
onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)}
|
||||
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||
</AccordionSummary>
|
||||
@@ -180,42 +401,77 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
||||
<TableCell
|
||||
key={column.id}
|
||||
align={column.align}
|
||||
style={{ minWidth: column.minWidth }}
|
||||
style={{
|
||||
minWidth: column.minWidth,
|
||||
cursor: column.id === 'startedAt' || column.id === 'finishedAt' ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={() => {
|
||||
if (column.id === 'startedAt' || column.id === 'finishedAt') {
|
||||
handleSort(column.id, robotMetaId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
<Tooltip
|
||||
title={
|
||||
(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={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: accordionSortConfigs[robotMetaId]?.field === column.id ? 1 : 0.3,
|
||||
transition: 'opacity 0.2s'
|
||||
}}>
|
||||
{renderSortIcon(column, robotMetaId)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => (
|
||||
<CollapsibleRow
|
||||
row={row}
|
||||
handleDelete={handleDelete}
|
||||
key={`row-${row.id}`}
|
||||
isOpen={runId === row.runId && runningRecordingName === row.name}
|
||||
currentLog={currentInterpretationLog}
|
||||
abortRunHandler={abortRunHandler}
|
||||
runningRecordingName={runningRecordingName}
|
||||
/>
|
||||
))}
|
||||
{renderTableRows(data, robotMetaId)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
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
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
component="div"
|
||||
count={filteredRows.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
count={Object.keys(groupedRows).length}
|
||||
page={accordionPage}
|
||||
rowsPerPage={accordionsPerPage}
|
||||
onPageChange={handleAccordionPageChange}
|
||||
onRowsPerPageChange={handleAccordionsPerPageChange}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
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 {
|
||||
browserId: string | null;
|
||||
@@ -16,6 +54,8 @@ interface GlobalInfo {
|
||||
setRecordings: (recordings: string[]) => void;
|
||||
rerenderRuns: boolean;
|
||||
setRerenderRuns: (rerenderRuns: boolean) => void;
|
||||
rerenderRobots: boolean;
|
||||
setRerenderRobots: (rerenderRuns: boolean) => void;
|
||||
recordingLength: number;
|
||||
setRecordingLength: (recordingLength: number) => void;
|
||||
recordingId: string | null;
|
||||
@@ -52,6 +92,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
|
||||
recordingId = null;
|
||||
recordings: string[] = [];
|
||||
rerenderRuns = false;
|
||||
rerenderRobots = false;
|
||||
recordingName = '';
|
||||
initialUrl = 'https://';
|
||||
recordingUrl = 'https://';
|
||||
@@ -75,6 +116,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
||||
const [notification, setNotification] = useState<AlertSnackbarProps>(globalInfoStore.notification);
|
||||
const [recordings, setRecordings] = useState<string[]>(globalInfoStore.recordings);
|
||||
const [rerenderRuns, setRerenderRuns] = useState<boolean>(globalInfoStore.rerenderRuns);
|
||||
const [rerenderRobots, setRerenderRobots] = useState<boolean>(globalInfoStore.rerenderRobots);
|
||||
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
||||
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
||||
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
||||
@@ -121,6 +163,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
||||
setRecordings,
|
||||
rerenderRuns,
|
||||
setRerenderRuns,
|
||||
rerenderRobots,
|
||||
setRerenderRobots,
|
||||
recordingLength,
|
||||
setRecordingLength,
|
||||
recordingId,
|
||||
|
||||
@@ -86,6 +86,12 @@ const darkTheme = createTheme({
|
||||
main: "#ff00c3",
|
||||
contrastText: "#ffffff",
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
light: '#e57373',
|
||||
dark: '#d32f2f',
|
||||
contrastText: '#ffffff',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
@@ -124,6 +130,14 @@ const darkTheme = createTheme({
|
||||
backgroundColor: 'rgba(255, 0, 195, 0.08)',
|
||||
borderColor: '#ff66d9',
|
||||
},
|
||||
'&.MuiButton-outlinedError': {
|
||||
borderColor: '#f44336',
|
||||
color: '#f44336',
|
||||
"&:hover": {
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.08)',
|
||||
borderColor: '#d32f2f',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -144,6 +158,12 @@ const darkTheme = createTheme({
|
||||
"&:hover": {
|
||||
backgroundColor: 'rgba(255, 0, 195, 0.08)',
|
||||
},
|
||||
'&.MuiIconButton-colorError': {
|
||||
color: '#f44336',
|
||||
"&:hover": {
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -218,7 +238,7 @@ const darkTheme = createTheme({
|
||||
});
|
||||
|
||||
const ThemeModeContext = createContext({
|
||||
toggleTheme: () => {},
|
||||
toggleTheme: () => { },
|
||||
darkMode: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -53,8 +53,14 @@ const Login = () => {
|
||||
notify("success", t('login.welcome_notification'));
|
||||
window.localStorage.setItem("user", JSON.stringify(data));
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
notify("error", t('login.error_notification'));
|
||||
} catch (err: any) {
|
||||
const errorResponse = err.response?.data;
|
||||
|
||||
const errorMessage = errorResponse?.code
|
||||
? t(errorResponse.code)
|
||||
: t('login.error.generic');
|
||||
|
||||
notify("error", errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
||||
import { IntegrationSettings } from "../components/integration/IntegrationSettings";
|
||||
import { RobotSettings } from "../components/robot/RobotSettings";
|
||||
import { apiUrl } from "../apiConfig";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface MainPageProps {
|
||||
handleEditRecording: (id: string, fileName: string) => void;
|
||||
@@ -24,6 +25,7 @@ interface MainPageProps {
|
||||
export interface CreateRunResponse {
|
||||
browserId: string;
|
||||
runId: string;
|
||||
robotMetaId: string;
|
||||
}
|
||||
|
||||
export interface ScheduleRunResponse {
|
||||
@@ -40,12 +42,14 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState('');
|
||||
const [ids, setIds] = React.useState<CreateRunResponse>({
|
||||
browserId: '',
|
||||
runId: ''
|
||||
runId: '',
|
||||
robotMetaId: ''
|
||||
});
|
||||
|
||||
let aborted = false;
|
||||
|
||||
const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const abortRunHandler = (runId: string) => {
|
||||
aborted = true;
|
||||
@@ -88,8 +92,9 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
||||
}, [currentInterpretationLog])
|
||||
|
||||
const handleRunRecording = useCallback((settings: RunSettings) => {
|
||||
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId }: CreateRunResponse) => {
|
||||
setIds({ browserId, runId });
|
||||
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => {
|
||||
setIds({ browserId, runId, robotMetaId });
|
||||
navigate(`/runs/${robotMetaId}/run/${runId}`);
|
||||
const socket =
|
||||
io(`${apiUrl}/${browserId}`, {
|
||||
transports: ["websocket"],
|
||||
|
||||
@@ -48,7 +48,13 @@ const Register = () => {
|
||||
window.localStorage.setItem("user", JSON.stringify(data));
|
||||
navigate("/");
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user