diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 03ed5e41..e4a305fd 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -30,6 +30,8 @@ export class RemoteBrowser { */ private browser: Browser | null = null; + private context: BrowserContext | null = null; + /** * The Playwright's [CDPSession](https://playwright.dev/docs/api/class-cdpsession) instance, * used to talk raw Chrome Devtools Protocol. @@ -90,13 +92,13 @@ export class RemoteBrowser { */ public initialize = async (options: RemoteBrowserOptions): Promise => { this.browser = (await options.browser.launch(options.launchOptions)); - const context = await this.browser.newContext( + this.context = await this.browser.newContext( { viewport: { height: 400, width: 900 }, // recordVideo: { dir: 'videos/' } } ); - this.currentPage = await context.newPage(); + this.currentPage = await this.context.newPage(); const blocker = await PlaywrightBlocker.fromPrebuiltAdsAndTracking(fetch); await blocker.enableBlockingInPage(this.currentPage); this.client = await this.currentPage.context().newCDPSession(this.currentPage); @@ -138,6 +140,16 @@ export class RemoteBrowser { logger.log('error', `${tabInfo.index} index out of range of pages`) } }); + this.socket.on('setViewportSize', async (data: { width: number, height: number }) => { + const { width, height } = data; + logger.log('debug', `Received viewport size: width=${width}, height=${height}`); + + // Update the browser context's viewport dynamically + if (this.context && this.browser) { + this.context = await this.browser.newContext({ viewport: { width, height } }); + logger.log('debug', `Viewport size updated to width=${width}, height=${height} for the entire browser context`); + } + }); } /** @@ -280,7 +292,7 @@ export class RemoteBrowser { if (page) { await this.stopScreencast(); this.currentPage = page; - await this.currentPage.setViewportSize({ height: 400, width: 900 }) + //await this.currentPage.setViewportSize({ height: 400, width: 900 }) this.client = await this.currentPage.context().newCDPSession(this.currentPage); this.socket.emit('urlChanged', this.currentPage.url()); await this.makeAndEmitScreenshot(); diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts index d2dbca67..848a33a2 100644 --- a/server/src/models/Robot.ts +++ b/server/src/models/Robot.ts @@ -1,8 +1,6 @@ import { Model, DataTypes, Optional } from 'sequelize'; import sequelize from '../storage/db'; import { WorkflowFile, Where, What, WhereWhatPair } from 'maxun-core'; -import User from './User'; // Import User model -import Run from './Run'; interface RobotMeta { name: string; @@ -27,6 +25,19 @@ interface RobotAttributes { google_sheet_id?: string | null; google_access_token?: string | null; google_refresh_token?: string | null; + schedule?: ScheduleConfig | null; +} + +interface ScheduleConfig { + runEvery: number; + runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; + startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; + lastRunAt?: Date; + nextRunAt?: Date; + cronExpression?: string; } interface RobotCreationAttributes extends Optional { } @@ -41,6 +52,7 @@ class Robot extends Model implements R public google_sheet_id?: string | null; public google_access_token!: string | null; public google_refresh_token!: string | null; + public schedule!: ScheduleConfig | null; } Robot.init( @@ -82,6 +94,10 @@ Robot.init( type: DataTypes.STRING, allowNull: true, }, + schedule: { + type: DataTypes.JSONB, + allowNull: true, + }, }, { sequelize, diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index c684d6ea..5a72039f 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -12,7 +12,7 @@ import { requireSignIn } from '../middlewares/auth'; import Robot from '../models/Robot'; import Run from '../models/Run'; import { BinaryOutputService } from '../storage/mino'; -// import { workflowQueue } from '../worker'; +import { workflowQueue } from '../worker'; export const router = Router(); @@ -46,7 +46,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { where: { 'recording_meta.id': req.params.id }, raw: true } - ); + ); return res.send(data); } catch (e) { logger.log('info', 'Error while reading recordings'); @@ -208,8 +208,8 @@ router.post('/runs/run/:id', requireSignIn, async (req, res) => { if (browser && currentPage) { const interpretationInfo = await browser.interpreter.InterpretRecording( recording.recording, currentPage, plainRun.interpreterSettings); - const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); - const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); + const binaryOutputService = new BinaryOutputService('maxun-run-screenshots'); + const uploadedBinaryOutput = await binaryOutputService.uploadAndStoreBinaryOutput(run, interpretationInfo.binaryOutput); await destroyRemoteBrowser(plainRun.browserId); await run.update({ ...run, @@ -247,18 +247,45 @@ router.put('/schedule/:id/', requireSignIn, async (req, res) => { try { const { id } = req.params; const { + // enabled = true, runEvery, runEveryUnit, startFrom, - atTime, + atTimeStart, + atTimeEnd, timezone } = req.body; - if (!id || !runEvery || !runEveryUnit || !startFrom || !atTime || !timezone) { + const robot = await Robot.findOne({ where: { 'recording_meta.id': id } }); + if (!robot) { + return res.status(404).json({ error: 'Robot not found' }); + } + + // If disabled, remove scheduling + // if (!enabled) { + // // Remove existing job from queue if it exists + // const existingJobs = await workflowQueue.getJobs(['delayed', 'waiting']); + // for (const job of existingJobs) { + // if (job.data.id === id) { + // await job.remove(); + // } + // } + + // // Update robot to disable scheduling + // await robot.update({ + // schedule: null + // }); + + // return res.status(200).json({ + // message: 'Schedule disabled successfully' + // }); + // } + + if (!id || !runEvery || !runEveryUnit || !startFrom || !timezone || (runEveryUnit === 'HOURS' || runEveryUnit === 'MINUTES') && (!atTimeStart || !atTimeEnd)) { return res.status(400).json({ error: 'Missing required parameters' }); } - if (!['HOURS', 'DAYS', 'WEEKS', 'MONTHS'].includes(runEveryUnit)) { + if (!['HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'MINUTES'].includes(runEveryUnit)) { return res.status(400).json({ error: 'Invalid runEvery unit' }); } @@ -266,8 +293,12 @@ router.put('/schedule/:id/', requireSignIn, async (req, res) => { return res.status(400).json({ error: 'Invalid timezone' }); } - const [hours, minutes] = atTime.split(':').map(Number); - if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + const [startHours, startMinutes] = atTimeStart.split(':').map(Number); + const [endHours, endMinutes] = atTimeEnd.split(':').map(Number); + + if (isNaN(startHours) || isNaN(startMinutes) || isNaN(endHours) || isNaN(endMinutes) || + startHours < 0 || startHours > 23 || startMinutes < 0 || startMinutes > 59 || + endHours < 0 || endHours > 23 || endMinutes < 0 || endMinutes > 59) { return res.status(400).json({ error: 'Invalid time format' }); } @@ -278,18 +309,19 @@ router.put('/schedule/:id/', requireSignIn, async (req, res) => { let cronExpression; switch (runEveryUnit) { + case 'MINUTES': case 'HOURS': - cronExpression = `${minutes} */${runEvery} * * *`; + cronExpression = `${startMinutes}-${endMinutes} */${runEvery} * * *`; break; case 'DAYS': - cronExpression = `${minutes} ${hours} */${runEvery} * *`; + cronExpression = `${startMinutes} ${startHours} */${runEvery} * *`; break; case 'WEEKS': const dayIndex = days.indexOf(startFrom); - cronExpression = `${minutes} ${hours} * * ${dayIndex}/${7 * runEvery}`; + cronExpression = `${startMinutes} ${startHours} * * ${dayIndex}/${7 * runEvery}`; break; case 'MONTHS': - cronExpression = `${minutes} ${hours} 1-7 */${runEvery} *`; + cronExpression = `${startMinutes} ${startHours} 1-7 */${runEvery} *`; if (startFrom !== 'SUNDAY') { const dayIndex = days.indexOf(startFrom); cronExpression += ` ${dayIndex}`; @@ -304,22 +336,50 @@ router.put('/schedule/:id/', requireSignIn, async (req, res) => { const runId = uuid(); const userId = req.user.id; - // await workflowQueue.add( - // 'run workflow', - // { id, runId, userId }, - // { - // repeat: { - // pattern: cronExpression, - // tz: timezone - // } - // } - // ); + // Remove existing jobs for this robot just in case some were left + // const existingJobs = await workflowQueue.getJobs(['delayed', 'waiting']); + // for (const job of existingJobs) { + // if (job.data.id === id) { + // await job.remove(); + // } + // } + + // Add new job + const job = await workflowQueue.add( + 'run workflow', + { id, runId, userId }, + { + repeat: { + pattern: cronExpression, + tz: timezone + } + } + ); + + const nextRun = job.timestamp; + + // Update robot with schedule details + await robot.update({ + schedule: { + runEvery, + runEveryUnit, + startFrom, + atTimeStart, + atTimeEnd, + timezone, + cronExpression, + lastRunAt: undefined, + nextRunAt: new Date(nextRun) + } + }); + + // Fetch updated schedule details after setting it + const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); res.status(200).json({ message: 'success', runId, - // cronExpression, - // nextRunTime: getNextRunTime(cronExpression, timezone) + robot: updatedRobot }); } catch (error) { @@ -328,12 +388,55 @@ router.put('/schedule/:id/', requireSignIn, async (req, res) => { } }); -// function getNextRunTime(cronExpression, timezone) { -// const schedule = cron.schedule(cronExpression, () => {}, { timezone }); -// const nextDate = schedule.nextDate(); -// schedule.stop(); -// return nextDate.toDate(); -// } +// Endpoint to get schedule details +router.get('/schedule/:id', requireSignIn, async (req, res) => { + try { + const robot = await Robot.findOne({ where: { 'recording_meta.id': req.params.id }, raw: true }); + + if (!robot) { + return res.status(404).json({ error: 'Robot not found' }); + } + + return res.status(200).json({ + schedule: robot.schedule + }); + + } catch (error) { + console.error('Error getting schedule:', error); + res.status(500).json({ error: 'Failed to get schedule' }); + } +}); + +// Endpoint to delete schedule +router.delete('/schedule/:id', requireSignIn, async (req, res) => { + try { + const { id } = req.params; + + const robot = await Robot.findOne({ where: { 'recording_meta.id': id } }); + if (!robot) { + return res.status(404).json({ error: 'Robot not found' }); + } + + // Remove existing job from queue if it exists + const existingJobs = await workflowQueue.getJobs(['delayed', 'waiting']); + for (const job of existingJobs) { + if (job.data.id === id) { + await job.remove(); + } + } + + // Delete the schedule from the robot + await robot.update({ + schedule: null + }); + + res.status(200).json({ message: 'Schedule deleted successfully' }); + + } catch (error) { + console.error('Error deleting schedule:', error); + res.status(500).json({ error: 'Failed to delete schedule' }); + } +}); /** * POST endpoint for aborting a current interpretation of the run. diff --git a/server/src/server.ts b/server/src/server.ts index eb435345..25660e11 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -58,16 +58,19 @@ readdirSync(path.join(__dirname, 'api')).forEach((r) => { } }); -const workerProcess = fork(path.resolve(__dirname, './worker.ts')); - workerProcess.on('message', (message) => { - console.log(`Message from worker: ${message}`); - }); +const workerProcess = fork(path.resolve(__dirname, './worker.ts'), [], { + execArgv: ['--inspect=5859'], // Specify a different debug port for the worker +}); + +workerProcess.on('message', (message) => { + console.log(`Message from worker: ${message}`); +}); workerProcess.on('error', (error) => { - console.error(`Error in worker: ${error}`); - }); - workerProcess.on('exit', (code) => { + console.error(`Error in worker: ${error}`); +}); +workerProcess.on('exit', (code) => { console.log(`Worker exited with code: ${code}`); - }); +}); app.get('/', function (req, res) { return res.send('Maxun server started 🚀'); @@ -81,6 +84,6 @@ server.listen(SERVER_PORT, async () => { process.on('SIGINT', () => { console.log('Main app shutting down...'); - //workerProcess.kill(); + workerProcess.kill(); process.exit(); }); diff --git a/server/src/worker.ts b/server/src/worker.ts index baef2387..f471470f 100644 --- a/server/src/worker.ts +++ b/server/src/worker.ts @@ -52,4 +52,8 @@ process.on('SIGINT', () => { process.exit(); }); -export { workflowQueue, worker }; \ No newline at end of file +export { workflowQueue, worker }; + +export const temp = () => { + console.log('temp'); +} \ No newline at end of file diff --git a/src/api/storage.ts b/src/api/storage.ts index 98e4a3b4..da017027 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -12,7 +12,7 @@ export const getStoredRecordings = async (): Promise => { } else { throw new Error('Couldn\'t retrieve stored recordings'); } - } catch(error: any) { + } catch (error: any) { console.log(error); return null; } @@ -26,7 +26,7 @@ export const getStoredRuns = async (): Promise => { } else { throw new Error('Couldn\'t retrieve stored recordings'); } - } catch(error: any) { + } catch (error: any) { console.log(error); return null; } @@ -40,7 +40,7 @@ export const getStoredRecording = async (id: string) => { } else { throw new Error(`Couldn't retrieve stored recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return null; } @@ -54,7 +54,7 @@ export const deleteRecordingFromStorage = async (id: string): Promise = } else { throw new Error(`Couldn't delete stored recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return false; } @@ -68,7 +68,7 @@ export const deleteRunFromStorage = async (id: string): Promise => { } else { throw new Error(`Couldn't delete stored recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return false; } @@ -82,7 +82,7 @@ export const editRecordingFromStorage = async (browserId: string, id: string): P } else { throw new Error(`Couldn't edit stored recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return null; } @@ -92,15 +92,15 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti try { const response = await axios.put( `http://localhost:8080/storage/runs/${id}`, - {...settings}); + { ...settings }); if (response.status === 200) { return response.data; } else { throw new Error(`Couldn't create a run for a recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); - return {browserId: '', runId: ''}; + return { browserId: '', runId: '' }; } } @@ -112,13 +112,13 @@ export const interpretStoredRecording = async (id: string): Promise => } else { throw new Error(`Couldn't run a recording ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return false; } } -export const notifyAboutAbort = async (id:string): Promise => { +export const notifyAboutAbort = async (id: string): Promise => { try { const response = await axios.post(`http://localhost:8080/storage/runs/abort/${id}`); if (response.status === 200) { @@ -126,7 +126,7 @@ export const notifyAboutAbort = async (id:string): Promise => { } else { throw new Error(`Couldn't abort a running recording with id ${id}`); } - } catch(error: any) { + } catch (error: any) { console.log(error); return false; } @@ -136,14 +136,42 @@ export const scheduleStoredRecording = async (id: string, settings: ScheduleSett try { const response = await axios.put( `http://localhost:8080/storage/schedule/${id}`, - {...settings}); + { ...settings }); if (response.status === 200) { return response.data; } else { throw new Error(`Couldn't schedule recording ${id}. Please try again later.`); } - } catch(error: any) { + } catch (error: any) { console.log(error); - return {message: '', runId: ''}; + return { message: '', runId: '' }; } } + +export const getSchedule = async (id: string) => { + try { + const response = await axios.get(`http://localhost:8080/storage/schedule/${id}`); + if (response.status === 200) { + return response.data.schedule; + } else { + throw new Error(`Couldn't retrieve schedule for recording ${id}`); + } + } catch (error: any) { + console.log(error); + return null; + } +} + +export const deleteSchedule = async (id: string): Promise => { + try { + const response = await axios.delete(`http://localhost:8080/storage/schedule/${id}`); + if (response.status === 200) { + return response.data; + } else { + throw new Error(`Couldn't delete schedule for recording ${id}`); + } + } catch (error: any) { + console.log(error); + return false; + } +} \ No newline at end of file diff --git a/src/components/molecules/ScheduleSettings.tsx b/src/components/molecules/ScheduleSettings.tsx index ef300a91..1174b4dc 100644 --- a/src/components/molecules/ScheduleSettings.tsx +++ b/src/components/molecules/ScheduleSettings.tsx @@ -1,40 +1,51 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { GenericModal } from "../atoms/GenericModal"; -import { MenuItem, TextField, Typography, Box } from "@mui/material"; +import { MenuItem, TextField, Typography, Box, Switch, FormControlLabel } from "@mui/material"; import { Dropdown } from "../atoms/DropdownMui"; import Button from "@mui/material/Button"; import { modalStyle } from "./AddWhereCondModal"; import { validMomentTimezones } from '../../constants/const'; +import { useGlobalInfoStore } from '../../context/globalInfo'; +import { getSchedule, deleteSchedule } from '../../api/storage'; interface ScheduleSettingsProps { isOpen: boolean; handleStart: (settings: ScheduleSettings) => void; handleClose: () => void; + initialSettings?: ScheduleSettings | null; } export interface ScheduleSettings { runEvery: number; runEveryUnit: string; startFrom: string; - atTime: string; + atTimeStart?: string; + atTimeEnd?: string; timezone: string; } -export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose }: ScheduleSettingsProps) => { +export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => { + const [schedule, setSchedule] = useState(null); const [settings, setSettings] = useState({ runEvery: 1, runEveryUnit: 'HOURS', startFrom: 'MONDAY', - atTime: '00:00', + atTimeStart: '00:00', + atTimeEnd: '01:00', timezone: 'UTC' }); - const handleChange = (field: keyof ScheduleSettings, value: string | number) => { + // Load initial settings if provided + useEffect(() => { + if (initialSettings) { + setSettings(initialSettings); + } + }, [initialSettings]); + + const handleChange = (field: keyof ScheduleSettings, value: string | number | boolean) => { setSettings(prev => ({ ...prev, [field]: value })); }; - console.log(`Settings:`, settings); - const textStyle = { width: '150px', height: '52px', @@ -49,11 +60,12 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose }: Sche }; const units = [ + 'MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS' - ] + ]; const days = [ 'MONDAY', @@ -63,7 +75,48 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose }: Sche 'FRIDAY', 'SATURDAY', 'SUNDAY' - ] + ]; + + const { recordingId } = useGlobalInfoStore(); + + console.log(`Recoridng ID Shculde: ${recordingId}`); + + const deleteRobotSchedule = () => { + if (recordingId) { + deleteSchedule(recordingId); + setSchedule(null); + } else { + console.error('No recording id provided'); + } + + setSettings({ + runEvery: 1, + runEveryUnit: 'HOURS', + startFrom: 'MONDAY', + atTimeStart: '00:00', + atTimeEnd: '01:00', + timezone: 'UTC' + }); + }; + + const getRobotSchedule = async () => { + if (recordingId) { + const scheduleData = await getSchedule(recordingId); + console.log(`Robot found schedule: ${JSON.stringify(scheduleData, null, 2)}`); + setSchedule(scheduleData); + } else { + console.error('No recording id provided'); + } + } + + useEffect(() => { + if (isOpen) { + const fetchSchedule = async () => { + await getRobotSchedule(); + }; + fetchSchedule(); + } + }, [isOpen]); return ( *': { marginBottom: '20px' }, }}> Schedule Settings + <> + { + (schedule !== null) ? ( + <> + Robot is scheduled to run every {schedule.runEvery} {schedule.runEveryUnit} starting from {schedule.startFrom} at {schedule.atTimeStart} to {schedule.atTimeEnd} in {schedule.timezone} timezone. + + + + + ) : ( + <> + + Run once every + handleChange('runEvery', parseInt(e.target.value))} + sx={textStyle} + inputProps={{ min: 1 }} + /> + handleChange('runEveryUnit', e.target.value)} + sx={dropDownStyle} + > + {units.map((unit) => ( + {unit} + ))} + + - - Run once every - handleChange('runEvery', parseInt(e.target.value))} - sx={textStyle} - inputProps={{ min: 1 }} - /> - handleChange('runEveryUnit', e.target.value)} - sx={dropDownStyle} - > - {units.map((unit) => ( - {unit} - ))} - - + + Start from / On + handleChange('startFrom', e.target.value)} + sx={dropDownStyle} + > + {days.map((day) => ( + {day} + ))} + + - - Start from / On - handleChange('startFrom', e.target.value)} - sx={dropDownStyle} - > - {days.map((day) => ( - {day} - ))} - - + {['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? ( + + + In Between + handleChange('atTimeStart', e.target.value)} + sx={textStyle} + /> + handleChange('atTimeEnd', e.target.value)} + sx={textStyle} + /> + + + ) : ( + + At Around + handleChange('atTimeStart', e.target.value)} + sx={textStyle} + /> + + )} - - - At around - handleChange('atTime', e.target.value)} - sx={textStyle} - /> - - - Timezone - handleChange('timezone', e.target.value)} - sx={dropDownStyle} - > - {validMomentTimezones.map((tz) => ( - {tz} - ))} - - - - - + + Timezone + handleChange('timezone', e.target.value)} + sx={dropDownStyle} + > + {validMomentTimezones.map((tz) => ( + {tz} + ))} + + + + + + + + ) + } + ); -} - -export default ScheduleSettingsModal; \ No newline at end of file +};