Merge branch 'develop' into integration_airtable
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<div>
|
<div>
|
||||||
<a href="https://maxun-website.vercel.app/?ref=ghread">
|
<a href="https://www.maxun.dev/?ref=ghread">
|
||||||
<img src="/src/assets/maxunlogo.png" width="50" />
|
<img src="/src/assets/maxunlogo.png" width="50" />
|
||||||
<br>
|
<br>
|
||||||
Maxun
|
Maxun
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun-core",
|
"name": "maxun-core",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"description": "Core package for Maxun, responsible for data extraction",
|
"description": "Core package for Maxun, responsible for data extraction",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"typings": "build/index.d.ts",
|
"typings": "build/index.d.ts",
|
||||||
|
|||||||
@@ -490,6 +490,14 @@ export default class Interpreter extends EventEmitter {
|
|||||||
|
|
||||||
const executeAction = async (invokee: any, methodName: string, args: any) => {
|
const executeAction = async (invokee: any, methodName: string, args: any) => {
|
||||||
console.log("Executing action:", methodName, args);
|
console.log("Executing action:", methodName, args);
|
||||||
|
|
||||||
|
if (methodName === 'press' || methodName === 'type') {
|
||||||
|
// Extract only the first two arguments for these methods
|
||||||
|
const limitedArgs = Array.isArray(args) ? args.slice(0, 2) : [args];
|
||||||
|
await (<any>invokee[methodName])(...limitedArgs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!args || Array.isArray(args)) {
|
if (!args || Array.isArray(args)) {
|
||||||
await (<any>invokee[methodName])(...(args ?? []));
|
await (<any>invokee[methodName])(...(args ?? []));
|
||||||
} else {
|
} else {
|
||||||
@@ -547,221 +555,225 @@ export default class Interpreter extends EventEmitter {
|
|||||||
let scrapedItems: Set<string> = new Set<string>();
|
let scrapedItems: Set<string> = new Set<string>();
|
||||||
let visitedUrls: string[] = [];
|
let visitedUrls: string[] = [];
|
||||||
|
|
||||||
|
// Debug logging helper
|
||||||
|
const debugLog = (message: string, ...args: any[]) => {
|
||||||
|
console.log(`[Page ${visitedUrls.length + 1}] ${message}`, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
let availableSelectors = config.pagination.selector.split(',');
|
let availableSelectors = config.pagination.selector.split(',');
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
switch (config.pagination.type) {
|
switch (config.pagination.type) {
|
||||||
case 'scrollDown':
|
case 'scrollDown':
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||||
if (currentHeight === previousHeight) {
|
if (currentHeight === previousHeight) {
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
allResults = allResults.concat(finalResults);
|
allResults = allResults.concat(finalResults);
|
||||||
return allResults;
|
return allResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
previousHeight = currentHeight;
|
previousHeight = currentHeight;
|
||||||
break;
|
|
||||||
case 'scrollUp':
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
|
|
||||||
if (currentTopHeight === 0) {
|
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
|
||||||
allResults = allResults.concat(finalResults);
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
previousHeight = currentTopHeight;
|
|
||||||
break;
|
|
||||||
case 'clickNext':
|
|
||||||
console.log("Page URL:", page.url());
|
|
||||||
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
|
||||||
|
|
||||||
// console.log("Page results:", pageResults);
|
|
||||||
|
|
||||||
// Filter out already scraped items
|
|
||||||
const newResults = pageResults.filter(item => {
|
|
||||||
const uniqueKey = JSON.stringify(item);
|
|
||||||
if (scrapedItems.has(uniqueKey)) return false; // Ignore if already scraped
|
|
||||||
scrapedItems.add(uniqueKey); // Mark as scraped
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
allResults = allResults.concat(newResults);
|
|
||||||
console.log("Results so far:", allResults.length);
|
|
||||||
|
|
||||||
if (config.limit && allResults.length >= config.limit) {
|
|
||||||
return allResults.slice(0, config.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
let checkButton = null;
|
|
||||||
let workingSelector = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < availableSelectors.length; i++) {
|
|
||||||
const selector = availableSelectors[i];
|
|
||||||
try {
|
|
||||||
// Wait for selector with a short timeout
|
|
||||||
checkButton = await page.waitForSelector(selector, { state: 'attached' });
|
|
||||||
if (checkButton) {
|
|
||||||
workingSelector = selector;
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'scrollUp':
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const currentTopHeight = await page.evaluate(() => document.documentElement.scrollTop);
|
||||||
|
if (currentTopHeight === 0) {
|
||||||
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
allResults = allResults.concat(finalResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousHeight = currentTopHeight;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clickNext':
|
||||||
|
debugLog("Current URL:", page.url());
|
||||||
|
const pageResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
|
||||||
|
// Filter out already scraped items
|
||||||
|
const newResults = pageResults.filter(item => {
|
||||||
|
const uniqueKey = JSON.stringify(item);
|
||||||
|
if (scrapedItems.has(uniqueKey)) return false;
|
||||||
|
scrapedItems.add(uniqueKey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
allResults = allResults.concat(newResults);
|
||||||
|
debugLog("Results collected so far:", allResults.length);
|
||||||
|
|
||||||
|
if (config.limit && allResults.length >= config.limit) {
|
||||||
|
return allResults.slice(0, config.limit);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.log(`Selector failed: ${selector}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workingSelector) {
|
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||||
return allResults;
|
await page.waitForTimeout(2000);
|
||||||
}
|
|
||||||
|
|
||||||
// const nextButton = await page.$(config.pagination.selector);
|
let checkButton = null;
|
||||||
const nextButton = await page.$(workingSelector);
|
let workingSelector = null;
|
||||||
if (!nextButton) {
|
|
||||||
return allResults; // No more pages to scrape
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectorIndex = availableSelectors.indexOf(workingSelector!);
|
// Try each selector with explicit waiting
|
||||||
availableSelectors = availableSelectors.slice(selectorIndex);
|
for (const selector of availableSelectors) {
|
||||||
|
try {
|
||||||
|
checkButton = await page.waitForSelector(selector, {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
if (checkButton) {
|
||||||
|
workingSelector = selector;
|
||||||
|
debugLog('Found working selector:', selector);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`Selector failed: ${selector} - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// await Promise.all([
|
if (!workingSelector) {
|
||||||
// nextButton.dispatchEvent('click'),
|
debugLog('No working selector found after trying all options');
|
||||||
// page.waitForNavigation({ waitUntil: 'networkidle' })
|
return allResults;
|
||||||
// ]);
|
}
|
||||||
|
|
||||||
const previousUrl = page.url();
|
const nextButton = await page.$(workingSelector);
|
||||||
visitedUrls.push(previousUrl);
|
if (!nextButton) {
|
||||||
|
debugLog('Next button not found');
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const selectorIndex = availableSelectors.indexOf(workingSelector);
|
||||||
// Try both click methods simultaneously
|
availableSelectors = availableSelectors.slice(selectorIndex);
|
||||||
await Promise.race([
|
|
||||||
Promise.all([
|
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }),
|
|
||||||
nextButton.click()
|
|
||||||
]),
|
|
||||||
Promise.all([
|
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }),
|
|
||||||
nextButton.dispatchEvent('click')
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
// Verify if navigation actually succeeded
|
|
||||||
const currentUrl = page.url();
|
|
||||||
if (currentUrl === previousUrl) {
|
|
||||||
console.log("Previous URL same as current URL. Navigation failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUrl = page.url();
|
|
||||||
if (visitedUrls.includes(currentUrl)) {
|
|
||||||
console.log(`Detected navigation to a previously visited URL: ${currentUrl}`);
|
|
||||||
|
|
||||||
// Extract the current page number from the URL
|
|
||||||
const match = currentUrl.match(/\d+/);
|
|
||||||
if (match) {
|
|
||||||
const currentNumber = match[0];
|
|
||||||
// Use visitedUrls.length + 1 as the next page number
|
|
||||||
const nextNumber = visitedUrls.length + 1;
|
|
||||||
|
|
||||||
// Create new URL by replacing the current number with the next number
|
|
||||||
const nextUrl = currentUrl.replace(currentNumber, nextNumber.toString());
|
|
||||||
|
|
||||||
console.log(`Navigating to constructed URL: ${nextUrl}`);
|
|
||||||
|
|
||||||
// Navigate to the next page
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForNavigation({ waitUntil: 'networkidle' }),
|
|
||||||
page.goto(nextUrl)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the page a moment to stabilize after navigation
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
break;
|
|
||||||
case 'clickLoadMore':
|
|
||||||
while (true) {
|
|
||||||
let checkButton = null;
|
|
||||||
let workingSelector = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < availableSelectors.length; i++) {
|
|
||||||
const selector = availableSelectors[i];
|
|
||||||
try {
|
try {
|
||||||
// Wait for selector with a short timeout
|
// Store current URL to check if navigation succeeded
|
||||||
checkButton = await page.waitForSelector(selector, { state: 'attached' });
|
const previousUrl = page.url();
|
||||||
if (checkButton) {
|
visitedUrls.push(previousUrl);
|
||||||
workingSelector = selector;
|
|
||||||
|
// Try both click methods in sequence
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 15000
|
||||||
|
}),
|
||||||
|
nextButton.click()
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
// If we're still on the same URL, try dispatch event
|
||||||
|
if (page.url() === previousUrl) {
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 15000
|
||||||
|
}),
|
||||||
|
nextButton.dispatchEvent('click')
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
if (visitedUrls.includes(currentUrl)) {
|
||||||
|
debugLog(`Navigation failed/Detected navigation to previously visited URL: ${currentUrl}`);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the page a moment to stabilize after navigation
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`Navigation failed completely: ${error.message}`);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clickLoadMore':
|
||||||
|
while (true) {
|
||||||
|
let checkButton = null;
|
||||||
|
let workingSelector = null;
|
||||||
|
|
||||||
|
for (const selector of availableSelectors) {
|
||||||
|
try {
|
||||||
|
checkButton = await page.waitForSelector(selector, {
|
||||||
|
state: 'attached',
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
if (checkButton) {
|
||||||
|
workingSelector = selector;
|
||||||
|
debugLog('Found working selector:', selector);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`Load More selector failed: ${selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workingSelector) {
|
||||||
|
debugLog('No working Load More selector found');
|
||||||
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
allResults = allResults.concat(finalResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreButton = await page.$(workingSelector);
|
||||||
|
if (!loadMoreButton) {
|
||||||
|
debugLog('Load More button not found');
|
||||||
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
allResults = allResults.concat(finalResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectorIndex = availableSelectors.indexOf(workingSelector);
|
||||||
|
availableSelectors = availableSelectors.slice(selectorIndex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await loadMoreButton.click();
|
||||||
|
} catch (error) {
|
||||||
|
await loadMoreButton.dispatchEvent('click');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
allResults = allResults.concat(finalResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||||
|
if (currentHeight === previousHeight) {
|
||||||
|
debugLog('No more items loaded after Load More');
|
||||||
|
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
|
allResults = allResults.concat(finalResults);
|
||||||
|
return allResults;
|
||||||
|
}
|
||||||
|
previousHeight = currentHeight;
|
||||||
|
|
||||||
|
if (config.limit && allResults.length >= config.limit) {
|
||||||
|
allResults = allResults.slice(0, config.limit);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.log(`Selector failed: ${selector}`);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!workingSelector) {
|
|
||||||
// No more working selectors available, so scrape the remaining items
|
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
|
||||||
allResults = allResults.concat(finalResults);
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMoreButton = await page.$(workingSelector);
|
|
||||||
if (!loadMoreButton) {
|
|
||||||
// No more "Load More" button, so scrape the remaining items
|
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
|
||||||
allResults = allResults.concat(finalResults);
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectorIndex = availableSelectors.indexOf(workingSelector!);
|
|
||||||
availableSelectors = availableSelectors.slice(selectorIndex);
|
|
||||||
|
|
||||||
// Click the 'Load More' button to load additional items
|
|
||||||
// await loadMoreButton.dispatchEvent('click');
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
loadMoreButton.click(),
|
|
||||||
loadMoreButton.dispatchEvent('click')
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Both click attempts failed');
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(2000); // Wait for new items to load
|
|
||||||
// After clicking 'Load More', scroll down to load more items
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if more items are available
|
|
||||||
const currentHeight = await page.evaluate(() => document.body.scrollHeight);
|
|
||||||
if (currentHeight === previousHeight) {
|
|
||||||
// No more items loaded, return the scraped results
|
|
||||||
const finalResults = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
|
||||||
allResults = allResults.concat(finalResults);
|
|
||||||
return allResults;
|
|
||||||
}
|
|
||||||
previousHeight = currentHeight;
|
|
||||||
|
|
||||||
if (config.limit && allResults.length >= config.limit) {
|
|
||||||
// If limit is set and reached, return the limited results
|
|
||||||
allResults = allResults.slice(0, config.limit);
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
default:
|
||||||
allResults = allResults.slice(0, config.limit);
|
const results = await page.evaluate((cfg) => window.scrapeList(cfg), config);
|
||||||
break;
|
allResults = allResults.concat(results);
|
||||||
}
|
return allResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.limit && allResults.length >= config.limit) {
|
||||||
|
allResults = allResults.slice(0, config.limit);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allResults;
|
return allResults;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "maxun",
|
"name": "maxun",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"author": "Maxun",
|
"author": "Maxun",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"loglevel": "^1.8.0",
|
"loglevel": "^1.8.0",
|
||||||
"loglevel-plugin-remote": "^0.6.8",
|
"loglevel-plugin-remote": "^0.6.8",
|
||||||
"maxun-core": "^0.0.9",
|
"maxun-core": "^0.0.10",
|
||||||
"minio": "^8.0.1",
|
"minio": "^8.0.1",
|
||||||
"moment-timezone": "^0.5.45",
|
"moment-timezone": "^0.5.45",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"new": "Roboter erstellen",
|
"new": "Roboter erstellen",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "Geben Sie die URL ein",
|
"title": "Geben Sie die URL ein",
|
||||||
|
"login_title": "Erfordert diese Seite eine Anmeldung?",
|
||||||
"label": "URL",
|
"label": "URL",
|
||||||
"button": "Aufnahme starten"
|
"button": "Aufnahme starten"
|
||||||
},
|
},
|
||||||
@@ -39,8 +40,9 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"duplicate": "Duplizieren",
|
"duplicate": "Duplizieren",
|
||||||
"notifications": {
|
"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"
|
"delete_success": "Roboter erfolgreich gelöscht",
|
||||||
|
"auth_success": "Roboter erfolgreich authentifiziert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"search": "Ausführungen suchen...",
|
"search": "Ausführungen suchen...",
|
||||||
|
"sort_tooltip": "Zum Sortieren klicken",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.",
|
"no_runs": "Keine Ausführungen gefunden. Bitte versuchen Sie es erneut.",
|
||||||
"delete_success": "Ausführung erfolgreich gelöscht"
|
"delete_success": "Ausführung erfolgreich gelöscht"
|
||||||
@@ -204,7 +207,8 @@
|
|||||||
"confirm_text_fields": "Bitte bestätigen Sie alle Textfelder",
|
"confirm_text_fields": "Bitte bestätigen Sie alle Textfelder",
|
||||||
"unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.",
|
"unable_create_settings": "Listeneinstellungen können nicht erstellt werden. Stellen Sie sicher, dass Sie ein Feld für die Liste definiert haben.",
|
||||||
"capture_text_discarded": "Texterfassung verworfen",
|
"capture_text_discarded": "Texterfassung verworfen",
|
||||||
"capture_list_discarded": "Listenerfassung verworfen"
|
"capture_list_discarded": "Listenerfassung verworfen",
|
||||||
|
"label_required": "Beschriftung darf nicht leer sein"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_recording": {
|
"save_recording": {
|
||||||
@@ -448,6 +452,7 @@
|
|||||||
"log": "Protokoll"
|
"log": "Protokoll"
|
||||||
},
|
},
|
||||||
"empty_output": "Die Ausgabe ist leer.",
|
"empty_output": "Die Ausgabe ist leer.",
|
||||||
|
"loading": "Ausführung läuft. Extrahierte Daten werden nach Abschluss des Durchlaufs hier angezeigt.",
|
||||||
"captured_data": {
|
"captured_data": {
|
||||||
"title": "Erfasste Daten",
|
"title": "Erfasste Daten",
|
||||||
"download_json": "Als JSON herunterladen",
|
"download_json": "Als JSON herunterladen",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"new":"Create Robot",
|
"new":"Create Robot",
|
||||||
"modal":{
|
"modal":{
|
||||||
"title":"Enter the URL",
|
"title":"Enter the URL",
|
||||||
|
"login_title": "Does this website require login?",
|
||||||
"label":"URL",
|
"label":"URL",
|
||||||
"button":"Start Recording"
|
"button":"Start Recording"
|
||||||
},
|
},
|
||||||
@@ -40,8 +41,9 @@
|
|||||||
"duplicate":"Duplicate",
|
"duplicate":"Duplicate",
|
||||||
"search":"Search Robots...",
|
"search":"Search Robots...",
|
||||||
"notifications": {
|
"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"
|
"delete_success": "Robot deleted successfully",
|
||||||
|
"auth_success": "Robot successfully authenticated"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu":{
|
"mainmenu":{
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"delete":"Delete",
|
"delete":"Delete",
|
||||||
"settings":"Settings",
|
"settings":"Settings",
|
||||||
"search":"Search Runs...",
|
"search":"Search Runs...",
|
||||||
|
"sort_tooltip": "Click to sort",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"no_runs": "No runs found. Please try again.",
|
"no_runs": "No runs found. Please try again.",
|
||||||
"delete_success": "Run deleted successfully"
|
"delete_success": "Run deleted successfully"
|
||||||
@@ -206,7 +209,8 @@
|
|||||||
"confirm_text_fields": "Please confirm all text fields",
|
"confirm_text_fields": "Please confirm all text fields",
|
||||||
"unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.",
|
"unable_create_settings": "Unable to create list settings. Make sure you have defined a field for the list.",
|
||||||
"capture_text_discarded": "Capture Text Discarded",
|
"capture_text_discarded": "Capture Text Discarded",
|
||||||
"capture_list_discarded": "Capture List Discarded"
|
"capture_list_discarded": "Capture List Discarded",
|
||||||
|
"label_required": "Label cannot be empty"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_recording": {
|
"save_recording": {
|
||||||
@@ -459,6 +463,7 @@
|
|||||||
"log": "Log"
|
"log": "Log"
|
||||||
},
|
},
|
||||||
"empty_output": "The output is empty.",
|
"empty_output": "The output is empty.",
|
||||||
|
"loading": "Run in progress. Extracted data will appear here once run completes.",
|
||||||
"captured_data": {
|
"captured_data": {
|
||||||
"title": "Captured Data",
|
"title": "Captured Data",
|
||||||
"download_json": "Download as JSON",
|
"download_json": "Download as JSON",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"new": "Crear Robot",
|
"new": "Crear Robot",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "Ingresa la URL",
|
"title": "Ingresa la URL",
|
||||||
|
"login_title": "¿Este sitio requiere inicio de sesión?",
|
||||||
"label": "URL",
|
"label": "URL",
|
||||||
"button": "Comenzar grabación"
|
"button": "Comenzar grabación"
|
||||||
},
|
},
|
||||||
@@ -40,8 +41,9 @@
|
|||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"search": "Buscar robots...",
|
"search": "Buscar robots...",
|
||||||
"notifications": {
|
"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"
|
"delete_success": "Robot eliminado exitosamente",
|
||||||
|
"auth_success": "Robot autenticado exitosamente"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"search": "Buscar ejecuciones...",
|
"search": "Buscar ejecuciones...",
|
||||||
|
"sort_tooltip": "Haga clic para ordenar",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.",
|
"no_runs": "No se encontraron ejecuciones. Por favor, inténtelo de nuevo.",
|
||||||
"delete_success": "Ejecución eliminada con éxito"
|
"delete_success": "Ejecución eliminada con éxito"
|
||||||
@@ -205,7 +208,8 @@
|
|||||||
"confirm_text_fields": "Por favor confirme todos los campos de texto",
|
"confirm_text_fields": "Por favor confirme todos los campos de texto",
|
||||||
"unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.",
|
"unable_create_settings": "No se pueden crear las configuraciones de la lista. Asegúrese de haber definido un campo para la lista.",
|
||||||
"capture_text_discarded": "Captura de texto descartada",
|
"capture_text_discarded": "Captura de texto descartada",
|
||||||
"capture_list_discarded": "Captura de lista descartada"
|
"capture_list_discarded": "Captura de lista descartada",
|
||||||
|
"label_required": "La etiqueta no puede estar vacía"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_recording": {
|
"save_recording": {
|
||||||
@@ -449,6 +453,7 @@
|
|||||||
"log": "Registro"
|
"log": "Registro"
|
||||||
},
|
},
|
||||||
"empty_output": "La salida está vacía.",
|
"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": {
|
"captured_data": {
|
||||||
"title": "Datos Capturados",
|
"title": "Datos Capturados",
|
||||||
"download_json": "Descargar como JSON",
|
"download_json": "Descargar como JSON",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"new": "ロボットを作成",
|
"new": "ロボットを作成",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "URLを入力してください",
|
"title": "URLを入力してください",
|
||||||
|
"login_title": "このサイトはログインが必要ですか?",
|
||||||
"label": "URL",
|
"label": "URL",
|
||||||
"button": "録画を開始"
|
"button": "録画を開始"
|
||||||
},
|
},
|
||||||
@@ -40,8 +41,9 @@
|
|||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
"search": "ロボットを検索...",
|
"search": "ロボットを検索...",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "関連する実行があるため、ロボットを削除できません",
|
"delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください",
|
||||||
"delete_success": "ロボットが正常に削除されました"
|
"delete_success": "ロボットが正常に削除されました",
|
||||||
|
"auth_success": "ロボットの認証に成功しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"search": "実行を検索...",
|
"search": "実行を検索...",
|
||||||
|
"sort_tooltip": "クリックして並べ替え",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"no_runs": "実行が見つかりません。もう一度お試しください。",
|
"no_runs": "実行が見つかりません。もう一度お試しください。",
|
||||||
"delete_success": "実行が正常に削除されました"
|
"delete_success": "実行が正常に削除されました"
|
||||||
@@ -205,7 +208,8 @@
|
|||||||
"confirm_text_fields": "すべてのテキストフィールドを確認してください",
|
"confirm_text_fields": "すべてのテキストフィールドを確認してください",
|
||||||
"unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。",
|
"unable_create_settings": "リスト設定を作成できません。リストのフィールドを定義したことを確認してください。",
|
||||||
"capture_text_discarded": "テキスト取得が破棄されました",
|
"capture_text_discarded": "テキスト取得が破棄されました",
|
||||||
"capture_list_discarded": "リスト取得が破棄されました"
|
"capture_list_discarded": "リスト取得が破棄されました",
|
||||||
|
"label_required": "ラベルは空にできません"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_recording": {
|
"save_recording": {
|
||||||
@@ -449,6 +453,7 @@
|
|||||||
"log": "ログ"
|
"log": "ログ"
|
||||||
},
|
},
|
||||||
"empty_output": "出力は空です。",
|
"empty_output": "出力は空です。",
|
||||||
|
"loading": "実行中です。実行が完了すると、抽出されたデータがここに表示されます。",
|
||||||
"captured_data": {
|
"captured_data": {
|
||||||
"title": "キャプチャされたデータ",
|
"title": "キャプチャされたデータ",
|
||||||
"download_json": "JSONとしてダウンロード",
|
"download_json": "JSONとしてダウンロード",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"new": "创建机器人",
|
"new": "创建机器人",
|
||||||
"modal": {
|
"modal": {
|
||||||
"title": "输入URL",
|
"title": "输入URL",
|
||||||
|
"login_title": "此网站需要登录吗?",
|
||||||
"label": "URL",
|
"label": "URL",
|
||||||
"button": "开始录制"
|
"button": "开始录制"
|
||||||
},
|
},
|
||||||
@@ -40,8 +41,9 @@
|
|||||||
"duplicate": "复制",
|
"duplicate": "复制",
|
||||||
"search": "搜索机器人...",
|
"search": "搜索机器人...",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "无法删除机器人,因为它有关联的运行记录",
|
"delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人",
|
||||||
"delete_success": "机器人删除成功"
|
"delete_success": "机器人删除成功",
|
||||||
|
"auth_success": "机器人认证成功"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"search": "搜索运行记录...",
|
"search": "搜索运行记录...",
|
||||||
|
"sort_tooltip": "点击排序",
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"no_runs": "未找到运行记录。请重试。",
|
"no_runs": "未找到运行记录。请重试。",
|
||||||
"delete_success": "运行记录删除成功"
|
"delete_success": "运行记录删除成功"
|
||||||
@@ -205,7 +208,8 @@
|
|||||||
"confirm_text_fields": "请确认所有文本字段",
|
"confirm_text_fields": "请确认所有文本字段",
|
||||||
"unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。",
|
"unable_create_settings": "无法创建列表设置。请确保您已为列表定义了字段。",
|
||||||
"capture_text_discarded": "文本捕获已放弃",
|
"capture_text_discarded": "文本捕获已放弃",
|
||||||
"capture_list_discarded": "列表捕获已放弃"
|
"capture_list_discarded": "列表捕获已放弃",
|
||||||
|
"label_required": "标签不能为空"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"save_recording": {
|
"save_recording": {
|
||||||
@@ -449,6 +453,7 @@
|
|||||||
"log": "日志"
|
"log": "日志"
|
||||||
},
|
},
|
||||||
"empty_output": "输出为空。",
|
"empty_output": "输出为空。",
|
||||||
|
"loading": "运行中。运行完成后,提取的数据将显示在此处。",
|
||||||
"captured_data": {
|
"captured_data": {
|
||||||
"title": "捕获的数据",
|
"title": "捕获的数据",
|
||||||
"download_json": "下载为JSON",
|
"download_json": "下载为JSON",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ RUN mkdir -p /tmp/chromium-data-dir && \
|
|||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libgbm-dev \
|
libgbm1 \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libatk1.0-0 \
|
libatk1.0-0 \
|
||||||
libatk-bridge2.0-0 \
|
libatk-bridge2.0-0 \
|
||||||
@@ -44,12 +44,6 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
|
&& mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
|
||||||
|
|
||||||
# Add a dbus configuration to prevent connection errors
|
|
||||||
# RUN mkdir -p /var/run/dbus
|
|
||||||
|
|
||||||
# Make the script executable
|
|
||||||
# RUN chmod +x ./start.sh
|
|
||||||
|
|
||||||
# Expose the backend port
|
# Expose the backend port
|
||||||
EXPOSE ${BACKEND_PORT:-8080}
|
EXPOSE ${BACKEND_PORT:-8080}
|
||||||
|
|
||||||
|
|||||||
@@ -119,12 +119,13 @@ router.get("/logout", async (req, res) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/current-user",
|
"/current-user",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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"] },
|
attributes: { exclude: ["password"] },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -147,7 +148,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/user/:id",
|
"/user/:id",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -176,12 +177,13 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/generate-api-key",
|
"/generate-api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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"] },
|
attributes: { exclude: ["password"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,13 +218,14 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
"/api-key",
|
"/api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
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,
|
raw: true,
|
||||||
attributes: ["api_key"],
|
attributes: ["api_key"],
|
||||||
});
|
});
|
||||||
@@ -244,13 +247,14 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/delete-api-key",
|
"/delete-api-key",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
if (!req.user) {
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.findByPk(req.user.id, { raw: true });
|
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: "User not found" });
|
return res.status(404).json({ message: "User not found" });
|
||||||
@@ -260,7 +264,7 @@ router.delete(
|
|||||||
return res.status(404).json({ message: "API Key not found" });
|
return res.status(404).json({ message: "API Key not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await User.update({ api_key: null }, { where: { id: req.user.id } });
|
await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } });
|
||||||
|
|
||||||
capture("maxun-oss-api-key-deleted", {
|
capture("maxun-oss-api-key-deleted", {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
@@ -306,7 +310,8 @@ router.get("/google", (req, res) => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/google/callback",
|
"/google/callback",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
try {
|
try {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -332,12 +337,12 @@ router.get(
|
|||||||
return res.status(400).json({ message: "Email not found" });
|
return res.status(400).json({ message: "Email not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the currently authenticated user (from `requireSignIn`)
|
// Get the currently authenticated user (from `requireSignIn`)
|
||||||
let user = await User.findOne({ where: { id: req.user.id } });
|
let user = await User.findOne({ where: { id: authenticatedReq.user.id } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: "User not found" });
|
return res.status(400).json({ message: "User not found" });
|
||||||
@@ -392,11 +397,19 @@ router.get(
|
|||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
maxAge: 60000,
|
maxAge: 60000,
|
||||||
}); // 1-minute expiration
|
}); // 1-minute expiration
|
||||||
res.cookie("robot_auth_message", "Robot successfully authenticated", {
|
// res.cookie("robot_auth_message", "Robot successfully authenticated", {
|
||||||
|
// httpOnly: false,
|
||||||
|
// maxAge: 60000,
|
||||||
|
// });
|
||||||
|
res.cookie('robot_auth_robotId', robotId, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
maxAge: 60000,
|
maxAge: 60000,
|
||||||
});
|
});
|
||||||
res.redirect(`${process.env.PUBLIC_URL}/robots/${robotId}/integrate` as string || `http://localhost:5173/robots/${robotId}/integrate`);
|
|
||||||
|
const baseUrl = process.env.PUBLIC_URL || "http://localhost:5173";
|
||||||
|
const redirectUrl = `${baseUrl}/robots/`;
|
||||||
|
|
||||||
|
res.redirect(redirectUrl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ message: `Google OAuth error: ${error.message}` });
|
res.status(500).json({ message: `Google OAuth error: ${error.message}` });
|
||||||
}
|
}
|
||||||
@@ -407,12 +420,13 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/gsheets/data",
|
"/gsheets/data",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
const { spreadsheetId, robotId } = req.body;
|
const { spreadsheetId, robotId } = req.body;
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
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) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: "User not found" });
|
return res.status(400).json({ message: "User not found" });
|
||||||
@@ -524,13 +538,14 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => {
|
|||||||
router.post(
|
router.post(
|
||||||
"/gsheets/remove",
|
"/gsheets/remove",
|
||||||
requireSignIn,
|
requireSignIn,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: Request, res) => {
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
const { robotId } = req.body;
|
const { robotId } = req.body;
|
||||||
if (!robotId) {
|
if (!robotId) {
|
||||||
return res.status(400).json({ message: "Robot ID is required" });
|
return res.status(400).json({ message: "Robot ID is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).send({ error: "Unauthorized" });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +567,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
capture("maxun-oss-google-sheet-integration-removed", {
|
capture("maxun-oss-google-sheet-integration-removed", {
|
||||||
user_id: req.user.id,
|
user_id: authenticatedReq.user.id,
|
||||||
robot_id: robotId,
|
robot_id: robotId,
|
||||||
deleted_at: new Date().toISOString(),
|
deleted_at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ interface AuthenticatedRequest extends Request {
|
|||||||
user?: { id: string };
|
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 { server_url, username, password } = req.body;
|
||||||
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
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'] },
|
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 {
|
try {
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
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'],
|
attributes: ['proxy_url', 'proxy_username', 'proxy_password'],
|
||||||
raw: true
|
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 {
|
try {
|
||||||
if (!req.user) {
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
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'],
|
attributes: ['proxy_url', 'proxy_username', 'proxy_password'],
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
@@ -125,12 +128,13 @@ router.get('/config', requireSignIn, async (req: AuthenticatedRequest, res: Resp
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/config', requireSignIn, async (req: AuthenticatedRequest, res: Response) => {
|
router.delete('/config', requireSignIn, async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
const authenticatedReq = req as AuthenticatedRequest;
|
||||||
|
if (!authenticatedReq.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
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) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: 'User not found' });
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AuthenticatedRequest } from './record';
|
|||||||
import { computeNextRun } from '../utils/schedule';
|
import { computeNextRun } from '../utils/schedule';
|
||||||
import { capture } from "../utils/analytics";
|
import { capture } from "../utils/analytics";
|
||||||
import { tryCatch } from 'bullmq';
|
import { tryCatch } from 'bullmq';
|
||||||
|
import { encrypt, decrypt } from '../utils/auth';
|
||||||
import { WorkflowFile } from 'maxun-core';
|
import { WorkflowFile } from 'maxun-core';
|
||||||
import { Page } from 'playwright';
|
import { Page } from 'playwright';
|
||||||
import { airtableUpdateTasks, processAirtableUpdates } from '../workflow-management/integrations/airtable';
|
import { airtableUpdateTasks, processAirtableUpdates } from '../workflow-management/integrations/airtable';
|
||||||
@@ -25,6 +26,36 @@ chromium.use(stealthPlugin());
|
|||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
|
|
||||||
|
export const decryptWorkflowActions = async (workflow: any[],): Promise<any[]> => {
|
||||||
|
// Create a deep copy to avoid mutating the original workflow
|
||||||
|
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
|
||||||
|
|
||||||
|
// Process each step in the workflow
|
||||||
|
for (const step of processedWorkflow) {
|
||||||
|
if (!step.what) continue;
|
||||||
|
|
||||||
|
// Process each action in the step
|
||||||
|
for (const action of step.what) {
|
||||||
|
// Only process type and press actions
|
||||||
|
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) {
|
||||||
|
// The second argument contains the encrypted value
|
||||||
|
const encryptedValue = action.args[1];
|
||||||
|
if (typeof encryptedValue === 'string') {
|
||||||
|
try {
|
||||||
|
// Decrypt the value and update the args array
|
||||||
|
action.args[1] = await decrypt(encryptedValue);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt value:', error);
|
||||||
|
// Keep the encrypted value if decryption fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedWorkflow;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs information about recordings API.
|
* Logs information about recordings API.
|
||||||
*/
|
*/
|
||||||
@@ -56,6 +87,13 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
|
|||||||
raw: true
|
raw: true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (data?.recording?.workflow) {
|
||||||
|
data.recording.workflow = await decryptWorkflowActions(
|
||||||
|
data.recording.workflow,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return res.send(data);
|
return res.send(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.log('info', 'Error while reading robots');
|
logger.log('info', 'Error while reading robots');
|
||||||
@@ -117,13 +155,74 @@ function formatRunResponse(run: any) {
|
|||||||
return formattedRun;
|
return formattedRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CredentialInfo {
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credentials {
|
||||||
|
[key: string]: CredentialInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) {
|
||||||
|
return workflow.map(step => {
|
||||||
|
if (!step.what) return step;
|
||||||
|
|
||||||
|
const indicesToRemove = new Set<number>();
|
||||||
|
step.what.forEach((action: any, index: number) => {
|
||||||
|
if (!action.action || !action.args?.[0]) return;
|
||||||
|
|
||||||
|
if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) {
|
||||||
|
indicesToRemove.add(index);
|
||||||
|
|
||||||
|
if (step.what[index + 1]?.action === 'waitForLoadState') {
|
||||||
|
indicesToRemove.add(index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index));
|
||||||
|
|
||||||
|
Object.entries(credentials).forEach(([selector, credentialInfo]) => {
|
||||||
|
const clickIndex = filteredWhat.findIndex((action: any) =>
|
||||||
|
action.action === 'click' && action.args?.[0] === selector
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clickIndex !== -1) {
|
||||||
|
const chars = credentialInfo.value.split('');
|
||||||
|
|
||||||
|
chars.forEach((char, i) => {
|
||||||
|
filteredWhat.splice(clickIndex + 1 + (i * 2), 0, {
|
||||||
|
action: 'type',
|
||||||
|
args: [
|
||||||
|
selector,
|
||||||
|
encrypt(char),
|
||||||
|
credentialInfo.type
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredWhat.splice(clickIndex + 2 + (i * 2), 0, {
|
||||||
|
action: 'waitForLoadState',
|
||||||
|
args: ['networkidle']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
what: filteredWhat
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT endpoint to update the name and limit of a robot.
|
* PUT endpoint to update the name and limit of a robot.
|
||||||
*/
|
*/
|
||||||
router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, limit } = req.body;
|
const { name, limit, credentials } = req.body;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!name && limit === undefined) {
|
if (!name && limit === undefined) {
|
||||||
@@ -142,17 +241,21 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
|
|||||||
robot.set('recording_meta', { ...robot.recording_meta, name });
|
robot.set('recording_meta', { ...robot.recording_meta, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let workflow = [...robot.recording.workflow]; // Create a copy of the workflow
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
workflow = updateTypeActionsInWorkflow(workflow, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
// Update the limit
|
// Update the limit
|
||||||
if (limit !== undefined) {
|
if (limit !== undefined) {
|
||||||
const workflow = [...robot.recording.workflow]; // Create a copy of the workflow
|
|
||||||
|
|
||||||
// Ensure the workflow structure is valid before updating
|
// Ensure the workflow structure is valid before updating
|
||||||
if (
|
if (
|
||||||
workflow.length > 0 &&
|
workflow.length > 0 &&
|
||||||
workflow[0]?.what?.[0]
|
workflow[0]?.what?.[0]
|
||||||
) {
|
) {
|
||||||
// Create a new workflow object with the updated limit
|
// Create a new workflow object with the updated limit
|
||||||
const updatedWorkflow = workflow.map((step, index) => {
|
workflow = workflow.map((step, index) => {
|
||||||
if (index === 0) { // Assuming you want to update the first step
|
if (index === 0) { // Assuming you want to update the first step
|
||||||
return {
|
return {
|
||||||
...step,
|
...step,
|
||||||
@@ -174,14 +277,13 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
|
|||||||
}
|
}
|
||||||
return step;
|
return step;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace the workflow in the recording object
|
|
||||||
robot.set('recording', { ...robot.recording, workflow: updatedWorkflow });
|
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' });
|
return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
robot.set('recording', { ...robot.recording, workflow });
|
||||||
|
|
||||||
await robot.save();
|
await robot.save();
|
||||||
|
|
||||||
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
|
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ import { fork } from 'child_process';
|
|||||||
import { capture } from "./utils/analytics";
|
import { capture } from "./utils/analytics";
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import swaggerSpec from './swagger/config';
|
import swaggerSpec from './swagger/config';
|
||||||
|
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
|
|
||||||
|
import Run from './models/Run';
|
||||||
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: process.env.PUBLIC_URL ? process.env.PUBLIC_URL : 'http://localhost:5173',
|
origin: process.env.PUBLIC_URL ? process.env.PUBLIC_URL : 'http://localhost:5173',
|
||||||
@@ -124,8 +128,23 @@ server.listen(SERVER_PORT, '0.0.0.0', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', async () => {
|
||||||
console.log('Main app shutting down...');
|
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) {
|
if (!isProduction) {
|
||||||
workerProcess.kill();
|
workerProcess.kill();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export const connectDB = async () => {
|
|||||||
export const syncDB = async () => {
|
export const syncDB = async () => {
|
||||||
try {
|
try {
|
||||||
//setupAssociations();
|
//setupAssociations();
|
||||||
await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
// force: true will drop and recreate tables on every run
|
||||||
|
// Use `alter: true` only in development mode
|
||||||
|
await sequelize.sync({
|
||||||
|
force: false,
|
||||||
|
alter: isDevelopment
|
||||||
|
});
|
||||||
console.log('Database synced successfully!');
|
console.log('Database synced successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to sync database:', error);
|
console.error('Failed to sync database:', error);
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ async function jobCounts() {
|
|||||||
|
|
||||||
jobCounts();
|
jobCounts();
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
// We dont need this right now
|
||||||
console.log('Worker shutting down...');
|
|
||||||
process.exit();
|
// process.on('SIGINT', () => {
|
||||||
});
|
// console.log('Worker shutting down...');
|
||||||
|
// process.exit();
|
||||||
|
// });
|
||||||
|
|
||||||
export { workflowQueue, worker };
|
export { workflowQueue, worker };
|
||||||
@@ -39,6 +39,7 @@ interface MetaData {
|
|||||||
pairs: number;
|
pairs: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
params: string[],
|
params: string[],
|
||||||
|
isLogin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +98,7 @@ export class WorkflowGenerator {
|
|||||||
pairs: 0,
|
pairs: 0,
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
params: [],
|
params: [],
|
||||||
|
isLogin: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,9 +136,9 @@ export class WorkflowGenerator {
|
|||||||
*/
|
*/
|
||||||
private registerEventHandlers = (socket: Socket) => {
|
private registerEventHandlers = (socket: Socket) => {
|
||||||
socket.on('save', (data) => {
|
socket.on('save', (data) => {
|
||||||
const { fileName, userId } = data;
|
const { fileName, userId, isLogin } = data;
|
||||||
logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`);
|
logger.log('debug', `Saving workflow ${fileName} for user ID ${userId}`);
|
||||||
this.saveNewWorkflow(fileName, userId);
|
this.saveNewWorkflow(fileName, userId, isLogin);
|
||||||
});
|
});
|
||||||
socket.on('new-recording', () => this.workflowRecord = {
|
socket.on('new-recording', () => this.workflowRecord = {
|
||||||
workflow: [],
|
workflow: [],
|
||||||
@@ -425,6 +427,40 @@ export class WorkflowGenerator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) {
|
||||||
|
// Calculate the exact position within the element
|
||||||
|
const elementPos = await page.evaluate((selector) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top
|
||||||
|
};
|
||||||
|
}, selector);
|
||||||
|
|
||||||
|
if (elementPos) {
|
||||||
|
const relativeX = coordinates.x - elementPos.x;
|
||||||
|
const relativeY = coordinates.y - elementPos.y;
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where,
|
||||||
|
what: [{
|
||||||
|
action: 'click',
|
||||||
|
args: [selector, { position: { x: relativeX, y: relativeY } }]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selector) {
|
||||||
|
this.generatedData.lastUsedSelector = selector;
|
||||||
|
this.generatedData.lastAction = 'click';
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//const element = await getElementMouseIsOver(page, coordinates);
|
//const element = await getElementMouseIsOver(page, coordinates);
|
||||||
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
|
//logger.log('debug', `Element: ${JSON.stringify(element, null, 2)}`);
|
||||||
if (selector) {
|
if (selector) {
|
||||||
@@ -474,6 +510,10 @@ export class WorkflowGenerator {
|
|||||||
public onKeyboardInput = async (key: string, coordinates: Coordinates, page: Page) => {
|
public onKeyboardInput = async (key: string, coordinates: Coordinates, page: Page) => {
|
||||||
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
||||||
const selector = await this.generateSelector(page, coordinates, ActionType.Keydown);
|
const selector = await this.generateSelector(page, coordinates, ActionType.Keydown);
|
||||||
|
|
||||||
|
const elementInfo = await getElementInformation(page, coordinates, '', false);
|
||||||
|
const inputType = elementInfo?.attributes?.type || "text";
|
||||||
|
|
||||||
if (selector) {
|
if (selector) {
|
||||||
where.selectors = [selector];
|
where.selectors = [selector];
|
||||||
}
|
}
|
||||||
@@ -481,7 +521,7 @@ export class WorkflowGenerator {
|
|||||||
where,
|
where,
|
||||||
what: [{
|
what: [{
|
||||||
action: 'press',
|
action: 'press',
|
||||||
args: [selector, encrypt(key)],
|
args: [selector, encrypt(key), inputType],
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
if (selector) {
|
if (selector) {
|
||||||
@@ -660,7 +700,7 @@ export class WorkflowGenerator {
|
|||||||
* @param fileName The name of the file.
|
* @param fileName The name of the file.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public saveNewWorkflow = async (fileName: string, userId: number) => {
|
public saveNewWorkflow = async (fileName: string, userId: number, isLogin: boolean) => {
|
||||||
const recording = this.optimizeWorkflow(this.workflowRecord);
|
const recording = this.optimizeWorkflow(this.workflowRecord);
|
||||||
try {
|
try {
|
||||||
this.recordingMeta = {
|
this.recordingMeta = {
|
||||||
@@ -670,6 +710,7 @@ export class WorkflowGenerator {
|
|||||||
pairs: recording.workflow.length,
|
pairs: recording.workflow.length,
|
||||||
updatedAt: new Date().toLocaleString(),
|
updatedAt: new Date().toLocaleString(),
|
||||||
params: this.getParams() || [],
|
params: this.getParams() || [],
|
||||||
|
isLogin: isLogin,
|
||||||
}
|
}
|
||||||
const robot = await Robot.create({
|
const robot = await Robot.create({
|
||||||
userId,
|
userId,
|
||||||
@@ -991,6 +1032,7 @@ export class WorkflowGenerator {
|
|||||||
let input = {
|
let input = {
|
||||||
selector: '',
|
selector: '',
|
||||||
value: '',
|
value: '',
|
||||||
|
type: '',
|
||||||
actionCounter: 0,
|
actionCounter: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1005,7 +1047,7 @@ export class WorkflowGenerator {
|
|||||||
// when more than one press action is present, add a type action
|
// when more than one press action is present, add a type action
|
||||||
pair.what.splice(index - input.actionCounter, input.actionCounter, {
|
pair.what.splice(index - input.actionCounter, input.actionCounter, {
|
||||||
action: 'type',
|
action: 'type',
|
||||||
args: [input.selector, encrypt(input.value)],
|
args: [input.selector, encrypt(input.value), input.type],
|
||||||
}, {
|
}, {
|
||||||
action: 'waitForLoadState',
|
action: 'waitForLoadState',
|
||||||
args: ['networkidle'],
|
args: ['networkidle'],
|
||||||
@@ -1033,13 +1075,14 @@ export class WorkflowGenerator {
|
|||||||
action: 'waitForLoadState',
|
action: 'waitForLoadState',
|
||||||
args: ['networkidle'],
|
args: ['networkidle'],
|
||||||
})
|
})
|
||||||
input = { selector: '', value: '', actionCounter: 0 };
|
input = { selector: '', value: '', type: '', actionCounter: 0 };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pushTheOptimizedAction(pair, index);
|
pushTheOptimizedAction(pair, index);
|
||||||
input = {
|
input = {
|
||||||
selector: condition.args[0],
|
selector: condition.args[0],
|
||||||
value: condition.args[1],
|
value: condition.args[1],
|
||||||
|
type: condition.args[2],
|
||||||
actionCounter: 1,
|
actionCounter: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1048,7 +1091,7 @@ export class WorkflowGenerator {
|
|||||||
if (input.value.length !== 0) {
|
if (input.value.length !== 0) {
|
||||||
pushTheOptimizedAction(pair, index);
|
pushTheOptimizedAction(pair, index);
|
||||||
// clear the input
|
// clear the input
|
||||||
input = { selector: '', value: '', actionCounter: 0 };
|
input = { selector: '', value: '', type: '', actionCounter: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
|
||||||
render(<App />);
|
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
|
||||||
expect(linkElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
91
src/App.tsx
91
src/App.tsx
@@ -1,89 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
|
||||||
import { GlobalInfoProvider } from "./context/globalInfo";
|
import { GlobalInfoProvider } from "./context/globalInfo";
|
||||||
import { PageWrapper } from "./pages/PageWrappper";
|
import { PageWrapper } from "./pages/PageWrapper";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
import ThemeModeProvider from './context/theme-provider';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeModeProvider>
|
<ThemeModeProvider>
|
||||||
@@ -93,16 +14,6 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</GlobalInfoProvider>
|
</GlobalInfoProvider>
|
||||||
</ThemeModeProvider>
|
</ThemeModeProvider>
|
||||||
|
|
||||||
// <ThemeProvider theme={theme}>
|
|
||||||
|
|
||||||
// <GlobalInfoProvider>
|
|
||||||
// <Routes>
|
|
||||||
// <Route path="/*" element={<PageWrapper />} />
|
|
||||||
// </Routes>
|
|
||||||
// </GlobalInfoProvider>
|
|
||||||
|
|
||||||
// </ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
|||||||
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
|
import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
|
|
||||||
|
interface CredentialInfo {
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credentials {
|
||||||
|
[key: string]: CredentialInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export const getStoredRecordings = async (): Promise<string[] | null> => {
|
export const getStoredRecordings = async (): Promise<string[] | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/storage/recordings`);
|
const response = await axios.get(`${apiUrl}/storage/recordings`);
|
||||||
@@ -19,7 +28,7 @@ export const getStoredRecordings = async (): Promise<string[] | null> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise<boolean> => {
|
export const updateRecording = async (id: string, data: { name?: string; limit?: number, credentials?: Credentials }): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data);
|
const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
|
|||||||
@@ -4,8 +4,33 @@ import axios from 'axios';
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { stopRecording } from "../../api/recording";
|
import { stopRecording } from "../../api/recording";
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar, Tooltip } from "@mui/material";
|
import {
|
||||||
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language, Brightness7, Brightness4, Description } from "@mui/icons-material";
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
Snackbar,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
AccountCircle,
|
||||||
|
Logout,
|
||||||
|
Clear,
|
||||||
|
YouTube,
|
||||||
|
X,
|
||||||
|
Update,
|
||||||
|
Close,
|
||||||
|
Language,
|
||||||
|
Description,
|
||||||
|
LightMode,
|
||||||
|
DarkMode
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AuthContext } from '../../context/auth';
|
import { AuthContext } from '../../context/auth';
|
||||||
import { SaveRecording } from '../recorder/SaveRecording';
|
import { SaveRecording } from '../recorder/SaveRecording';
|
||||||
@@ -114,7 +139,7 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{darkMode ? <Brightness7 /> : <Brightness4 />}
|
{darkMode ? <LightMode /> : <DarkMode />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -200,7 +225,7 @@ export const NavBar: React.FC<NavBarProps> = ({
|
|||||||
border: "#00000099 1px solid",
|
border: "#00000099 1px solid",
|
||||||
'&:hover': { color: '#ff00c3', border: '#ff00c3 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>
|
</Button>
|
||||||
<Modal open={open} onClose={handleUpdateClose}>
|
<Modal open={open} onClose={handleUpdateClose}>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
11
src/components/dashboard/NotFound.tsx
Normal file
11
src/components/dashboard/NotFound.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>Oops! This page does not exist.</p>
|
||||||
|
<a href="/" style={{ textDecoration: 'none' }}>Take me to the homepage</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import axios from "axios";
|
|||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { getStoredRecording } from "../../api/storage";
|
import { getStoredRecording } from "../../api/storage";
|
||||||
import { apiUrl } from "../../apiConfig.js";
|
import { apiUrl } from "../../apiConfig.js";
|
||||||
|
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SignalCellularConnectedNoInternet0BarSharp } from "@mui/icons-material";
|
import { SignalCellularConnectedNoInternet0BarSharp } from "@mui/icons-material";
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ export interface IntegrationSettings {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions to replace js-cookie functionality
|
|
||||||
const getCookie = (name: string): string | null => {
|
const getCookie = (name: string): string | null => {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
@@ -49,6 +51,7 @@ const removeCookie = (name: string): void => {
|
|||||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const IntegrationSettingsModal = ({
|
export const IntegrationSettingsModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
handleStart,
|
handleStart,
|
||||||
@@ -285,6 +288,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Remove Airtable integration
|
// Remove Airtable integration
|
||||||
const removeAirtableIntegration = async () => {
|
const removeAirtableIntegration = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -314,6 +318,7 @@ export const IntegrationSettingsModal = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError("Error authenticating with Airtable");
|
setError("Error authenticating with Airtable");
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { Coordinates } from './canvas';
|
import { Coordinates } from '../recorder/canvas';
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
coordinates: Coordinates;
|
coordinates: Coordinates;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { GenericModal } from "../ui/GenericModal";
|
|||||||
import { modalStyle } from "./AddWhereCondModal";
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
import { Button, MenuItem, TextField, Typography } from "@mui/material";
|
import { Button, MenuItem, TextField, Typography } from "@mui/material";
|
||||||
import React, { useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { Dropdown as MuiDropdown } from "../ui/DropdownMui";
|
|
||||||
import { KeyValueForm } from "./KeyValueForm";
|
import { KeyValueForm } from "./KeyValueForm";
|
||||||
import { ClearButton } from "../ui/buttons/ClearButton";
|
import { ClearButton } from "../ui/buttons/ClearButton";
|
||||||
import { useSocketStore } from "../../context/socket";
|
import { useSocketStore } from "../../context/socket";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
interface HighlighterProps {
|
interface HighlighterProps {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||||||
import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow";
|
import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow";
|
||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||||
import { SidePanelHeader } from "./SidePanelHeader";
|
|
||||||
import { emptyWorkflow } from "../../shared/constants";
|
import { emptyWorkflow } from "../../shared/constants";
|
||||||
import { LeftSidePanelContent } from "./LeftSidePanelContent";
|
import { LeftSidePanelContent } from "./LeftSidePanelContent";
|
||||||
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
|
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import { Pair } from "./Pair";
|
import { Pair } from "./Pair";
|
||||||
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
||||||
import { useSocketStore } from "../../context/socket";
|
import { useSocketStore } from "../../context/socket";
|
||||||
import { Add } from "@mui/icons-material";
|
|
||||||
import { Socket } from "socket.io-client";
|
import { Socket } from "socket.io-client";
|
||||||
import { AddButton } from "../ui/buttons/AddButton";
|
import { AddButton } from "../ui/buttons/AddButton";
|
||||||
import { AddPair } from "../../api/workflow";
|
import { AddPair } from "../../api/workflow";
|
||||||
import { GenericModal } from "../ui/GenericModal";
|
import { GenericModal } from "../ui/GenericModal";
|
||||||
import { PairEditForm } from "./PairEditForm";
|
import { PairEditForm } from "./PairEditForm";
|
||||||
import { Fab, Tooltip, Typography } from "@mui/material";
|
import { Tooltip } from "@mui/material";
|
||||||
|
|
||||||
interface LeftSidePanelContentProps {
|
interface LeftSidePanelContentProps {
|
||||||
workflow: WorkflowFile;
|
workflow: WorkflowFile;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { WhereWhatPair } from "maxun-core";
|
import { WhereWhatPair } from "maxun-core";
|
||||||
import { Box, Button, IconButton, MenuItem, Stack, TextField, Tooltip, Typography } from "@mui/material";
|
import { IconButton, Stack, TextField, Tooltip, Typography } from "@mui/material";
|
||||||
import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
|
import { Close, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material";
|
||||||
import TreeView from '@mui/lab/TreeView';
|
import TreeView from '@mui/lab/TreeView';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
@@ -11,7 +11,6 @@ import { WarningText } from "../ui/texts";
|
|||||||
import NotificationImportantIcon from '@mui/icons-material/NotificationImportant';
|
import NotificationImportantIcon from '@mui/icons-material/NotificationImportant';
|
||||||
import { RemoveButton } from "../ui/buttons/RemoveButton";
|
import { RemoveButton } from "../ui/buttons/RemoveButton";
|
||||||
import { AddWhereCondModal } from "./AddWhereCondModal";
|
import { AddWhereCondModal } from "./AddWhereCondModal";
|
||||||
import { UpdatePair } from "../../api/workflow";
|
|
||||||
import { useSocketStore } from "../../context/socket";
|
import { useSocketStore } from "../../context/socket";
|
||||||
import { AddWhatCondModal } from "./AddWhatCondModal";
|
import { AddWhatCondModal } from "./AddWhatCondModal";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Button, Paper, Box, TextField, IconButton } from "@mui/material";
|
|||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||||
import DocumentScannerIcon from '@mui/icons-material/DocumentScanner';
|
import DocumentScannerIcon from '@mui/icons-material/DocumentScanner';
|
||||||
import { SimpleBox } from "../ui/Box";
|
|
||||||
import { WorkflowFile } from "maxun-core";
|
import { WorkflowFile } from "maxun-core";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
@@ -12,15 +11,12 @@ import { useBrowserSteps } from '../../context/browserSteps';
|
|||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { ScreenshotSettings } from '../../shared/types';
|
import { ScreenshotSettings } from '../../shared/types';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import { SidePanelHeader } from './SidePanelHeader';
|
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import FormLabel from '@mui/material/FormLabel';
|
import FormLabel from '@mui/material/FormLabel';
|
||||||
import Radio from '@mui/material/Radio';
|
import Radio from '@mui/material/Radio';
|
||||||
import RadioGroup from '@mui/material/RadioGroup';
|
import RadioGroup from '@mui/material/RadioGroup';
|
||||||
import { emptyWorkflow } from "../../shared/constants";
|
|
||||||
import { getActiveWorkflow } from "../../api/workflow";
|
import { getActiveWorkflow } from "../../api/workflow";
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import ActionDescriptionBox from '../action/ActionDescriptionBox';
|
import ActionDescriptionBox from '../action/ActionDescriptionBox';
|
||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
|||||||
const [recordingName, setRecordingName] = useState<string>(fileName);
|
const [recordingName, setRecordingName] = useState<string>(fileName);
|
||||||
const [waitingForSave, setWaitingForSave] = useState<boolean>(false);
|
const [waitingForSave, setWaitingForSave] = useState<boolean>(false);
|
||||||
|
|
||||||
const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore();
|
const { browserId, setBrowserId, notify, recordings, isLogin } = useGlobalInfoStore();
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { state, dispatch } = useContext(AuthContext);
|
const { state, dispatch } = useContext(AuthContext);
|
||||||
const { user } = state;
|
const { user } = state;
|
||||||
@@ -59,7 +59,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
|
|||||||
// releases resources and changes the view for main page by clearing the global browserId
|
// releases resources and changes the view for main page by clearing the global browserId
|
||||||
const saveRecording = async () => {
|
const saveRecording = async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const payload = { fileName: recordingName, userId: user.id };
|
const payload = { fileName: recordingName, userId: user.id, isLogin: isLogin };
|
||||||
socket?.emit('save', payload);
|
socket?.emit('save', payload);
|
||||||
setWaitingForSave(true);
|
setWaitingForSave(true);
|
||||||
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { getMappedCoordinates } from "../../helpers/inputHelpers";
|
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { useActionContext } from '../../context/browserActions';
|
import { useActionContext } from '../../context/browserActions';
|
||||||
import DatePicker from '../pickers/DatePicker';
|
import DatePicker from '../pickers/DatePicker';
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { RecordingsTable } from "./RecordingsTable";
|
import { RecordingsTable } from "./RecordingsTable";
|
||||||
import { Grid } from "@mui/material";
|
import { Grid } from "@mui/material";
|
||||||
import { RunSettings, RunSettingsModal } from "../run/RunSettings";
|
import { RunSettings, RunSettingsModal } from "../run/RunSettings";
|
||||||
import { ScheduleSettings, ScheduleSettingsModal } from "./ScheduleSettings";
|
import { ScheduleSettings, ScheduleSettingsModal } from "./ScheduleSettings";
|
||||||
import { IntegrationSettings, IntegrationSettingsModal } from "../integration/IntegrationSettings";
|
import { IntegrationSettingsModal } from "../integration/IntegrationSettings";
|
||||||
import { RobotSettings, RobotSettingsModal } from "./RobotSettings";
|
import { RobotSettingsModal } from "./RobotSettings";
|
||||||
import { RobotEditModal } from "./RobotEdit";
|
import { RobotEditModal } from "./RobotEdit";
|
||||||
import { RobotDuplicationModal } from "./RobotDuplicate";
|
import { RobotDuplicationModal } from "./RobotDuplicate";
|
||||||
import { useNavigate, useLocation, useParams } from "react-router-dom";
|
import { useNavigate, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface RecordingsProps {
|
interface RecordingsProps {
|
||||||
handleEditRecording: (id: string, fileName: string) => void;
|
handleEditRecording: (id: string, fileName: string) => void;
|
||||||
@@ -26,6 +28,8 @@ export const Recordings = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { selectedRecordingId } = useParams();
|
const { selectedRecordingId } = useParams();
|
||||||
const [params, setParams] = useState<string[]>([]);
|
const [params, setParams] = useState<string[]>([]);
|
||||||
|
const { notify } = useGlobalInfoStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleNavigate = (path: string, id: string, name: string, params: string[]) => {
|
const handleNavigate = (path: string, id: string, name: string, params: string[]) => {
|
||||||
setParams(params);
|
setParams(params);
|
||||||
@@ -39,6 +43,31 @@ export const Recordings = ({
|
|||||||
navigate("/robots"); // Navigate back to the main robots page
|
navigate("/robots"); // Navigate back to the main robots page
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Helper function to get and clear a cookie
|
||||||
|
const getAndClearCookie = (name: string) => {
|
||||||
|
const value = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith(`${name}=`))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const authStatus = getAndClearCookie('robot_auth_status');
|
||||||
|
const robotId = getAndClearCookie('robot_auth_robotId');
|
||||||
|
|
||||||
|
if (authStatus === 'success' && robotId) {
|
||||||
|
notify(authStatus, t("recordingtable.notifications.auth_success"));
|
||||||
|
|
||||||
|
handleNavigate(`/robots/${robotId}/integrate`, robotId, "", []);''
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Determine which modal to open based on the current route
|
// Determine which modal to open based on the current route
|
||||||
const getCurrentModal = () => {
|
const getCurrentModal = () => {
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
|
|||||||
@@ -8,11 +8,35 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TablePagination from '@mui/material/TablePagination';
|
import TablePagination from '@mui/material/TablePagination';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import { useEffect } from "react";
|
import { memo, useCallback, useEffect, useMemo } from "react";
|
||||||
import { WorkflowFile } from "maxun-core";
|
import { WorkflowFile } from "maxun-core";
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText } from "@mui/material";
|
import {
|
||||||
import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, MoreHoriz } from "@mui/icons-material";
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
MenuItem,
|
||||||
|
Menu,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
CircularProgress,
|
||||||
|
RadioGroup,
|
||||||
|
FormControlLabel,
|
||||||
|
Radio,
|
||||||
|
Checkbox,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Schedule,
|
||||||
|
DeleteForever,
|
||||||
|
Edit,
|
||||||
|
PlayCircle,
|
||||||
|
Settings,
|
||||||
|
Power,
|
||||||
|
ContentCopy,
|
||||||
|
MoreHoriz
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
|
import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
|
||||||
import { Add } from "@mui/icons-material";
|
import { Add } from "@mui/icons-material";
|
||||||
@@ -52,90 +76,162 @@ interface RecordingsTableProps {
|
|||||||
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
|
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
|
// Virtualized row component for efficient rendering
|
||||||
|
const TableRowMemoized = memo(({ row, columns, handlers }: any) => {
|
||||||
|
return (
|
||||||
|
<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,
|
||||||
|
handleScheduleRecording,
|
||||||
|
handleIntegrateRecording,
|
||||||
|
handleSettingsRecording,
|
||||||
|
handleEditRobot,
|
||||||
|
handleDuplicateRobot }: RecordingsTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = React.useState(0);
|
const [page, setPage] = React.useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||||
const [rows, setRows] = React.useState<Data[]>([]);
|
const [rows, setRows] = React.useState<Data[]>([]);
|
||||||
const [isModalOpen, setModalOpen] = React.useState(false);
|
const [isModalOpen, setModalOpen] = React.useState(false);
|
||||||
const [searchTerm, setSearchTerm] = React.useState('');
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
const columns: readonly Column[] = [
|
const columns = useMemo(() => [
|
||||||
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
|
||||||
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
|
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
|
||||||
{
|
{ id: 'schedule', label: t('recordingtable.schedule'), minWidth: 80 },
|
||||||
id: 'schedule',
|
{ id: 'integrate', label: t('recordingtable.integrate'), minWidth: 80 },
|
||||||
label: t('recordingtable.schedule'),
|
{ id: 'settings', label: t('recordingtable.settings'), minWidth: 80 },
|
||||||
minWidth: 80,
|
{ id: 'options', label: t('recordingtable.options'), minWidth: 80 },
|
||||||
},
|
], [t]);
|
||||||
{
|
|
||||||
id: 'integrate',
|
|
||||||
label: t('recordingtable.integrate'),
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
label: t('recordingtable.settings'),
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'options',
|
|
||||||
label: t('recordingtable.options'),
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const { notify, setRecordings, browserId, setBrowserId, setInitialUrl, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
|
const {
|
||||||
|
notify,
|
||||||
|
setRecordings,
|
||||||
|
browserId,
|
||||||
|
setBrowserId,
|
||||||
|
setInitialUrl,
|
||||||
|
recordingUrl,
|
||||||
|
setRecordingUrl,
|
||||||
|
isLogin,
|
||||||
|
setIsLogin,
|
||||||
|
recordingName,
|
||||||
|
setRecordingName,
|
||||||
|
recordingId,
|
||||||
|
setRecordingId } = useGlobalInfoStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleChangePage = (event: unknown, newPage: number) => {
|
const handleChangePage = useCallback((event: unknown, newPage: number) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRowsPerPage(+event.target.value);
|
setRowsPerPage(+event.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchTerm(event.target.value);
|
setSearchTerm(event.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const fetchRecordings = async () => {
|
const fetchRecordings = useCallback(async () => {
|
||||||
const recordings = await getStoredRecordings();
|
setIsLoading(true);
|
||||||
if (recordings) {
|
try {
|
||||||
const parsedRows: Data[] = [];
|
const recordings = await getStoredRecordings();
|
||||||
recordings.map((recording: any, index: number) => {
|
if (recordings) {
|
||||||
if (recording && recording.recording_meta) {
|
const parsedRows = recordings
|
||||||
parsedRows.push({
|
.map((recording: any, index: number) => {
|
||||||
id: index,
|
if (recording?.recording_meta) {
|
||||||
...recording.recording_meta,
|
return {
|
||||||
content: recording.recording
|
id: index,
|
||||||
});
|
...recording.recording_meta,
|
||||||
}
|
content: recording.recording
|
||||||
});
|
};
|
||||||
setRecordings(parsedRows.map((recording) => recording.name));
|
}
|
||||||
setRows(parsedRows);
|
return null;
|
||||||
} else {
|
})
|
||||||
console.log('No recordings found.');
|
.filter(Boolean);
|
||||||
|
|
||||||
|
setRecordings(parsedRows.map((recording) => recording.name));
|
||||||
|
setRows(parsedRows);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recordings:', error);
|
||||||
|
notify('error', t('recordingtable.notifications.fetch_error'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}, [setRecordings, notify, t]);
|
||||||
|
|
||||||
const handleNewRecording = async () => {
|
const handleNewRecording = useCallback(async () => {
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
setBrowserId(null);
|
setBrowserId(null);
|
||||||
await stopRecording(browserId);
|
await stopRecording(browserId);
|
||||||
}
|
}
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
}, [browserId]);
|
||||||
|
|
||||||
const handleStartRecording = () => {
|
const handleStartRecording = useCallback(() => {
|
||||||
setBrowserId('new-recording');
|
setBrowserId('new-recording');
|
||||||
setRecordingName('');
|
setRecordingName('');
|
||||||
setRecordingId('');
|
setRecordingId('');
|
||||||
navigate('/recording');
|
navigate('/recording');
|
||||||
}
|
}, [navigate]);
|
||||||
|
|
||||||
const startRecording = () => {
|
const startRecording = () => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
@@ -151,14 +247,61 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
fetchRecordings();
|
fetchRecordings();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [fetchRecordings]);
|
||||||
|
|
||||||
|
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
|
// Filter rows based on search term
|
||||||
const filteredRows = rows.filter((row) =>
|
const filteredRows = useMemo(() => {
|
||||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
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 (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@@ -200,103 +343,42 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
{isLoading ? (
|
||||||
<Table stickyHeader aria-label="sticky table">
|
<Box display="flex" justifyContent="center" alignItems="center" height="50%">
|
||||||
<TableHead>
|
<CircularProgress />
|
||||||
<TableRow>
|
</Box>
|
||||||
{columns.map((column) => (
|
) : (
|
||||||
<TableCell
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden', marginTop: '15px' }}>
|
||||||
key={column.id}
|
<Table stickyHeader aria-label="sticky table">
|
||||||
align={column.align}
|
<TableHead>
|
||||||
style={{ minWidth: column.minWidth }}
|
<TableRow>
|
||||||
>
|
{columns.map((column) => (
|
||||||
{column.label}
|
<MemoizedTableCell
|
||||||
</TableCell>
|
key={column.id}
|
||||||
|
// align={column.align}
|
||||||
|
style={{ minWidth: column.minWidth }}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</MemoizedTableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{visibleRows.map((row) => (
|
||||||
|
<TableRowMemoized
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
columns={columns}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableBody>
|
||||||
</TableHead>
|
</Table>
|
||||||
<TableBody>
|
</TableContainer>
|
||||||
{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={() => {
|
|
||||||
|
|
||||||
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
|
<TablePagination
|
||||||
rowsPerPageOptions={[10, 25, 50]}
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
component="div"
|
component="div"
|
||||||
count={filteredRows.length}
|
count={filteredRows.length}
|
||||||
rowsPerPage={rowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
@@ -305,7 +387,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
/>
|
/>
|
||||||
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
|
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '10px' }}>
|
||||||
<Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography>
|
<Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
label={t('recordingtable.modal.label')}
|
label={t('recordingtable.modal.label')}
|
||||||
@@ -313,8 +395,22 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={recordingUrl}
|
value={recordingUrl}
|
||||||
onChange={setBrowserRecordingUrl}
|
onChange={setBrowserRecordingUrl}
|
||||||
style={{ marginBottom: '20px', marginTop: '20px' }}
|
style={{ marginBottom: '10px', marginTop: '20px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={isLogin}
|
||||||
|
onChange={(e) => setIsLogin(e.target.checked)}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t('recordingtable.modal.login_title')}
|
||||||
|
style={{ marginBottom: '10px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br />
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -447,6 +543,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 = {
|
const modalStyle = {
|
||||||
top: '50%',
|
top: '50%',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { modalStyle } from "../recorder/AddWhereCondModal";
|
|||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { duplicateRecording, getStoredRecording } from '../../api/storage';
|
import { duplicateRecording, getStoredRecording } from '../../api/storage';
|
||||||
import { WhereWhatPair } from 'maxun-core';
|
import { WhereWhatPair } from 'maxun-core';
|
||||||
import { getUserById } from "../../api/auth";
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface RobotMeta {
|
interface RobotMeta {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { GenericModal } from "../ui/GenericModal";
|
import { GenericModal } from "../ui/GenericModal";
|
||||||
import { TextField, Typography, Box, Button } from "@mui/material";
|
import { TextField, Typography, Box, Button, IconButton, InputAdornment } from "@mui/material";
|
||||||
|
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
||||||
import { modalStyle } from "../recorder/AddWhereCondModal";
|
import { modalStyle } from "../recorder/AddWhereCondModal";
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
import { getStoredRecording, updateRecording } from '../../api/storage';
|
import { getStoredRecording, updateRecording } from '../../api/storage';
|
||||||
@@ -20,11 +21,6 @@ interface RobotWorkflow {
|
|||||||
workflow: WhereWhatPair[];
|
workflow: WhereWhatPair[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RobotEditOptions {
|
|
||||||
name: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleConfig {
|
interface ScheduleConfig {
|
||||||
runEvery: number;
|
runEvery: number;
|
||||||
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
|
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
|
||||||
@@ -57,10 +53,61 @@ interface RobotSettingsProps {
|
|||||||
initialSettings?: RobotSettings | null;
|
initialSettings?: RobotSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CredentialInfo {
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Credentials {
|
||||||
|
[key: string]: CredentialInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialVisibility {
|
||||||
|
[key: string]: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedCredentials {
|
||||||
|
passwords: string[];
|
||||||
|
emails: string[];
|
||||||
|
usernames: string[];
|
||||||
|
others: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
|
const [credentials, setCredentials] = useState<Credentials>({});
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
const [credentialGroups, setCredentialGroups] = useState<GroupedCredentials>({
|
||||||
|
passwords: [],
|
||||||
|
emails: [],
|
||||||
|
usernames: [],
|
||||||
|
others: []
|
||||||
|
});
|
||||||
|
const [showPasswords, setShowPasswords] = useState<CredentialVisibility>({});
|
||||||
|
|
||||||
|
const isEmailPattern = (value: string): boolean => {
|
||||||
|
return value.includes('@');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUsernameSelector = (selector: string): boolean => {
|
||||||
|
return selector.toLowerCase().includes('username') ||
|
||||||
|
selector.toLowerCase().includes('user') ||
|
||||||
|
selector.toLowerCase().includes('email');
|
||||||
|
};
|
||||||
|
|
||||||
|
const determineCredentialType = (selector: string, info: CredentialInfo): 'password' | 'email' | 'username' | 'other' => {
|
||||||
|
if (info.type === 'password' || selector.toLowerCase().includes('password')) {
|
||||||
|
return 'password';
|
||||||
|
}
|
||||||
|
if (isEmailPattern(info.value) || selector.toLowerCase().includes('email')) {
|
||||||
|
return 'email';
|
||||||
|
}
|
||||||
|
if (isUsernameSelector(selector)) {
|
||||||
|
return 'username';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -68,6 +115,112 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (robot?.recording?.workflow) {
|
||||||
|
const extractedCredentials = extractInitialCredentials(robot.recording.workflow);
|
||||||
|
setCredentials(extractedCredentials);
|
||||||
|
setCredentialGroups(groupCredentialsByType(extractedCredentials));
|
||||||
|
}
|
||||||
|
}, [robot]);
|
||||||
|
|
||||||
|
const extractInitialCredentials = (workflow: any[]): Credentials => {
|
||||||
|
const credentials: Credentials = {};
|
||||||
|
|
||||||
|
// Helper function to check if a character is printable
|
||||||
|
const isPrintableCharacter = (char: string): boolean => {
|
||||||
|
return char.length === 1 && !!char.match(/^[\x20-\x7E]$/);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each step in the workflow
|
||||||
|
workflow.forEach(step => {
|
||||||
|
if (!step.what) return;
|
||||||
|
|
||||||
|
// Keep track of the current input field being processed
|
||||||
|
let currentSelector = '';
|
||||||
|
let currentValue = '';
|
||||||
|
let currentType = '';
|
||||||
|
|
||||||
|
// Process actions in sequence to maintain correct text state
|
||||||
|
step.what.forEach((action: any) => {
|
||||||
|
if (
|
||||||
|
(action.action === 'type' || action.action === 'press') &&
|
||||||
|
action.args?.length >= 2 &&
|
||||||
|
typeof action.args[1] === 'string'
|
||||||
|
) {
|
||||||
|
const selector: string = action.args[0];
|
||||||
|
const character: string = action.args[1];
|
||||||
|
const inputType: string = action.args[2] || '';
|
||||||
|
|
||||||
|
// Detect `input[type="password"]`
|
||||||
|
if (!currentType && inputType.toLowerCase() === 'password') {
|
||||||
|
currentType = 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're dealing with a new selector, store the previous one
|
||||||
|
if (currentSelector && selector !== currentSelector) {
|
||||||
|
if (!credentials[currentSelector]) {
|
||||||
|
credentials[currentSelector] = {
|
||||||
|
value: currentValue,
|
||||||
|
type: currentType
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
credentials[currentSelector].value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current tracking variables
|
||||||
|
if (selector !== currentSelector) {
|
||||||
|
currentSelector = selector;
|
||||||
|
currentValue = credentials[selector]?.value || '';
|
||||||
|
currentType = inputType || credentials[selector]?.type || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different types of key actions
|
||||||
|
if (character === 'Backspace') {
|
||||||
|
// Remove the last character when backspace is pressed
|
||||||
|
currentValue = currentValue.slice(0, -1);
|
||||||
|
} else if (isPrintableCharacter(character)) {
|
||||||
|
// Add the character to the current value
|
||||||
|
currentValue += character;
|
||||||
|
}
|
||||||
|
// Note: We ignore other special keys like 'Shift', 'Enter', etc.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the final state of the last processed selector
|
||||||
|
if (currentSelector) {
|
||||||
|
credentials[currentSelector] = {
|
||||||
|
value: currentValue,
|
||||||
|
type: currentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return credentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupCredentialsByType = (credentials: Credentials): GroupedCredentials => {
|
||||||
|
return Object.entries(credentials).reduce((acc: GroupedCredentials, [selector, info]) => {
|
||||||
|
const credentialType = determineCredentialType(selector, info);
|
||||||
|
|
||||||
|
switch (credentialType) {
|
||||||
|
case 'password':
|
||||||
|
acc.passwords.push(selector);
|
||||||
|
break;
|
||||||
|
case 'email':
|
||||||
|
acc.emails.push(selector);
|
||||||
|
break;
|
||||||
|
case 'username':
|
||||||
|
acc.usernames.push(selector);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
acc.others.push(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, { passwords: [], emails: [], usernames: [], others: [] });
|
||||||
|
};
|
||||||
|
|
||||||
const getRobot = async () => {
|
const getRobot = async () => {
|
||||||
if (recordingId) {
|
if (recordingId) {
|
||||||
const robot = await getStoredRecording(recordingId);
|
const robot = await getStoredRecording(recordingId);
|
||||||
@@ -75,7 +228,14 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
} else {
|
} else {
|
||||||
notify('error', t('robot_edit.notifications.update_failed'));
|
notify('error', t('robot_edit.notifications.update_failed'));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleClickShowPassword = (selector: string) => {
|
||||||
|
setShowPasswords(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selector]: !prev[selector]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleRobotNameChange = (newName: string) => {
|
const handleRobotNameChange = (newName: string) => {
|
||||||
setRobot((prev) =>
|
setRobot((prev) =>
|
||||||
@@ -83,12 +243,21 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCredentialChange = (selector: string, value: string) => {
|
||||||
|
setCredentials(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selector]: {
|
||||||
|
...prev[selector],
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleLimitChange = (newLimit: number) => {
|
const handleLimitChange = (newLimit: number) => {
|
||||||
setRobot((prev) => {
|
setRobot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
|
|
||||||
const updatedWorkflow = [...prev.recording.workflow];
|
const updatedWorkflow = [...prev.recording.workflow];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updatedWorkflow.length > 0 &&
|
updatedWorkflow.length > 0 &&
|
||||||
updatedWorkflow[0]?.what &&
|
updatedWorkflow[0]?.what &&
|
||||||
@@ -104,20 +273,101 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderAllCredentialFields = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderCredentialFields(
|
||||||
|
credentialGroups.usernames,
|
||||||
|
t('Username'),
|
||||||
|
'text'
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderCredentialFields(
|
||||||
|
credentialGroups.emails,
|
||||||
|
t('Email'),
|
||||||
|
'text'
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderCredentialFields(
|
||||||
|
credentialGroups.passwords,
|
||||||
|
t('Password'),
|
||||||
|
'password'
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderCredentialFields(
|
||||||
|
credentialGroups.others,
|
||||||
|
t('Other'),
|
||||||
|
'text'
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCredentialFields = (selectors: string[], headerText: string, defaultType: 'text' | 'password' = 'text') => {
|
||||||
|
if (selectors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* <Typography variant="h6" style={{ marginBottom: '20px' }}>
|
||||||
|
{headerText}
|
||||||
|
</Typography> */}
|
||||||
|
{selectors.map((selector, index) => {
|
||||||
|
const isVisible = showPasswords[selector];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
key={selector}
|
||||||
|
type={isVisible ? 'text' : 'password'}
|
||||||
|
label={headerText === 'Other' ? `${`Input`} ${index + 1}` : headerText}
|
||||||
|
value={credentials[selector]?.value || ''}
|
||||||
|
onChange={(e) => handleCredentialChange(selector, e.target.value)}
|
||||||
|
style={{ marginBottom: '20px' }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Show input"
|
||||||
|
onClick={() => handleClickShowPassword(selector)}
|
||||||
|
edge="end"
|
||||||
|
disabled={!credentials[selector]?.value}
|
||||||
|
>
|
||||||
|
{isVisible ? <Visibility /> : <VisibilityOff />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!robot) return;
|
if (!robot) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const credentialsForPayload = Object.entries(credentials).reduce((acc, [selector, info]) => {
|
||||||
|
const enforceType = info.type === 'password' ? 'password' : 'text';
|
||||||
|
|
||||||
|
acc[selector] = {
|
||||||
|
value: info.value,
|
||||||
|
type: enforceType
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CredentialInfo>);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: robot.recording_meta.name,
|
name: robot.recording_meta.name,
|
||||||
limit: robot.recording.workflow[0]?.what[0]?.args?.[0]?.limit,
|
limit: robot.recording.workflow[0]?.what[0]?.args?.[0]?.limit,
|
||||||
|
credentials: credentialsForPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
const success = await updateRecording(robot.recording_meta.id, payload);
|
const success = await updateRecording(robot.recording_meta.id, payload);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
notify('success', t('robot_edit.notifications.update_success'));
|
notify('success', t('robot_edit.notifications.update_success'));
|
||||||
handleStart(robot); // Inform parent about the updated robot
|
handleStart(robot);
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -143,53 +393,61 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
|
|||||||
{t('robot_edit.title')}
|
{t('robot_edit.title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
{
|
{robot && (
|
||||||
robot && (
|
<>
|
||||||
<>
|
<TextField
|
||||||
|
label={t('robot_edit.change_name')}
|
||||||
|
key="Robot Name"
|
||||||
|
type='text'
|
||||||
|
value={robot.recording_meta.name}
|
||||||
|
onChange={(e) => handleRobotNameChange(e.target.value)}
|
||||||
|
style={{ marginBottom: '20px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
||||||
<TextField
|
<TextField
|
||||||
label={t('robot_edit.change_name')}
|
label={t('robot_edit.robot_limit')}
|
||||||
key="Robot Name"
|
type="number"
|
||||||
type='text'
|
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
||||||
value={robot.recording_meta.name}
|
onChange={(e) => {
|
||||||
onChange={(e) => handleRobotNameChange(e.target.value)}
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (value >= 1) {
|
||||||
|
handleLimitChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
style={{ marginBottom: '20px' }}
|
style={{ marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
)}
|
||||||
<TextField
|
|
||||||
label={t('robot_edit.robot_limit')}
|
|
||||||
type="number"
|
|
||||||
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = parseInt(e.target.value, 10);
|
|
||||||
if (value >= 1) {
|
|
||||||
handleLimitChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
inputProps={{ min: 1 }}
|
|
||||||
style={{ marginBottom: '20px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box mt={2} display="flex" justifyContent="flex-end">
|
{(Object.keys(credentials).length > 0) && (
|
||||||
<Button variant="contained" color="primary" onClick={handleSave}>
|
<>
|
||||||
{t('robot_edit.save')}
|
<Typography variant="body1" style={{ marginBottom: '20px' }}>
|
||||||
</Button>
|
{t('Input Texts')}
|
||||||
<Button
|
</Typography>
|
||||||
onClick={handleClose}
|
{renderAllCredentialFields()}
|
||||||
color="primary"
|
</>
|
||||||
variant="outlined"
|
)}
|
||||||
style={{ marginLeft: '10px' }}
|
|
||||||
sx={{
|
<Box mt={2} display="flex" justifyContent="flex-end">
|
||||||
color: '#ff00c3 !important',
|
<Button variant="contained" color="primary" onClick={handleSave}>
|
||||||
borderColor: '#ff00c3 !important',
|
{t('robot_edit.save')}
|
||||||
backgroundColor: 'whitesmoke !important',
|
</Button>
|
||||||
}}>
|
<Button
|
||||||
{t('robot_edit.cancel')}
|
onClick={handleClose}
|
||||||
</Button>
|
color="primary"
|
||||||
</Box>
|
variant="outlined"
|
||||||
</>
|
style={{ marginLeft: '10px' }}
|
||||||
)
|
sx={{
|
||||||
}
|
color: '#ff00c3 !important',
|
||||||
|
borderColor: '#ff00c3 !important',
|
||||||
|
backgroundColor: 'whitesmoke !important',
|
||||||
|
}}>
|
||||||
|
{t('robot_edit.cancel')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
sx={dropDownStyle}
|
sx={dropDownStyle}
|
||||||
>
|
>
|
||||||
{units.map((unit) => (
|
{units.map((unit) => (
|
||||||
<MenuItem key={unit} value={unit}>{unit}</MenuItem>
|
<MenuItem key={unit} value={unit}> {unit.charAt(0).toUpperCase() + unit.slice(1).toLowerCase()}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -207,7 +207,9 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
sx={dropDownStyle}
|
sx={dropDownStyle}
|
||||||
>
|
>
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<MenuItem key={day} value={day}>{day}</MenuItem>
|
<MenuItem key={day} value={day}>
|
||||||
|
{day.charAt(0).toUpperCase() + day.slice(1).toLowerCase()}
|
||||||
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -265,7 +267,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
|
|||||||
sx={dropDownStyle}
|
sx={dropDownStyle}
|
||||||
>
|
>
|
||||||
{validMomentTimezones.map((tz) => (
|
{validMomentTimezones.map((tz) => (
|
||||||
<MenuItem key={tz} value={tz}>{tz}</MenuItem>
|
<MenuItem key={tz} value={tz}>{tz.charAt(0).toUpperCase() + tz.slice(1).toLowerCase()}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Box, Button, Stack, Typography, CircularProgress } from "@mui/material";
|
import { Box, Button, Stack, Typography, CircularProgress } from "@mui/material";
|
||||||
import { PlayCircle } from "@mui/icons-material";
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { interpretCurrentRecording, stopCurrentInterpretation } from "../../api/recording";
|
import { interpretCurrentRecording } from "../../api/recording";
|
||||||
import { useSocketStore } from "../../context/socket";
|
import { useSocketStore } from "../../context/socket";
|
||||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { GenericModal } from "../ui/GenericModal";
|
import { GenericModal } from "../ui/GenericModal";
|
||||||
|
|||||||
@@ -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 Highlight from "react-highlight";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Data } from "./RunsTable";
|
import { Data } from "./RunsTable";
|
||||||
@@ -148,9 +148,15 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
</Button> : null}
|
</Button> : null}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value='output' sx={{ width: '700px' }}>
|
<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)
|
|| (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 &&
|
{row.serializableOutput &&
|
||||||
Object.keys(row.serializableOutput).length !== 0 &&
|
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 { GenericModal } from "../ui/GenericModal";
|
||||||
import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material";
|
import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material";
|
||||||
import { Dropdown } from "../ui/DropdownMui";
|
import { Dropdown } from "../ui/DropdownMui";
|
||||||
@@ -28,6 +28,23 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [showInterpreterSettings, setShowInterpreterSettings] = useState(false);
|
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 (
|
return (
|
||||||
<GenericModal
|
<GenericModal
|
||||||
@@ -35,18 +52,22 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
flexDirection: 'column',
|
display: "flex",
|
||||||
alignItems: 'flex-start',
|
flexDirection: "column",
|
||||||
marginLeft: '65px',
|
alignItems: "flex-start",
|
||||||
}}>
|
marginLeft: "65px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isTask && (
|
{isTask && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Typography sx={{ margin: '20px 0px' }}>Recording parameters:</Typography>
|
<Typography sx={{ margin: "20px 0px" }}>
|
||||||
|
Recording parameters:
|
||||||
|
</Typography>
|
||||||
{params?.map((item, index) => (
|
{params?.map((item, index) => (
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ marginBottom: '15px' }}
|
sx={{ marginBottom: "15px" }}
|
||||||
key={`param-${index}`}
|
key={`param-${index}`}
|
||||||
type="string"
|
type="string"
|
||||||
label={item}
|
label={item}
|
||||||
@@ -65,15 +86,22 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Switch checked={showInterpreterSettings} onChange={() => setShowInterpreterSettings(!showInterpreterSettings)} />}
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={showInterpreterSettings}
|
||||||
|
onChange={() =>
|
||||||
|
setShowInterpreterSettings(!showInterpreterSettings)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Developer Mode Settings"
|
label="Developer Mode Settings"
|
||||||
sx={{ margin: '20px 0px' }}
|
sx={{ margin: "20px 0px" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showInterpreterSettings && (
|
{showInterpreterSettings && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ marginBottom: '15px' }}
|
sx={{ marginBottom: "15px" }}
|
||||||
type="number"
|
type="number"
|
||||||
label="Max Concurrency"
|
label="Max Concurrency"
|
||||||
required
|
required
|
||||||
@@ -86,7 +114,7 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
|||||||
defaultValue={settings.maxConcurrency}
|
defaultValue={settings.maxConcurrency}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
sx={{ marginBottom: '15px' }}
|
sx={{ marginBottom: "15px" }}
|
||||||
type="number"
|
type="number"
|
||||||
label="Max Repeats"
|
label="Max Repeats"
|
||||||
required
|
required
|
||||||
@@ -115,7 +143,13 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par
|
|||||||
</React.Fragment>
|
</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>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
@@ -9,7 +9,7 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TablePagination from '@mui/material/TablePagination';
|
import TablePagination from '@mui/material/TablePagination';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
|
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField, CircularProgress, Tooltip } from '@mui/material';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -17,8 +17,8 @@ import { useGlobalInfoStore } from "../../context/globalInfo";
|
|||||||
import { getStoredRuns } from "../../api/storage";
|
import { getStoredRuns } from "../../api/storage";
|
||||||
import { RunSettings } from "./RunSettings";
|
import { RunSettings } from "./RunSettings";
|
||||||
import { CollapsibleRow } from "./ColapsibleRow";
|
import { CollapsibleRow } from "./ColapsibleRow";
|
||||||
|
import { ArrowDownward, ArrowUpward, UnfoldMore } from '@mui/icons-material';
|
||||||
|
|
||||||
// Export columns before the component
|
|
||||||
export const columns: readonly Column[] = [
|
export const columns: readonly Column[] = [
|
||||||
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
{ id: 'runStatus', label: 'Status', minWidth: 80 },
|
||||||
{ id: 'name', label: 'Name', minWidth: 80 },
|
{ id: 'name', label: 'Name', minWidth: 80 },
|
||||||
@@ -28,6 +28,15 @@ export const columns: readonly Column[] = [
|
|||||||
{ id: 'delete', label: 'Delete', minWidth: 80 },
|
{ id: 'delete', label: 'Delete', minWidth: 80 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc' | 'none';
|
||||||
|
|
||||||
|
interface AccordionSortConfig {
|
||||||
|
[robotMetaId: string]: {
|
||||||
|
field: keyof Data | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Column {
|
interface Column {
|
||||||
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
|
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
|
||||||
label: string;
|
label: string;
|
||||||
@@ -70,91 +79,223 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Update column labels using translation if needed
|
const [accordionSortConfigs, setAccordionSortConfigs] = useState<AccordionSortConfig>({});
|
||||||
const translatedColumns = columns.map(column => ({
|
|
||||||
...column,
|
const handleSort = useCallback((columnId: keyof Data, robotMetaId: string) => {
|
||||||
label: t(`runstable.${column.id}`, column.label)
|
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 [page, setPage] = useState(0);
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
const [rows, setRows] = useState<Data[]>([]);
|
const [rows, setRows] = useState<Data[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||||
|
|
||||||
const handleAccordionChange = (robotMetaId: string, isExpanded: boolean) => {
|
const handleAccordionChange = useCallback((robotMetaId: string, isExpanded: boolean) => {
|
||||||
if (isExpanded) {
|
navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs');
|
||||||
navigate(`/runs/${robotMetaId}`);
|
}, [navigate]);
|
||||||
} else {
|
|
||||||
navigate(`/runs`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePage = (event: unknown, newPage: number) => {
|
const handleChangePage = useCallback((event: unknown, newPage: number) => {
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setRowsPerPage(+event.target.value);
|
setRowsPerPage(+event.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const debouncedSearch = useCallback((fn: Function, delay: number) => {
|
||||||
setSearchTerm(event.target.value);
|
let timeoutId: NodeJS.Timeout;
|
||||||
setPage(0);
|
return (...args: any[]) => {
|
||||||
};
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchRuns = async () => {
|
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const runs = await getStoredRuns();
|
const debouncedSetSearch = debouncedSearch((value: string) => {
|
||||||
if (runs) {
|
setSearchTerm(value);
|
||||||
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
setPage(0);
|
||||||
id: index,
|
}, 300);
|
||||||
...run,
|
debouncedSetSearch(event.target.value);
|
||||||
}));
|
}, [debouncedSearch]);
|
||||||
setRows(parsedRows);
|
|
||||||
} else {
|
const fetchRuns = useCallback(async () => {
|
||||||
notify('error', t('runstable.notifications.no_runs'));
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const runs = await getStoredRuns();
|
||||||
|
if (runs) {
|
||||||
|
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
|
||||||
|
id: index,
|
||||||
|
...run,
|
||||||
|
}));
|
||||||
|
setRows(parsedRows);
|
||||||
|
} else {
|
||||||
|
notify('error', t('runstable.notifications.no_runs'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify('error', t('runstable.notifications.fetch_error'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [notify, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rows.length === 0 || rerenderRuns) {
|
let mounted = true;
|
||||||
fetchRuns();
|
|
||||||
setRerenderRuns(false);
|
|
||||||
}
|
|
||||||
}, [rerenderRuns, rows.length, setRerenderRuns]);
|
|
||||||
|
|
||||||
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([]);
|
setRows([]);
|
||||||
notify('success', t('runstable.notifications.delete_success'));
|
notify('success', t('runstable.notifications.delete_success'));
|
||||||
fetchRuns();
|
fetchRuns();
|
||||||
};
|
}, [notify, t, fetchRuns]);
|
||||||
|
|
||||||
// Filter rows based on search term
|
// Filter rows based on search term
|
||||||
const filteredRows = rows.filter((row) =>
|
const filteredRows = useMemo(() => {
|
||||||
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
let result = rows.filter((row) =>
|
||||||
);
|
row.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}, [rows, searchTerm]);
|
||||||
|
|
||||||
// Group filtered rows by robot meta id
|
// Group filtered rows by robot meta id
|
||||||
const groupedRows = filteredRows.reduce((acc, row) => {
|
const groupedRows = useMemo(() =>
|
||||||
if (!acc[row.robotMetaId]) {
|
filteredRows.reduce((acc, row) => {
|
||||||
acc[row.robotMetaId] = [];
|
if (!acc[row.robotMetaId]) {
|
||||||
|
acc[row.robotMetaId] = [];
|
||||||
|
}
|
||||||
|
acc[row.robotMetaId].push(row);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Data[]>),
|
||||||
|
[filteredRows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseDateString = (dateStr: string): Date => {
|
||||||
|
try {
|
||||||
|
if (dateStr.includes('PM') || dateStr.includes('AM')) {
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(dateStr.replace(/(\d+)\/(\d+)\//, '$2/$1/'))
|
||||||
|
} catch {
|
||||||
|
return new Date(0);
|
||||||
}
|
}
|
||||||
acc[row.robotMetaId].push(row);
|
};
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Data[]>);
|
const renderTableRows = useCallback((data: Data[], robotMetaId: string) => {
|
||||||
|
const start = page * rowsPerPage;
|
||||||
|
const end = start + rowsPerPage;
|
||||||
|
|
||||||
|
let sortedData = [...data];
|
||||||
|
const sortConfig = accordionSortConfigs[robotMetaId];
|
||||||
|
|
||||||
|
if (sortConfig?.field === 'startedAt' || sortConfig?.field === 'finishedAt') {
|
||||||
|
if (sortConfig.direction !== 'none') {
|
||||||
|
sortedData.sort((a, b) => {
|
||||||
|
const dateA = parseDateString(a[sortConfig.field!]);
|
||||||
|
const dateB = parseDateString(b[sortConfig.field!]);
|
||||||
|
|
||||||
|
return sortConfig.direction === 'asc'
|
||||||
|
? dateA.getTime() - dateB.getTime()
|
||||||
|
: dateB.getTime() - dateA.getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedData
|
||||||
|
.slice(start, end)
|
||||||
|
.map((row) => (
|
||||||
|
<CollapsibleRow
|
||||||
|
key={`row-${row.id}`}
|
||||||
|
row={row}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
isOpen={runId === row.runId && runningRecordingName === row.name}
|
||||||
|
currentLog={currentInterpretationLog}
|
||||||
|
abortRunHandler={abortRunHandler}
|
||||||
|
runningRecordingName={runningRecordingName}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}, [page, rowsPerPage, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete, accordionSortConfigs]);
|
||||||
|
|
||||||
|
const renderSortIcon = useCallback((column: Column, robotMetaId: string) => {
|
||||||
|
const sortConfig = accordionSortConfigs[robotMetaId];
|
||||||
|
if (column.id !== 'startedAt' && column.id !== 'finishedAt') return null;
|
||||||
|
|
||||||
|
if (sortConfig?.field !== column.id) {
|
||||||
|
return (
|
||||||
|
<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]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" height="50vh">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" component="h2">
|
||||||
{t('runstable.runs', 'Runs')}
|
{t('runstable.runs', 'Runs')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder={t('runstable.search', 'Search runs...')}
|
placeholder={t('runstable.search', 'Search runs...')}
|
||||||
value={searchTerm}
|
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
startAdornment: <SearchIcon sx={{ color: 'action.active', mr: 1 }} />
|
||||||
@@ -162,9 +303,14 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
sx={{ width: '250px' }}
|
sx={{ width: '250px' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||||
{Object.entries(groupedRows).map(([id, data]) => (
|
{Object.entries(groupedRows).map(([robotMetaId, data]) => (
|
||||||
<Accordion key={id} onChange={(event, isExpanded) => handleAccordionChange(id, isExpanded)}>
|
<Accordion
|
||||||
|
key={robotMetaId}
|
||||||
|
onChange={(event, isExpanded) => handleAccordionChange(robotMetaId, isExpanded)}
|
||||||
|
TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering
|
||||||
|
>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
@@ -177,41 +323,65 @@ export const RunsTable: React.FC<RunsTableProps> = ({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={column.id}
|
key={column.id}
|
||||||
align={column.align}
|
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>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data
|
{renderTableRows(data, robotMetaId)}
|
||||||
.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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
))}
|
))}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
||||||
<TablePagination
|
<TablePagination
|
||||||
rowsPerPageOptions={[10, 25, 50]}
|
|
||||||
component="div"
|
component="div"
|
||||||
count={filteredRows.length}
|
count={filteredRows.length}
|
||||||
rowsPerPage={rowsPerPage}
|
rowsPerPage={rowsPerPage}
|
||||||
page={page}
|
page={page}
|
||||||
onPageChange={handleChangePage}
|
onPageChange={handleChangePage}
|
||||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface GlobalInfo {
|
|||||||
notification: AlertSnackbarProps;
|
notification: AlertSnackbarProps;
|
||||||
notify: (severity: 'error' | 'warning' | 'info' | 'success', message: string) => void;
|
notify: (severity: 'error' | 'warning' | 'info' | 'success', message: string) => void;
|
||||||
closeNotify: () => void;
|
closeNotify: () => void;
|
||||||
|
isLogin: boolean;
|
||||||
|
setIsLogin: (isLogin: boolean) => void;
|
||||||
recordings: string[];
|
recordings: string[];
|
||||||
setRecordings: (recordings: string[]) => void;
|
setRecordings: (recordings: string[]) => void;
|
||||||
rerenderRuns: boolean;
|
rerenderRuns: boolean;
|
||||||
@@ -53,6 +55,7 @@ class GlobalInfoStore implements Partial<GlobalInfo> {
|
|||||||
recordingName = '';
|
recordingName = '';
|
||||||
initialUrl = 'https://';
|
initialUrl = 'https://';
|
||||||
recordingUrl = 'https://';
|
recordingUrl = 'https://';
|
||||||
|
isLogin = false;
|
||||||
currentWorkflowActionsState = {
|
currentWorkflowActionsState = {
|
||||||
hasScrapeListAction: false,
|
hasScrapeListAction: false,
|
||||||
hasScreenshotAction: false,
|
hasScreenshotAction: false,
|
||||||
@@ -75,6 +78,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
const [recordingLength, setRecordingLength] = useState<number>(globalInfoStore.recordingLength);
|
||||||
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
const [recordingId, setRecordingId] = useState<string | null>(globalInfoStore.recordingId);
|
||||||
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
const [recordingName, setRecordingName] = useState<string>(globalInfoStore.recordingName);
|
||||||
|
const [isLogin, setIsLogin] = useState<boolean>(globalInfoStore.isLogin);
|
||||||
const [initialUrl, setInitialUrl] = useState<string>(globalInfoStore.initialUrl);
|
const [initialUrl, setInitialUrl] = useState<string>(globalInfoStore.initialUrl);
|
||||||
const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl);
|
const [recordingUrl, setRecordingUrl] = useState<string>(globalInfoStore.recordingUrl);
|
||||||
const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState);
|
const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState);
|
||||||
@@ -127,6 +131,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => {
|
|||||||
setInitialUrl,
|
setInitialUrl,
|
||||||
recordingUrl,
|
recordingUrl,
|
||||||
setRecordingUrl,
|
setRecordingUrl,
|
||||||
|
isLogin,
|
||||||
|
setIsLogin,
|
||||||
currentWorkflowActionsState,
|
currentWorkflowActionsState,
|
||||||
setCurrentWorkflowActionsState,
|
setCurrentWorkflowActionsState,
|
||||||
shouldResetInterpretationLog,
|
shouldResetInterpretationLog,
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ const darkTheme = createTheme({
|
|||||||
main: "#ff00c3",
|
main: "#ff00c3",
|
||||||
contrastText: "#ffffff",
|
contrastText: "#ffffff",
|
||||||
},
|
},
|
||||||
|
error: {
|
||||||
|
main: '#f44336',
|
||||||
|
light: '#e57373',
|
||||||
|
dark: '#d32f2f',
|
||||||
|
contrastText: '#ffffff',
|
||||||
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#121212',
|
default: '#121212',
|
||||||
paper: '#1e1e1e',
|
paper: '#1e1e1e',
|
||||||
@@ -124,6 +130,14 @@ const darkTheme = createTheme({
|
|||||||
backgroundColor: 'rgba(255, 0, 195, 0.08)',
|
backgroundColor: 'rgba(255, 0, 195, 0.08)',
|
||||||
borderColor: '#ff66d9',
|
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": {
|
"&:hover": {
|
||||||
backgroundColor: 'rgba(255, 0, 195, 0.08)',
|
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({
|
const ThemeModeContext = createContext({
|
||||||
toggleTheme: () => {},
|
toggleTheme: () => { },
|
||||||
darkMode: false,
|
darkMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Login from './Login';
|
|||||||
import Register from './Register';
|
import Register from './Register';
|
||||||
import UserRoute from '../routes/userRoute';
|
import UserRoute from '../routes/userRoute';
|
||||||
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
||||||
import { Runs } from '../components/run/Runs';
|
import { NotFoundPage } from '../components/dashboard/NotFound';
|
||||||
|
|
||||||
export const PageWrapper = () => {
|
export const PageWrapper = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -75,6 +75,7 @@ export const PageWrapper = () => {
|
|||||||
path="/register"
|
path="/register"
|
||||||
element={<Register />}
|
element={<Register />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
Reference in New Issue
Block a user