Merge branch 'develop' into failed-run
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
"@types/react-dom": "^18.0.1",
|
"@types/react-dom": "^18.0.1",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"airtable": "^0.12.2",
|
"airtable": "^0.12.2",
|
||||||
"axios": "^0.26.0",
|
"axios": "^1.9.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "Der Roboter hat zugehörige Ausführungen. Löschen Sie zuerst die Ausführungen, um den Roboter zu löschen",
|
"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"
|
"auth_success": "Roboter erfolgreich authentifiziert",
|
||||||
|
"browser_limit_warning": "Remote-Browser sind derzeit ausgelastet. Bitte warten Sie einige Minuten und versuchen Sie es erneut"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "The robot has associated runs. First delete runs to delete the robot",
|
"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"
|
"auth_success": "Robot successfully authenticated",
|
||||||
|
"browser_limit_warning": "Remote browsers are currently busy. Please wait for a few minutes and try again"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu":{
|
"mainmenu":{
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "El robot tiene ejecuciones asociadas. Primero elimine las ejecuciones para eliminar el robot",
|
"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"
|
"auth_success": "Robot autenticado exitosamente",
|
||||||
|
"browser_limit_warning": "Los navegadores remotos están ocupados actualmente. Por favor, espere unos minutos e inténtelo de nuevo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください",
|
"delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください",
|
||||||
"delete_success": "ロボットが正常に削除されました",
|
"delete_success": "ロボットが正常に削除されました",
|
||||||
"auth_success": "ロボットの認証に成功しました"
|
"auth_success": "ロボットの認証に成功しました",
|
||||||
|
"browser_limit_warning": "リモートブラウザは現在ビジー状態です。数分お待ちいただいてから再度お試しください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人",
|
"delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人",
|
||||||
"delete_success": "机器人删除成功",
|
"delete_success": "机器人删除成功",
|
||||||
"auth_success": "机器人认证成功"
|
"auth_success": "机器人认证成功",
|
||||||
|
"browser_limit_warning": "远程浏览器当前繁忙。请稍等几分钟后重试"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mainmenu": {
|
"mainmenu": {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface BrowserPoolInfo {
|
|||||||
/**
|
/**
|
||||||
* The instance of remote browser.
|
* The instance of remote browser.
|
||||||
*/
|
*/
|
||||||
browser: RemoteBrowser,
|
browser: RemoteBrowser | null,
|
||||||
/**
|
/**
|
||||||
* States if the browser's instance is being actively used.
|
* States if the browser's instance is being actively used.
|
||||||
* Helps to persist the progress on the frontend when the application has been reloaded.
|
* Helps to persist the progress on the frontend when the application has been reloaded.
|
||||||
@@ -31,6 +31,11 @@ interface BrowserPoolInfo {
|
|||||||
* @default "recording"
|
* @default "recording"
|
||||||
*/
|
*/
|
||||||
state: BrowserState,
|
state: BrowserState,
|
||||||
|
/**
|
||||||
|
* The status of the browser instance.
|
||||||
|
* Can be "reserved", "initializing", "ready" or "failed".
|
||||||
|
*/
|
||||||
|
status?: "reserved" | "initializing" | "ready" | "failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,8 +210,18 @@ export class BrowserPool {
|
|||||||
* @returns remote browser instance or undefined if it does not exist in the pool
|
* @returns remote browser instance or undefined if it does not exist in the pool
|
||||||
*/
|
*/
|
||||||
public getRemoteBrowser = (id: string): RemoteBrowser | undefined => {
|
public getRemoteBrowser = (id: string): RemoteBrowser | undefined => {
|
||||||
logger.log('debug', `Remote browser with id: ${id} retrieved from the pool`);
|
const poolInfo = this.pool[id];
|
||||||
return this.pool[id]?.browser;
|
if (!poolInfo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return undefined for reserved slots (browser is null)
|
||||||
|
if (poolInfo.status === "reserved") {
|
||||||
|
logger.log('debug', `Browser ${id} is reserved but not yet ready`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return poolInfo.browser || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,6 +521,29 @@ export class BrowserPool {
|
|||||||
return browserIds.length > 0 ? browserIds[0] : null;
|
return browserIds.length > 0 ? browserIds[0] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are available browser slots for a user.
|
||||||
|
* Returns true if user has available slots AND none of their active browsers are in "recording" state.
|
||||||
|
* @param userId the user ID to check browser slots for
|
||||||
|
* @returns {boolean} true if user has available slots and no recording browsers, false otherwise
|
||||||
|
*/
|
||||||
|
public hasAvailableBrowserSlots = (userId: string, state?: BrowserState): boolean => {
|
||||||
|
const userBrowserIds = this.userToBrowserMap.get(userId) || [];
|
||||||
|
|
||||||
|
if (userBrowserIds.length >= 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "recording") {
|
||||||
|
const hasBrowserInState = userBrowserIds.some(browserId =>
|
||||||
|
this.pool[browserId] && this.pool[browserId].state === "recording"
|
||||||
|
);
|
||||||
|
return !hasBrowserInState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first active browser's instance id from the pool.
|
* Returns the first active browser's instance id from the pool.
|
||||||
* If there is no active browser, it returns null.
|
* If there is no active browser, it returns null.
|
||||||
@@ -524,4 +562,71 @@ export class BrowserPool {
|
|||||||
// logger.log('warn', `No active browser in the pool`);
|
// logger.log('warn', `No active browser in the pool`);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserves a browser slot immediately without creating the actual browser.
|
||||||
|
* This ensures slot counting is accurate for rapid successive requests.
|
||||||
|
*
|
||||||
|
* @param id browser ID to reserve
|
||||||
|
* @param userId user ID that owns this reservation
|
||||||
|
* @param state browser state ("recording" or "run")
|
||||||
|
* @returns true if slot was reserved, false if user has reached limit
|
||||||
|
*/
|
||||||
|
public reserveBrowserSlot = (id: string, userId: string, state: BrowserState = "run"): boolean => {
|
||||||
|
// Check if user has available slots first
|
||||||
|
if (!this.hasAvailableBrowserSlots(userId, state)) {
|
||||||
|
logger.log('debug', `Cannot reserve slot for user ${userId}: no available slots`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve the slot with null browser
|
||||||
|
this.pool[id] = {
|
||||||
|
browser: null,
|
||||||
|
active: false,
|
||||||
|
userId,
|
||||||
|
state,
|
||||||
|
status: "reserved"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the user-to-browser mapping
|
||||||
|
let userBrowserIds = this.userToBrowserMap.get(userId) || [];
|
||||||
|
if (!userBrowserIds.includes(id)) {
|
||||||
|
userBrowserIds.push(id);
|
||||||
|
this.userToBrowserMap.set(userId, userBrowserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Reserved browser slot ${id} for user ${userId} in state ${state}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrades a reserved slot to an actual browser instance.
|
||||||
|
*
|
||||||
|
* @param id browser ID that was previously reserved
|
||||||
|
* @param browser the actual RemoteBrowser instance
|
||||||
|
* @returns true if successful, false if slot wasn't reserved
|
||||||
|
*/
|
||||||
|
public upgradeBrowserSlot = (id: string, browser: RemoteBrowser): boolean => {
|
||||||
|
if (!this.pool[id] || this.pool[id].status !== "reserved") {
|
||||||
|
logger.log('warn', `Cannot upgrade browser ${id}: slot not reserved`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pool[id].browser = browser;
|
||||||
|
this.pool[id].status = "ready";
|
||||||
|
logger.log('info', `Upgraded browser slot ${id} to ready state`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a reserved slot as failed and removes it.
|
||||||
|
*
|
||||||
|
* @param id browser ID to mark as failed
|
||||||
|
*/
|
||||||
|
public failBrowserSlot = (id: string): void => {
|
||||||
|
if (this.pool[id]) {
|
||||||
|
logger.log('info', `Marking browser slot ${id} as failed`);
|
||||||
|
this.deleteRemoteBrowser(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,13 @@ const MEMORY_CONFIG = {
|
|||||||
heapUsageThreshold: 0.7 // 70% (reduced threshold to react earlier)
|
heapUsageThreshold: 0.7 // 70% (reduced threshold to react earlier)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_VIEWPORT = {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
mobile: false
|
||||||
|
};
|
||||||
|
|
||||||
const SCREENCAST_CONFIG: {
|
const SCREENCAST_CONFIG: {
|
||||||
format: "jpeg" | "png";
|
format: "jpeg" | "png";
|
||||||
maxWidth: number;
|
maxWidth: number;
|
||||||
@@ -32,13 +39,17 @@ const SCREENCAST_CONFIG: {
|
|||||||
targetFPS: number;
|
targetFPS: number;
|
||||||
compressionQuality: number;
|
compressionQuality: number;
|
||||||
maxQueueSize: number;
|
maxQueueSize: number;
|
||||||
|
skipFrameThreshold: number,
|
||||||
|
enableAdaptiveQuality: boolean,
|
||||||
} = {
|
} = {
|
||||||
format: 'png',
|
format: 'jpeg',
|
||||||
maxWidth: 1280,
|
maxWidth: DEFAULT_VIEWPORT.width,
|
||||||
maxHeight: 720,
|
maxHeight: DEFAULT_VIEWPORT.height,
|
||||||
targetFPS: 15,
|
targetFPS: 30,
|
||||||
compressionQuality: 0.95,
|
compressionQuality: 0.8,
|
||||||
maxQueueSize: 1
|
maxQueueSize: 2,
|
||||||
|
skipFrameThreshold: 100,
|
||||||
|
enableAdaptiveQuality: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +136,18 @@ export class RemoteBrowser {
|
|||||||
this.generator = new WorkflowGenerator(socket, poolId);
|
this.generator = new WorkflowGenerator(socket, poolId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cleanupMemory(): void {
|
||||||
|
if (this.screenshotQueue.length > 10) {
|
||||||
|
this.screenshotQueue = this.screenshotQueue.slice(-3); // Keep only last 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMemoryCleanup(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
this.cleanupMemory();
|
||||||
|
}, 30000); // Every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
private initializeMemoryManagement(): void {
|
private initializeMemoryManagement(): void {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const memoryUsage = process.memoryUsage();
|
const memoryUsage = process.memoryUsage();
|
||||||
@@ -412,6 +435,7 @@ export class RemoteBrowser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setupMemoryCleanup();
|
||||||
// this.initializeMemoryManagement();
|
// this.initializeMemoryManagement();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1399,7 +1423,7 @@ export class RemoteBrowser {
|
|||||||
*/
|
*/
|
||||||
private emitScreenshot = async (payload: Buffer, viewportSize?: { width: number, height: number }): Promise<void> => {
|
private emitScreenshot = async (payload: Buffer, viewportSize?: { width: number, height: number }): Promise<void> => {
|
||||||
if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) {
|
if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) {
|
||||||
this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize);
|
this.screenshotQueue = this.screenshotQueue.slice(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isProcessingScreenshot) {
|
if (this.isProcessingScreenshot) {
|
||||||
@@ -1414,7 +1438,7 @@ export class RemoteBrowser {
|
|||||||
try {
|
try {
|
||||||
const optimizationPromise = this.optimizeScreenshot(payload);
|
const optimizationPromise = this.optimizeScreenshot(payload);
|
||||||
const timeoutPromise = new Promise<Buffer>((resolve) => {
|
const timeoutPromise = new Promise<Buffer>((resolve) => {
|
||||||
setTimeout(() => resolve(payload), 150);
|
setTimeout(() => resolve(payload), 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const optimizedScreenshot = await Promise.race([optimizationPromise, timeoutPromise]);
|
const optimizedScreenshot = await Promise.race([optimizationPromise, timeoutPromise]);
|
||||||
@@ -1423,10 +1447,12 @@ export class RemoteBrowser {
|
|||||||
|
|
||||||
payload = null as any;
|
payload = null as any;
|
||||||
|
|
||||||
this.socket.emit('screencast', {
|
setImmediate(async () => {
|
||||||
|
this.socket.emit('screencast', {
|
||||||
image: dataWithMimeType,
|
image: dataWithMimeType,
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
viewport: viewportSize || await this.currentPage?.viewportSize() || null
|
viewport: viewportSize || await this.currentPage?.viewportSize() || null
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Screenshot emission failed:', error);
|
logger.error('Screenshot emission failed:', error);
|
||||||
@@ -1434,24 +1460,27 @@ export class RemoteBrowser {
|
|||||||
const base64Data = payload.toString('base64');
|
const base64Data = payload.toString('base64');
|
||||||
const dataWithMimeType = `data:image/png;base64,${base64Data}`;
|
const dataWithMimeType = `data:image/png;base64,${base64Data}`;
|
||||||
|
|
||||||
this.socket.emit('screencast', {
|
setImmediate(async () => {
|
||||||
image: dataWithMimeType,
|
this.socket.emit('screencast', {
|
||||||
userId: this.userId,
|
image: dataWithMimeType,
|
||||||
viewport: viewportSize || await this.currentPage?.viewportSize() || null
|
userId: this.userId,
|
||||||
|
viewport: viewportSize || await this.currentPage?.viewportSize() || null
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Fallback screenshot emission also failed:', e);
|
logger.error('Fallback screenshot emission also failed:', e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.isProcessingScreenshot = false;
|
this.isProcessingScreenshot = false;
|
||||||
|
|
||||||
if (this.screenshotQueue.length > 0) {
|
if (this.screenshotQueue.length > 0) {
|
||||||
const nextScreenshot = this.screenshotQueue.shift();
|
const nextScreenshot = this.screenshotQueue.shift();
|
||||||
if (nextScreenshot) {
|
if (nextScreenshot) {
|
||||||
setTimeout(() => {
|
const delay = this.screenshotQueue.length > 0 ? 16 : 33;
|
||||||
this.emitScreenshot(nextScreenshot);
|
setTimeout(() => {
|
||||||
}, 1000 / SCREENCAST_CONFIG.targetFPS);
|
this.emitScreenshot(nextScreenshot);
|
||||||
}
|
}, delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,20 +54,23 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
|
|||||||
* @category BrowserManagement-Controller
|
* @category BrowserManagement-Controller
|
||||||
*/
|
*/
|
||||||
export const createRemoteBrowserForRun = (userId: string): string => {
|
export const createRemoteBrowserForRun = (userId: string): string => {
|
||||||
const id = uuid();
|
if (!userId) {
|
||||||
|
logger.log('error', 'createRemoteBrowserForRun: Missing required parameter userId');
|
||||||
|
throw new Error('userId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuid();
|
||||||
|
|
||||||
|
const slotReserved = browserPool.reserveBrowserSlot(id, userId, "run");
|
||||||
|
if (!slotReserved) {
|
||||||
|
logger.log('warn', `Cannot create browser for user ${userId}: no available slots`);
|
||||||
|
throw new Error('User has reached maximum browser limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `createRemoteBrowserForRun: Reserved slot ${id} for user ${userId}`);
|
||||||
|
|
||||||
|
initializeBrowserAsync(id, userId);
|
||||||
|
|
||||||
createSocketConnectionForRun(
|
|
||||||
io.of(id),
|
|
||||||
async (socket: Socket) => {
|
|
||||||
try {
|
|
||||||
const browserSession = new RemoteBrowser(socket, userId, id);
|
|
||||||
await browserSession.initialize(userId);
|
|
||||||
browserPool.addRemoteBrowser(id, browserSession, userId, false, "run");
|
|
||||||
socket.emit('ready-for-run');
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Error initializing browser: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +138,19 @@ export const getActiveBrowserIdByState = (userId: string, state: "recording" | "
|
|||||||
return browserPool.getActiveBrowserId(userId, state);
|
return browserPool.getActiveBrowserId(userId, state);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are available browser slots for a user.
|
||||||
|
* Wrapper around {@link browserPool.hasAvailableBrowserSlots()} function.
|
||||||
|
* If state is provided, also checks that none of their active browsers are in that state.
|
||||||
|
* @param userId the user ID to check browser slots for
|
||||||
|
* @param state optional state to check - if provided, ensures no browser is in this state
|
||||||
|
* @returns {boolean} true if user has available slots (and no browsers in specified state if state is provided)
|
||||||
|
* @category BrowserManagement-Controller
|
||||||
|
*/
|
||||||
|
export const canCreateBrowserInState = (userId: string, state?: "recording" | "run"): boolean => {
|
||||||
|
return browserPool.hasAvailableBrowserSlots(userId, state);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the url string from a remote browser if exists in the browser pool.
|
* Returns the url string from a remote browser if exists in the browser pool.
|
||||||
* @param id instance id of the remote browser
|
* @param id instance id of the remote browser
|
||||||
@@ -198,3 +214,87 @@ export const stopRunningInterpretation = async (userId: string) => {
|
|||||||
logger.log('error', 'Cannot stop interpretation: No active browser or generator.');
|
logger.log('error', 'Cannot stop interpretation: No active browser or generator.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initializeBrowserAsync = async (id: string, userId: string) => {
|
||||||
|
try {
|
||||||
|
const namespace = io.of(id);
|
||||||
|
let clientConnected = false;
|
||||||
|
let connectionTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const waitForConnection = new Promise<Socket | null>((resolve) => {
|
||||||
|
namespace.on('connection', (socket: Socket) => {
|
||||||
|
clientConnected = true;
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
logger.log('info', `Frontend connected to browser ${id} via socket ${socket.id}`);
|
||||||
|
resolve(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
connectionTimeout = setTimeout(() => {
|
||||||
|
if (!clientConnected) {
|
||||||
|
logger.log('warn', `No client connected to browser ${id} within timeout, proceeding with dummy socket`);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
namespace.on('error', (error: any) => {
|
||||||
|
logger.log('error', `Socket namespace error for browser ${id}: ${error.message}`);
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
browserPool.failBrowserSlot(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = await waitForConnection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let browserSession: RemoteBrowser;
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
logger.log('info', `Using real socket for browser ${id}`);
|
||||||
|
browserSession = new RemoteBrowser(socket, userId, id);
|
||||||
|
} else {
|
||||||
|
logger.log('info', `Using dummy socket for browser ${id}`);
|
||||||
|
const dummySocket = {
|
||||||
|
emit: (event: string, data?: any) => {
|
||||||
|
logger.log('debug', `Browser ${id} dummy socket emitted ${event}:`, data);
|
||||||
|
},
|
||||||
|
on: () => {},
|
||||||
|
id: `dummy-${id}`,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
browserSession = new RemoteBrowser(dummySocket, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browserSession.initialize(userId);
|
||||||
|
|
||||||
|
const upgraded = browserPool.upgradeBrowserSlot(id, browserSession);
|
||||||
|
if (!upgraded) {
|
||||||
|
throw new Error('Failed to upgrade reserved browser slot');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('ready-for-run');
|
||||||
|
} else {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
logger.log('info', `Starting execution for browser ${id} with dummy socket`);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.log('error', `Error executing run for browser ${id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Browser ${id} successfully initialized for run with ${socket ? 'real' : 'dummy'} socket`);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.log('error', `Error initializing browser ${id}: ${error.message}`);
|
||||||
|
browserPool.failBrowserSlot(id);
|
||||||
|
if (socket) {
|
||||||
|
socket.emit('error', { message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.log('error', `Error setting up browser ${id}: ${error.message}`);
|
||||||
|
browserPool.failBrowserSlot(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
|||||||
const data = job.data;
|
const data = job.data;
|
||||||
logger.log('info', `Processing run execution job for runId: ${data.runId}, browserId: ${data.browserId}`);
|
logger.log('info', `Processing run execution job for runId: ${data.runId}, browserId: ${data.browserId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the run
|
// Find the run
|
||||||
const run = await Run.findOne({ where: { runId: data.runId } });
|
const run = await Run.findOne({ where: { runId: data.runId } });
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -232,7 +232,7 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
|||||||
|
|
||||||
const plainRun = run.toJSON();
|
const plainRun = run.toJSON();
|
||||||
const browserId = data.browserId || plainRun.browserId;
|
const browserId = data.browserId || plainRun.browserId;
|
||||||
|
|
||||||
if (!browserId) {
|
if (!browserId) {
|
||||||
throw new Error(`No browser ID available for run ${data.runId}`);
|
throw new Error(`No browser ID available for run ${data.runId}`);
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ async function processRunExecution(job: Job<ExecuteRunData>) {
|
|||||||
if (!recording) {
|
if (!recording) {
|
||||||
throw new Error(`Recording for run ${data.runId} not found`);
|
throw new Error(`Recording for run ${data.runId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRunAborted = async (): Promise<boolean> => {
|
const isRunAborted = async (): Promise<boolean> => {
|
||||||
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
const currentRun = await Run.findOne({ where: { runId: data.runId } });
|
||||||
return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false;
|
return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getRemoteBrowserCurrentTabs,
|
getRemoteBrowserCurrentTabs,
|
||||||
getActiveBrowserIdByState,
|
getActiveBrowserIdByState,
|
||||||
destroyRemoteBrowser,
|
destroyRemoteBrowser,
|
||||||
|
canCreateBrowserInState,
|
||||||
} from '../browser-management/controller';
|
} from '../browser-management/controller';
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from 'playwright-extra';
|
||||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
@@ -181,6 +182,18 @@ router.get('/active', requireSignIn, (req: AuthenticatedRequest, res) => {
|
|||||||
return res.send(id);
|
return res.send(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET endpoint for checking if the user can create a new remote browser.
|
||||||
|
*/
|
||||||
|
router.get('/can-create/:state', requireSignIn, (req: AuthenticatedRequest, res) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).send('User not authenticated');
|
||||||
|
}
|
||||||
|
const state = req.params.state as "recording" | "run";
|
||||||
|
const canCreate = canCreateBrowserInState(req.user.id, state);
|
||||||
|
return res.json({ canCreate });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET endpoint for getting the current url of the active remote browser.
|
* GET endpoint for getting the current url of the active remote browser.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
import { createRemoteBrowserForRun, getActiveBrowserIdByState } from "../browser-management/controller";
|
import { createRemoteBrowserForRun, destroyRemoteBrowser, getActiveBrowserIdByState } from "../browser-management/controller";
|
||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from 'playwright-extra';
|
||||||
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import { browserPool } from "../server";
|
import { browserPool } from "../server";
|
||||||
@@ -517,98 +517,124 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
|
|||||||
return res.status(401).send({ error: 'Unauthorized' });
|
return res.status(401).send({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyConfig = await getDecryptedProxyConfig(req.user.id);
|
|
||||||
let proxyOptions: any = {};
|
|
||||||
|
|
||||||
if (proxyConfig.proxy_url) {
|
|
||||||
proxyOptions = {
|
|
||||||
server: proxyConfig.proxy_url,
|
|
||||||
...(proxyConfig.proxy_username && proxyConfig.proxy_password && {
|
|
||||||
username: proxyConfig.proxy_username,
|
|
||||||
password: proxyConfig.proxy_password,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Proxy config for run: ${JSON.stringify(proxyOptions)}`);
|
|
||||||
|
|
||||||
// Generate runId first
|
// Generate runId first
|
||||||
const runId = uuid();
|
const runId = uuid();
|
||||||
|
|
||||||
// Check if user has reached browser limit
|
const canCreateBrowser = await browserPool.hasAvailableBrowserSlots(req.user.id, "run");
|
||||||
const userBrowserIds = browserPool.getAllBrowserIdsForUser(req.user.id);
|
|
||||||
const canCreateBrowser = userBrowserIds.length < 2;
|
|
||||||
|
|
||||||
if (canCreateBrowser) {
|
|
||||||
// User has available browser slots, create it directly
|
|
||||||
const id = createRemoteBrowserForRun(req.user.id);
|
|
||||||
|
|
||||||
const run = await Run.create({
|
if (canCreateBrowser) {
|
||||||
status: 'running',
|
let browserId: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
browserId = await createRemoteBrowserForRun(req.user.id);
|
||||||
|
|
||||||
|
if (!browserId || browserId.trim() === '') {
|
||||||
|
throw new Error('Failed to generate valid browser ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log('info', `Created browser ${browserId} for run ${runId}`);
|
||||||
|
|
||||||
|
} catch (browserError: any) {
|
||||||
|
logger.log('error', `Failed to create browser: ${browserError.message}`);
|
||||||
|
return res.status(500).send({ error: 'Failed to create browser instance' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Run.create({
|
||||||
|
status: 'running',
|
||||||
|
name: recording.recording_meta.name,
|
||||||
|
robotId: recording.id,
|
||||||
|
robotMetaId: recording.recording_meta.id,
|
||||||
|
startedAt: new Date().toLocaleString(),
|
||||||
|
finishedAt: '',
|
||||||
|
browserId: browserId,
|
||||||
|
interpreterSettings: req.body,
|
||||||
|
log: '',
|
||||||
|
runId,
|
||||||
|
runByUserId: req.user.id,
|
||||||
|
serializableOutput: {},
|
||||||
|
binaryOutput: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Created run ${runId} with browser ${browserId}`);
|
||||||
|
|
||||||
|
} catch (dbError: any) {
|
||||||
|
logger.log('error', `Database error creating run: ${dbError.message}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await destroyRemoteBrowser(browserId, req.user.id);
|
||||||
|
} catch (cleanupError: any) {
|
||||||
|
logger.log('warn', `Failed to cleanup browser after run creation failure: ${cleanupError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).send({ error: 'Failed to create run record' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userQueueName = `execute-run-user-${req.user.id}`;
|
||||||
|
await pgBoss.createQueue(userQueueName);
|
||||||
|
|
||||||
|
const jobId = await pgBoss.send(userQueueName, {
|
||||||
|
userId: req.user.id,
|
||||||
|
runId: runId,
|
||||||
|
browserId: browserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Queued run execution job with ID: ${jobId} for run: ${runId}`);
|
||||||
|
} catch (queueError: any) {
|
||||||
|
logger.log('error', `Failed to queue run execution: ${queueError.message}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Run.update({
|
||||||
|
status: 'failed',
|
||||||
|
finishedAt: new Date().toLocaleString(),
|
||||||
|
log: 'Failed to queue execution job'
|
||||||
|
}, { where: { runId: runId } });
|
||||||
|
|
||||||
|
await destroyRemoteBrowser(browserId, req.user.id);
|
||||||
|
} catch (cleanupError: any) {
|
||||||
|
logger.log('warn', `Failed to cleanup after queue error: ${cleanupError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(503).send({ error: 'Unable to queue run, please try again later' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send({
|
||||||
|
browserId: browserId,
|
||||||
|
runId: runId,
|
||||||
|
robotMetaId: recording.recording_meta.id,
|
||||||
|
queued: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const browserId = uuid();
|
||||||
|
|
||||||
|
await Run.create({
|
||||||
|
status: 'queued',
|
||||||
name: recording.recording_meta.name,
|
name: recording.recording_meta.name,
|
||||||
robotId: recording.id,
|
robotId: recording.id,
|
||||||
robotMetaId: recording.recording_meta.id,
|
robotMetaId: recording.recording_meta.id,
|
||||||
startedAt: new Date().toLocaleString(),
|
startedAt: new Date().toLocaleString(),
|
||||||
finishedAt: '',
|
finishedAt: '',
|
||||||
browserId: id,
|
browserId,
|
||||||
interpreterSettings: req.body,
|
interpreterSettings: req.body,
|
||||||
log: '',
|
log: 'Run queued - waiting for available browser slot',
|
||||||
runId,
|
runId,
|
||||||
runByUserId: req.user.id,
|
runByUserId: req.user.id,
|
||||||
serializableOutput: {},
|
serializableOutput: {},
|
||||||
binaryOutput: {},
|
binaryOutput: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const plainRun = run.toJSON();
|
|
||||||
|
|
||||||
return res.send({
|
return res.send({
|
||||||
browserId: id,
|
browserId: browserId,
|
||||||
runId: plainRun.runId,
|
runId: runId,
|
||||||
robotMetaId: recording.recording_meta.id,
|
robotMetaId: recording.recording_meta.id,
|
||||||
queued: false
|
queued: true
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const browserId = getActiveBrowserIdByState(req.user.id, "run")
|
|
||||||
|
|
||||||
if (browserId) {
|
|
||||||
// User has reached the browser limit, queue the run
|
|
||||||
try {
|
|
||||||
// Create the run record with 'queued' status
|
|
||||||
await Run.create({
|
|
||||||
status: 'queued',
|
|
||||||
name: recording.recording_meta.name,
|
|
||||||
robotId: recording.id,
|
|
||||||
robotMetaId: recording.recording_meta.id,
|
|
||||||
startedAt: new Date().toLocaleString(),
|
|
||||||
finishedAt: '',
|
|
||||||
browserId: browserId, // Random will be updated later
|
|
||||||
interpreterSettings: req.body,
|
|
||||||
log: 'Run queued - waiting for available browser slot',
|
|
||||||
runId,
|
|
||||||
runByUserId: req.user.id,
|
|
||||||
serializableOutput: {},
|
|
||||||
binaryOutput: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send({
|
|
||||||
browserId: browserId,
|
|
||||||
runId: runId,
|
|
||||||
robotMetaId: recording.recording_meta.id,
|
|
||||||
queued: true,
|
|
||||||
});
|
|
||||||
} catch (queueError: any) {
|
|
||||||
logger.log('error', `Failed to queue run job: ${queueError.message}`);
|
|
||||||
return res.status(503).send({ error: 'Unable to queue run, please try again later' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.log('info', "Browser id does not exist");
|
|
||||||
return res.send('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
logger.log('info', `Error while creating a run with robot id: ${req.params.id} - ${message}`);
|
logger.log('error', `Error while creating a run with robot id: ${req.params.id} - ${message}`);
|
||||||
return res.send('');
|
return res.status(500).send({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -945,3 +971,73 @@ router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest,
|
|||||||
return res.status(500).send({ error: 'Failed to abort run' });
|
return res.status(500).send({ error: 'Failed to abort run' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function processQueuedRuns() {
|
||||||
|
try {
|
||||||
|
const queuedRun = await Run.findOne({
|
||||||
|
where: { status: 'queued' },
|
||||||
|
order: [['startedAt', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!queuedRun) return;
|
||||||
|
|
||||||
|
const userId = queuedRun.runByUserId;
|
||||||
|
|
||||||
|
const canCreateBrowser = await browserPool.hasAvailableBrowserSlots(userId, "run");
|
||||||
|
|
||||||
|
if (canCreateBrowser) {
|
||||||
|
logger.log('info', `Processing queued run ${queuedRun.runId} for user ${userId}`);
|
||||||
|
|
||||||
|
const recording = await Robot.findOne({
|
||||||
|
where: {
|
||||||
|
'recording_meta.id': queuedRun.robotMetaId
|
||||||
|
},
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recording) {
|
||||||
|
await queuedRun.update({
|
||||||
|
status: 'failed',
|
||||||
|
finishedAt: new Date().toLocaleString(),
|
||||||
|
log: 'Recording not found'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newBrowserId = await createRemoteBrowserForRun(userId);
|
||||||
|
|
||||||
|
logger.log('info', `Created and initialized browser ${newBrowserId} for queued run ${queuedRun.runId}`);
|
||||||
|
|
||||||
|
await queuedRun.update({
|
||||||
|
status: 'running',
|
||||||
|
browserId: newBrowserId,
|
||||||
|
log: 'Browser created and ready for execution'
|
||||||
|
});
|
||||||
|
|
||||||
|
const userQueueName = `execute-run-user-${userId}`;
|
||||||
|
await pgBoss.createQueue(userQueueName);
|
||||||
|
|
||||||
|
const jobId = await pgBoss.send(userQueueName, {
|
||||||
|
userId: userId,
|
||||||
|
runId: queuedRun.runId,
|
||||||
|
browserId: newBrowserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log('info', `Queued execution for run ${queuedRun.runId} with ready browser ${newBrowserId}, job ID: ${jobId}`);
|
||||||
|
|
||||||
|
} catch (browserError: any) {
|
||||||
|
logger.log('error', `Failed to create browser for queued run: ${browserError.message}`);
|
||||||
|
await queuedRun.update({
|
||||||
|
status: 'failed',
|
||||||
|
finishedAt: new Date().toLocaleString(),
|
||||||
|
log: `Failed to create browser: ${browserError.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.log('error', `Error processing queued runs: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { processQueuedRuns };
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import connectPgSimple from 'connect-pg-simple';
|
|||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import Run from './models/Run';
|
import Run from './models/Run';
|
||||||
|
import { processQueuedRuns } from './routes/storage';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
@@ -163,6 +164,10 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
processQueuedRuns();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
server.listen(SERVER_PORT, '0.0.0.0', async () => {
|
server.listen(SERVER_PORT, '0.0.0.0', async () => {
|
||||||
try {
|
try {
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export const getActiveBrowserId = async(): Promise<string> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const canCreateBrowserInState = async(state: "recording" | "run"): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${apiUrl}/record/can-create/${state}`, { withCredentials: true });
|
||||||
|
if (response.status === 200) {
|
||||||
|
return response.data.canCreate;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch(error: any) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const interpretCurrentRecording = async(): Promise<boolean> => {
|
export const interpretCurrentRecording = async(): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/record/interpret`);
|
const response = await axios.get(`${apiUrl}/record/interpret`);
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ export const editRecordingFromStorage = async (browserId: string, id: string): P
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface CreateRunResponseWithQueue extends CreateRunResponse {
|
||||||
|
queued?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAndRunRecording = async (id: string, settings: RunSettings): Promise<CreateRunResponseWithQueue> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(
|
||||||
|
`${apiUrl}/storage/runs/${id}`,
|
||||||
|
{ ...settings, withCredentials: true }
|
||||||
|
);
|
||||||
|
if (response.status === 200) {
|
||||||
|
return response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Couldn't create and run recording ${id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(error);
|
||||||
|
return { browserId: '', runId: '', robotMetaId: '', queued: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const createRunForStoredRecording = async (id: string, settings: RunSettings): Promise<CreateRunResponse> => {
|
export const createRunForStoredRecording = async (id: string, settings: RunSettings): Promise<CreateRunResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { useSocketStore } from '../../context/socket';
|
import { useSocketStore } from '../../context/socket';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
import Canvas from "../recorder/canvas";
|
import Canvas from "../recorder/canvas";
|
||||||
@@ -84,6 +84,8 @@ export const BrowserWindow = () => {
|
|||||||
const [fields, setFields] = useState<Record<string, TextStep>>({});
|
const [fields, setFields] = useState<Record<string, TextStep>>({});
|
||||||
const [paginationSelector, setPaginationSelector] = useState<string>('');
|
const [paginationSelector, setPaginationSelector] = useState<string>('');
|
||||||
|
|
||||||
|
const highlighterUpdateRef = useRef<number>(0);
|
||||||
|
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { notify, currentTextActionId, currentListActionId } = useGlobalInfoStore();
|
const { notify, currentTextActionId, currentListActionId } = useGlobalInfoStore();
|
||||||
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
|
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
|
||||||
@@ -103,12 +105,12 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listSelector) {
|
if (listSelector) {
|
||||||
window.sessionStorage.setItem('recordingListSelector', listSelector);
|
sessionStorage.setItem('recordingListSelector', listSelector);
|
||||||
}
|
}
|
||||||
}, [listSelector]);
|
}, [listSelector]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedListSelector = window.sessionStorage.getItem('recordingListSelector');
|
const storedListSelector = sessionStorage.getItem('recordingListSelector');
|
||||||
|
|
||||||
// Only restore state if it exists in sessionStorage
|
// Only restore state if it exists in sessionStorage
|
||||||
if (storedListSelector && !listSelector) {
|
if (storedListSelector && !listSelector) {
|
||||||
@@ -172,6 +174,12 @@ export const BrowserWindow = () => {
|
|||||||
}, [screenShot, canvasRef, socket, screencastHandler]);
|
}, [screenShot, canvasRef, socket, screencastHandler]);
|
||||||
|
|
||||||
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - highlighterUpdateRef.current < 16) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
highlighterUpdateRef.current = now;
|
||||||
|
|
||||||
// Map the incoming DOMRect from browser coordinates to canvas coordinates
|
// Map the incoming DOMRect from browser coordinates to canvas coordinates
|
||||||
const mappedRect = new DOMRect(
|
const mappedRect = new DOMRect(
|
||||||
data.rect.x,
|
data.rect.x,
|
||||||
@@ -573,17 +581,22 @@ export const BrowserWindow = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const drawImage = (image: string, canvas: HTMLCanvasElement): void => {
|
const drawImage = (image: string, canvas: HTMLCanvasElement): void => {
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
img.src = image;
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
URL.revokeObjectURL(img.src);
|
requestAnimationFrame(() => {
|
||||||
ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
});
|
||||||
|
if (image.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(image);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.warn('Failed to load image');
|
||||||
|
};
|
||||||
|
img.src = image;
|
||||||
};
|
};
|
||||||
|
|
||||||
const modalStyle = {
|
const modalStyle = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { coordinateMapper } from '../../helpers/coordinateMapper';
|
import { coordinateMapper } from '../../helpers/coordinateMapper';
|
||||||
|
|
||||||
@@ -14,16 +14,15 @@ const HighlighterComponent = ({ unmodifiedRect, displayedSelector = '', width, h
|
|||||||
if (!unmodifiedRect) {
|
if (!unmodifiedRect) {
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
const mappedRect = coordinateMapper.mapBrowserRectToCanvas(unmodifiedRect);
|
const rect = useMemo(() => {
|
||||||
|
const mappedRect = coordinateMapper.mapBrowserRectToCanvas(unmodifiedRect);
|
||||||
const rect = {
|
return {
|
||||||
top: mappedRect.top + canvasRect.top + window.scrollY,
|
top: mappedRect.top + canvasRect.top + window.scrollY,
|
||||||
left: mappedRect.left + canvasRect.left + window.scrollX,
|
left: mappedRect.left + canvasRect.left + window.scrollX,
|
||||||
right: mappedRect.right + canvasRect.left,
|
width: mappedRect.width,
|
||||||
bottom: mappedRect.bottom + canvasRect.top,
|
height: mappedRect.height,
|
||||||
width: mappedRect.width,
|
};
|
||||||
height: mappedRect.height,
|
}, [unmodifiedRect, canvasRect.top, canvasRect.left]);
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,13 +29,17 @@ export interface Coordinates {
|
|||||||
const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const contextRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
|
const imageDataRef = useRef<ImageData | null>(null);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { setLastAction, lastAction } = useGlobalInfoStore();
|
const { setLastAction, lastAction } = useGlobalInfoStore();
|
||||||
const { getText, getList } = useActionContext();
|
const { getText, getList } = useActionContext();
|
||||||
const getTextRef = useRef(getText);
|
const getTextRef = useRef(getText);
|
||||||
const getListRef = useRef(getList);
|
const getListRef = useRef(getList);
|
||||||
|
|
||||||
const MOUSE_MOVE_THROTTLE = 16; // ~60fps
|
const MOUSE_MOVE_THROTTLE = 8;
|
||||||
const lastMouseMoveTime = useRef(0);
|
const lastMouseMoveTime = useRef(0);
|
||||||
|
|
||||||
const [datePickerInfo, setDatePickerInfo] = React.useState<{
|
const [datePickerInfo, setDatePickerInfo] = React.useState<{
|
||||||
@@ -72,6 +76,22 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
|
|
||||||
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 });
|
const lastMousePosition = useRef<Coordinates>({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && !contextRef.current) {
|
||||||
|
const ctx = canvasRef.current.getContext('2d', {
|
||||||
|
alpha: false,
|
||||||
|
desynchronized: true,
|
||||||
|
willReadFrequently: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
contextRef.current = ctx;
|
||||||
|
|
||||||
|
imageDataRef.current = ctx.createImageData(width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTextRef.current = getText;
|
getTextRef.current = getText;
|
||||||
getListRef.current = getList;
|
getListRef.current = getList;
|
||||||
@@ -79,115 +99,100 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on('showDatePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
const handleDatePicker = (info: { coordinates: Coordinates, selector: string }) => {
|
||||||
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
||||||
setDatePickerInfo({
|
setDatePickerInfo({ ...info, coordinates: canvasCoords });
|
||||||
...info,
|
};
|
||||||
coordinates: canvasCoords
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('showDropdown', (info: {
|
const handleDropdown = (info: {
|
||||||
coordinates: Coordinates,
|
coordinates: Coordinates,
|
||||||
selector: string,
|
selector: string,
|
||||||
options: Array<{
|
options: Array<{ value: string; text: string; disabled: boolean; selected: boolean; }>;
|
||||||
value: string;
|
|
||||||
text: string;
|
|
||||||
disabled: boolean;
|
|
||||||
selected: boolean;
|
|
||||||
}>;
|
|
||||||
}) => {
|
}) => {
|
||||||
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
||||||
setDropdownInfo({
|
setDropdownInfo({ ...info, coordinates: canvasCoords });
|
||||||
...info,
|
};
|
||||||
coordinates: canvasCoords
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('showTimePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
const handleTimePicker = (info: { coordinates: Coordinates, selector: string }) => {
|
||||||
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
||||||
setTimePickerInfo({
|
setTimePickerInfo({ ...info, coordinates: canvasCoords });
|
||||||
...info,
|
};
|
||||||
coordinates: canvasCoords
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('showDateTimePicker', (info: { coordinates: Coordinates, selector: string }) => {
|
const handleDateTimePicker = (info: { coordinates: Coordinates, selector: string }) => {
|
||||||
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates);
|
||||||
setDateTimeLocalInfo({
|
setDateTimeLocalInfo({ ...info, coordinates: canvasCoords });
|
||||||
...info,
|
};
|
||||||
coordinates: canvasCoords
|
|
||||||
});
|
socket.on('showDatePicker', handleDatePicker);
|
||||||
});
|
socket.on('showDropdown', handleDropdown);
|
||||||
|
socket.on('showTimePicker', handleTimePicker);
|
||||||
|
socket.on('showDateTimePicker', handleDateTimePicker);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('showDatePicker');
|
socket.off('showDatePicker', handleDatePicker);
|
||||||
socket.off('showDropdown');
|
socket.off('showDropdown', handleDropdown);
|
||||||
socket.off('showTimePicker');
|
socket.off('showTimePicker', handleTimePicker);
|
||||||
socket.off('showDateTimePicker');
|
socket.off('showDateTimePicker', handleDateTimePicker);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const onMouseEvent = useCallback((event: MouseEvent) => {
|
const onMouseEvent = useCallback((event: MouseEvent) => {
|
||||||
if (socket && canvasRef.current) {
|
if (!socket || !canvasRef.current) return;
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
const clickCoordinates = {
|
|
||||||
x: event.clientX - rect.left, // Use relative x coordinate
|
|
||||||
y: event.clientY - rect.top, // Use relative y coordinate
|
|
||||||
};
|
|
||||||
|
|
||||||
const browserCoordinates = coordinateMapper.mapCanvasToBrowser(clickCoordinates);
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const clickCoordinates = {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
};
|
||||||
|
|
||||||
switch (event.type) {
|
const browserCoordinates = coordinateMapper.mapCanvasToBrowser(clickCoordinates);
|
||||||
case 'mousedown':
|
|
||||||
if (getTextRef.current === true) {
|
switch (event.type) {
|
||||||
console.log('Capturing Text...');
|
case 'mousedown':
|
||||||
} else if (getListRef.current === true) {
|
if (getTextRef.current === true) {
|
||||||
console.log('Capturing List...');
|
console.log('Capturing Text...');
|
||||||
} else {
|
} else if (getListRef.current === true) {
|
||||||
socket.emit('input:mousedown', browserCoordinates);
|
console.log('Capturing List...');
|
||||||
}
|
} else {
|
||||||
notifyLastAction('click');
|
socket.emit('input:mousedown', browserCoordinates);
|
||||||
break;
|
|
||||||
case 'mousemove': {
|
|
||||||
const now = performance.now();
|
|
||||||
if (now - lastMouseMoveTime.current < MOUSE_MOVE_THROTTLE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastMouseMoveTime.current = now;
|
|
||||||
|
|
||||||
const dx = Math.abs(lastMousePosition.current.x - clickCoordinates.x);
|
|
||||||
const dy = Math.abs(lastMousePosition.current.y - clickCoordinates.y);
|
|
||||||
if (dx > 1 || dy > 1) {
|
|
||||||
lastMousePosition.current = {
|
|
||||||
x: clickCoordinates.x,
|
|
||||||
y: clickCoordinates.y,
|
|
||||||
};
|
|
||||||
socket.emit('input:mousemove', browserCoordinates);
|
|
||||||
notifyLastAction('move');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
notifyLastAction('click');
|
||||||
|
break;
|
||||||
|
|
||||||
// Optimize wheel events
|
case 'mousemove': {
|
||||||
case 'wheel': {
|
const now = performance.now();
|
||||||
const wheelEvent = event as WheelEvent;
|
if (now - lastMouseMoveTime.current < MOUSE_MOVE_THROTTLE) {
|
||||||
const deltaX = Math.round(wheelEvent.deltaX / 10) * 10;
|
return;
|
||||||
const deltaY = Math.round(wheelEvent.deltaY / 10) * 10;
|
|
||||||
|
|
||||||
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
|
|
||||||
socket.emit('input:wheel', { deltaX, deltaY });
|
|
||||||
notifyLastAction('scroll');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
default:
|
lastMouseMoveTime.current = now;
|
||||||
console.log('Default mouseEvent registered');
|
|
||||||
return;
|
const dx = Math.abs(lastMousePosition.current.x - clickCoordinates.x);
|
||||||
|
const dy = Math.abs(lastMousePosition.current.y - clickCoordinates.y);
|
||||||
|
|
||||||
|
if (dx > 0.5 || dy > 0.5) {
|
||||||
|
lastMousePosition.current = clickCoordinates;
|
||||||
|
socket.emit('input:mousemove', browserCoordinates);
|
||||||
|
notifyLastAction('move');
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'wheel': {
|
||||||
|
const wheelEvent = event as WheelEvent;
|
||||||
|
const deltaX = Math.round(wheelEvent.deltaX / 5) * 5;
|
||||||
|
const deltaY = Math.round(wheelEvent.deltaY / 5) * 5;
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
|
||||||
|
socket.emit('input:wheel', { deltaX, deltaY });
|
||||||
|
notifyLastAction('scroll');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [socket, notifyLastAction]);
|
||||||
|
|
||||||
const onKeyboardEvent = useCallback((event: KeyboardEvent) => {
|
const onKeyboardEvent = useCallback((event: KeyboardEvent) => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -206,47 +211,66 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [socket, notifyLastAction]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current) {
|
const canvas = canvasRef.current;
|
||||||
onCreateRef(canvasRef);
|
if (!canvas) return;
|
||||||
canvasRef.current.addEventListener('mousedown', onMouseEvent);
|
|
||||||
canvasRef.current.addEventListener('mousemove', onMouseEvent);
|
|
||||||
canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true });
|
|
||||||
canvasRef.current.addEventListener('keydown', onKeyboardEvent);
|
|
||||||
canvasRef.current.addEventListener('keyup', onKeyboardEvent);
|
|
||||||
|
|
||||||
return () => {
|
onCreateRef(canvasRef);
|
||||||
if (canvasRef.current) {
|
|
||||||
canvasRef.current.removeEventListener('mousedown', onMouseEvent);
|
|
||||||
canvasRef.current.removeEventListener('mousemove', onMouseEvent);
|
|
||||||
canvasRef.current.removeEventListener('wheel', onMouseEvent);
|
|
||||||
canvasRef.current.removeEventListener('keydown', onKeyboardEvent);
|
|
||||||
canvasRef.current.removeEventListener('keyup', onKeyboardEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
const options = { passive: true };
|
||||||
} else {
|
|
||||||
console.log('Canvas not initialized');
|
canvas.addEventListener('mousedown', onMouseEvent, options);
|
||||||
}
|
canvas.addEventListener('mousemove', onMouseEvent, options);
|
||||||
|
canvas.addEventListener('wheel', onMouseEvent, options);
|
||||||
|
canvas.addEventListener('keydown', onKeyboardEvent);
|
||||||
|
canvas.addEventListener('keyup', onKeyboardEvent);
|
||||||
|
|
||||||
}, [onMouseEvent]);
|
return () => {
|
||||||
|
canvas.removeEventListener('mousedown', onMouseEvent);
|
||||||
|
canvas.removeEventListener('mousemove', onMouseEvent);
|
||||||
|
canvas.removeEventListener('wheel', onMouseEvent);
|
||||||
|
canvas.removeEventListener('keydown', onKeyboardEvent);
|
||||||
|
canvas.removeEventListener('keyup', onKeyboardEvent);
|
||||||
|
};
|
||||||
|
}, [onMouseEvent, onKeyboardEvent, onCreateRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const containerStyle = React.useMemo<React.CSSProperties>(() => ({
|
||||||
|
borderRadius: '0px 0px 5px 5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
contain: 'layout style paint',
|
||||||
|
isolation: 'isolate' as React.CSSProperties['isolation']
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const canvasStyle = React.useMemo(() => ({
|
||||||
|
display: 'block',
|
||||||
|
imageRendering: 'crisp-edges' as const,
|
||||||
|
willChange: 'contents',
|
||||||
|
backfaceVisibility: 'hidden' as const,
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%'
|
||||||
|
}), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: '0px 0px 5px 5px', overflow: 'hidden', backgroundColor: 'white' }}>
|
<div style={containerStyle}>
|
||||||
<canvas
|
<canvas
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
style={{
|
style={canvasStyle}
|
||||||
display: 'block',
|
|
||||||
imageRendering: 'crisp-edges',
|
|
||||||
willChange: 'transform',
|
|
||||||
transform: 'translateZ(0)'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{datePickerInfo && (
|
{datePickerInfo && (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ 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";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getActiveBrowserId, stopRecording } from "../../api/recording";
|
import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from "../../api/recording";
|
||||||
import { GenericModal } from '../ui/GenericModal';
|
import { GenericModal } from '../ui/GenericModal';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -274,11 +274,16 @@ export const RecordingsTable = ({
|
|||||||
}, [setRecordings, notify, t]);
|
}, [setRecordings, notify, t]);
|
||||||
|
|
||||||
const handleNewRecording = useCallback(async () => {
|
const handleNewRecording = useCallback(async () => {
|
||||||
const activeBrowserId = await getActiveBrowserId();
|
const canCreateRecording = await canCreateBrowserInState("recording");
|
||||||
|
|
||||||
if (activeBrowserId) {
|
if (!canCreateRecording) {
|
||||||
setActiveBrowserId(activeBrowserId);
|
const activeBrowserId = await getActiveBrowserId();
|
||||||
setWarningModalOpen(true);
|
if (activeBrowserId) {
|
||||||
|
setActiveBrowserId(activeBrowserId);
|
||||||
|
setWarningModalOpen(true);
|
||||||
|
} else {
|
||||||
|
notify('warning', t('recordingtable.notifications.browser_limit_warning'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -314,7 +319,6 @@ export const RecordingsTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRetrainRobot = useCallback(async (id: string, name: string) => {
|
const handleRetrainRobot = useCallback(async (id: string, name: string) => {
|
||||||
const activeBrowserId = await getActiveBrowserId();
|
|
||||||
const robot = rows.find(row => row.id === id);
|
const robot = rows.find(row => row.id === id);
|
||||||
let targetUrl;
|
let targetUrl;
|
||||||
|
|
||||||
@@ -340,11 +344,18 @@ export const RecordingsTable = ({
|
|||||||
window.sessionStorage.setItem('initialUrl', targetUrl);
|
window.sessionStorage.setItem('initialUrl', targetUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeBrowserId) {
|
const canCreateRecording = await canCreateBrowserInState("recording");
|
||||||
setActiveBrowserId(activeBrowserId);
|
|
||||||
setWarningModalOpen(true);
|
if (!canCreateRecording) {
|
||||||
|
const activeBrowserId = await getActiveBrowserId();
|
||||||
|
if (activeBrowserId) {
|
||||||
|
setActiveBrowserId(activeBrowserId);
|
||||||
|
setWarningModalOpen(true);
|
||||||
|
} else {
|
||||||
|
notify('warning', t('recordingtable.notifications.browser_limit_warning'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
startRetrainRecording(id, name, targetUrl);
|
startRetrainRecording(id, name, targetUrl);
|
||||||
}
|
}
|
||||||
}, [rows, setInitialUrl, setRecordingUrl]);
|
}, [rows, setInitialUrl, setRecordingUrl]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { MainMenu } from "../components/dashboard/MainMenu";
|
import { MainMenu } from "../components/dashboard/MainMenu";
|
||||||
import { Stack } from "@mui/material";
|
import { Stack } from "@mui/material";
|
||||||
@@ -7,13 +7,14 @@ import { Runs } from "../components/run/Runs";
|
|||||||
import ProxyForm from '../components/proxy/ProxyForm';
|
import ProxyForm from '../components/proxy/ProxyForm';
|
||||||
import ApiKey from '../components/api/ApiKey';
|
import ApiKey from '../components/api/ApiKey';
|
||||||
import { useGlobalInfoStore } from "../context/globalInfo";
|
import { useGlobalInfoStore } from "../context/globalInfo";
|
||||||
import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage";
|
import { createAndRunRecording, createRunForStoredRecording, CreateRunResponseWithQueue, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
import { stopRecording } from "../api/recording";
|
import { stopRecording } from "../api/recording";
|
||||||
import { RunSettings } from "../components/run/RunSettings";
|
import { RunSettings } from "../components/run/RunSettings";
|
||||||
import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
import { ScheduleSettings } from "../components/robot/ScheduleSettings";
|
||||||
import { apiUrl } from "../apiConfig";
|
import { apiUrl } from "../apiConfig";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { AuthContext } from '../context/auth';
|
||||||
|
|
||||||
interface MainPageProps {
|
interface MainPageProps {
|
||||||
handleEditRecording: (id: string, fileName: string) => void;
|
handleEditRecording: (id: string, fileName: string) => void;
|
||||||
@@ -52,7 +53,7 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
|||||||
|
|
||||||
const abortRunHandler = (runId: string, robotName: string, browserId: string) => {
|
const abortRunHandler = (runId: string, robotName: string, browserId: string) => {
|
||||||
notify('info', t('main_page.notifications.abort_initiated', { name: robotName }));
|
notify('info', t('main_page.notifications.abort_initiated', { name: robotName }));
|
||||||
|
|
||||||
aborted = true;
|
aborted = true;
|
||||||
|
|
||||||
notifyAboutAbort(runId).then(async (response) => {
|
notifyAboutAbort(runId).then(async (response) => {
|
||||||
@@ -127,48 +128,109 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps)
|
|||||||
}, [currentInterpretationLog])
|
}, [currentInterpretationLog])
|
||||||
|
|
||||||
const handleRunRecording = useCallback((settings: RunSettings) => {
|
const handleRunRecording = useCallback((settings: RunSettings) => {
|
||||||
createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => {
|
createAndRunRecording(runningRecordingId, settings).then((response: CreateRunResponseWithQueue) => {
|
||||||
|
const { browserId, runId, robotMetaId, queued } = response;
|
||||||
|
|
||||||
setIds({ browserId, runId, robotMetaId });
|
setIds({ browserId, runId, robotMetaId });
|
||||||
navigate(`/runs/${robotMetaId}/run/${runId}`);
|
navigate(`/runs/${robotMetaId}/run/${runId}`);
|
||||||
const socket =
|
|
||||||
io(`${apiUrl}/${browserId}`, {
|
if (queued) {
|
||||||
|
console.log('Creating queue socket for queued run:', runId);
|
||||||
|
|
||||||
|
setQueuedRuns(prev => new Set([...prev, runId]));
|
||||||
|
|
||||||
|
const queueSocket = io(`${apiUrl}/queued-run`, {
|
||||||
|
transports: ["websocket"],
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
query: { userId: user?.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
queueSocket.on('connect', () => {
|
||||||
|
console.log('Queue socket connected for user:', user?.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
queueSocket.on('connect_error', (error) => {
|
||||||
|
console.log('Queue socket connection error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
queueSocket.on('run-completed', (completionData) => {
|
||||||
|
if (completionData.runId === runId) {
|
||||||
|
setRunningRecordingName('');
|
||||||
|
setCurrentInterpretationLog('');
|
||||||
|
setRerenderRuns(true);
|
||||||
|
|
||||||
|
setQueuedRuns(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(runId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
const robotName = completionData.robotName || runningRecordingName;
|
||||||
|
|
||||||
|
if (completionData.status === 'success') {
|
||||||
|
notify('success', t('main_page.notifications.interpretation_success', { name: robotName }));
|
||||||
|
} else {
|
||||||
|
notify('error', t('main_page.notifications.interpretation_failed', { name: robotName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
queueSocket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSockets(sockets => [...sockets, queueSocket]);
|
||||||
|
|
||||||
|
notify('info', `Run queued: ${runningRecordingName}`);
|
||||||
|
} else {
|
||||||
|
const socket = io(`${apiUrl}/${browserId}`, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket"],
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
});
|
});
|
||||||
setSockets(sockets => [...sockets, socket]);
|
|
||||||
socket.on('ready-for-run', () => readyForRunHandler(browserId, runId));
|
|
||||||
socket.on('debugMessage', debugMessageHandler);
|
|
||||||
socket.on('run-completed', (data) => {
|
|
||||||
setRerenderRuns(true);
|
|
||||||
|
|
||||||
const robotName = data.robotName;
|
setSockets(sockets => [...sockets, socket]);
|
||||||
|
|
||||||
if (data.status === 'success') {
|
socket.on('debugMessage', debugMessageHandler);
|
||||||
notify('success', t('main_page.notifications.interpretation_success', { name: robotName }));
|
socket.on('run-completed', (data) => {
|
||||||
|
setRunningRecordingName('');
|
||||||
|
setCurrentInterpretationLog('');
|
||||||
|
setRerenderRuns(true);
|
||||||
|
|
||||||
|
const robotName = data.robotName;
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
notify('success', t('main_page.notifications.interpretation_success', { name: robotName }));
|
||||||
|
} else {
|
||||||
|
notify('error', t('main_page.notifications.interpretation_failed', { name: robotName }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', (error) => {
|
||||||
|
console.log('error', `Failed to connect to browser ${browserId}: ${error}`);
|
||||||
|
notify('error', t('main_page.notifications.connection_failed', { name: runningRecordingName }));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
console.log('warn', `Disconnected from browser ${browserId}: ${reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runId) {
|
||||||
|
notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));
|
||||||
} else {
|
} else {
|
||||||
notify('error', t('main_page.notifications.interpretation_failed', { name: robotName }));
|
notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName }));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('run-aborted', (data) => {
|
|
||||||
setRerenderRuns(true);
|
|
||||||
|
|
||||||
const abortedRobotName = data.robotName;
|
|
||||||
notify('success', t('main_page.notifications.abort_success', { name: abortedRobotName }));
|
|
||||||
});
|
|
||||||
|
|
||||||
setContent('runs');
|
|
||||||
if (browserId) {
|
|
||||||
notify('info', t('main_page.notifications.run_started', { name: runningRecordingName }));
|
|
||||||
} else {
|
|
||||||
notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName }));
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
return (socket: Socket, browserId: string, runId: string) => {
|
setContent('runs');
|
||||||
socket.off('ready-for-run', () => readyForRunHandler(browserId, runId));
|
}).catch((error: any) => {
|
||||||
|
console.error('Error in createAndRunRecording:', error); // ✅ Debug log
|
||||||
|
});
|
||||||
|
|
||||||
|
return (socket: Socket) => {
|
||||||
socket.off('debugMessage', debugMessageHandler);
|
socket.off('debugMessage', debugMessageHandler);
|
||||||
|
socket.off('run-completed');
|
||||||
|
socket.off('connect_error');
|
||||||
|
socket.off('disconnect');
|
||||||
}
|
}
|
||||||
}, [runningRecordingName, sockets, ids, readyForRunHandler, debugMessageHandler])
|
}, [runningRecordingName, sockets, ids, debugMessageHandler, user?.id, t, notify, setRerenderRuns, setQueuedRuns, navigate, setContent, setIds]);
|
||||||
|
|
||||||
const handleScheduleRecording = (settings: ScheduleSettings) => {
|
const handleScheduleRecording = (settings: ScheduleSettings) => {
|
||||||
scheduleStoredRecording(runningRecordingId, settings)
|
scheduleStoredRecording(runningRecordingId, settings)
|
||||||
|
|||||||
Reference in New Issue
Block a user