diff --git a/package.json b/package.json index 3bd306a2..addce564 100644 --- a/package.json +++ b/package.json @@ -21,21 +21,26 @@ "@wbr-project/wbr-interpret": "^0.9.3-marketa.1", "axios": "^0.26.0", "buffer": "^6.0.3", + "bullmq": "^5.12.15", "cors": "^2.8.5", "cross-fetch": "^4.0.0", "dotenv": "^16.0.0", "express": "^4.17.2", "fortawesome": "^0.0.1-security", + "ioredis": "^5.4.1", "joi": "^17.6.0", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", - "playwright": "^1.18.1", + "moment-timezone": "^0.5.45", + "node-cron": "^3.0.3", + "playwright": "^1.20.1", "playwright-extra": "^4.3.6", "prismjs": "^1.28.0", "puppeteer-extra-plugin-stealth": "^2.11.2", "react": "^18.0.0", "react-dom": "^18.0.0", "react-highlight": "^0.14.0", + "react-router-dom": "^6.26.1", "react-scripts": "5.0.1", "react-simple-code-editor": "^0.11.2", "react-transition-group": "^4.4.2", @@ -80,10 +85,12 @@ "@types/express": "^4.17.13", "@types/loglevel": "^1.6.3", "@types/node": "^17.0.15", + "@types/node-cron": "^3.0.11", "@types/prismjs": "^1.26.0", "@types/react-highlight": "^0.12.5", "@types/react-transition-group": "^4.4.4", "@types/styled-components": "^5.1.23", + "ajv": "^8.8.2", "concurrently": "^7.0.0", "nodemon": "^2.0.15", "react-app-rewired": "^2.2.1", diff --git a/server/src/index.ts b/server/src/index.ts index 46ce10e3..da7c3ece 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -11,3 +11,4 @@ export * from "./workflow-management/storage"; export * from "./workflow-management/utils"; export * from "./workflow-management/classes/Interpreter"; export * from "./workflow-management/classes/Generator"; +export * from "./workflow-management/scheduler"; diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index beed25bb..08be5b7c 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -10,6 +10,9 @@ import { chromium } from "playwright"; import { browserPool } from "../server"; import fs from "fs"; import { uuid } from "uuidv4"; +import { workflowQueue } from '../workflow-management/scheduler'; +import moment from 'moment-timezone'; +import cron from 'node-cron'; export const router = Router(); @@ -106,6 +109,9 @@ router.put('/runs/:fileName', async (req, res) => { JSON.stringify({ ...run_meta }, null, 2) ); logger.log('debug', `Created run with name: ${req.params.fileName}.json`); + + console.log('Run meta:', run_meta); + return res.send({ browserId: id, runId: runId, @@ -188,6 +194,98 @@ router.post('/runs/run/:fileName/:runId', async (req, res) => { } }); +router.put('/schedule/:fileName/', async (req, res) => { + console.log(req.body); + try { + const { fileName } = req.params; + const { + runEvery, + runEveryUnit, + startFrom, + atTime, + timezone + } = req.body; + + if (!fileName || !runEvery || !runEveryUnit || !startFrom || !atTime || !timezone) { + return res.status(400).json({ error: 'Missing required parameters' }); + } + + if (!['HOURS', 'DAYS', 'WEEKS', 'MONTHS'].includes(runEveryUnit)) { + return res.status(400).json({ error: 'Invalid runEvery unit' }); + } + + if (!moment.tz.zone(timezone)) { + 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) { + return res.status(400).json({ error: 'Invalid time format' }); + } + + const days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; + if (!days.includes(startFrom)) { + return res.status(400).json({ error: 'Invalid start day' }); + } + + let cronExpression; + switch (runEveryUnit) { + case 'HOURS': + cronExpression = `${minutes} */${runEvery} * * *`; + break; + case 'DAYS': + cronExpression = `${minutes} ${hours} */${runEvery} * *`; + break; + case 'WEEKS': + const dayIndex = days.indexOf(startFrom); + cronExpression = `${minutes} ${hours} * * ${dayIndex}/${7 * runEvery}`; + break; + case 'MONTHS': + cronExpression = `${minutes} ${hours} 1-7 */${runEvery} *`; + if (startFrom !== 'SUNDAY') { + const dayIndex = days.indexOf(startFrom); + cronExpression += ` ${dayIndex}`; + } + break; + } + + if (!cronExpression || !cron.validate(cronExpression)) { + return res.status(400).json({ error: 'Invalid cron expression generated' }); + } + + const runId = uuid(); + + await workflowQueue.add( + 'run workflow', + { fileName, runId }, + { + repeat: { + pattern: cronExpression, + tz: timezone + } + } + ); + + res.status(200).json({ + message: 'success', + runId, + // cronExpression, + // nextRunTime: getNextRunTime(cronExpression, timezone) + }); + + } catch (error) { + console.error('Error scheduling workflow:', error); + res.status(500).json({ error: 'Failed to schedule workflow' }); + } +}); + +// function getNextRunTime(cronExpression, timezone) { +// const schedule = cron.schedule(cronExpression, () => {}, { timezone }); +// const nextDate = schedule.nextDate(); +// schedule.stop(); +// return nextDate.toDate(); +// } + /** * POST endpoint for aborting a current interpretation of the run. */ diff --git a/server/src/server.ts b/server/src/server.ts index 6b34de08..a81a216c 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -7,7 +7,8 @@ import { record, workflow, storage } from './routes'; import { BrowserPool } from "./browser-management/classes/BrowserPool"; import logger from './logger' import { SERVER_PORT } from "./constants/config"; -import {Server} from "socket.io"; +import { Server } from "socket.io"; +import { worker } from './workflow-management/scheduler'; const app = express(); app.use(cors()); @@ -31,7 +32,7 @@ app.use('/workflow', workflow); app.use('/storage', storage); app.get('/', function (req, res) { - return res.send('Welcome to the BR recorder server :-)'); + return res.send('Maxun server started 🚀'); }); -server.listen(SERVER_PORT, () => logger.log('info',`Server listening on port ${SERVER_PORT}`)); +server.listen(SERVER_PORT, () => logger.log('info', `Server listening on port ${SERVER_PORT}`)); diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index 7819fe1a..f9ae1410 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -482,10 +482,6 @@ export class WorkflowGenerator { */ private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => { const elementInfo = await getElementInformation(page, coordinates); - const generalSelector = await getNonUniqueSelectors(page, coordinates) - const childSelectors = await getChildSelectors(page, generalSelector.generalSelector); - - console.log(`Get List value while generating selector`, this.getList); const selectorBasedOnCustomAction = (this.getList === true) ? await getNonUniqueSelectors(page, coordinates) diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts new file mode 100644 index 00000000..d874ac5b --- /dev/null +++ b/server/src/workflow-management/scheduler/index.ts @@ -0,0 +1,233 @@ +import fs from "fs"; +import { uuid } from "uuidv4"; +import { chromium } from "playwright"; +import { io, Socket } from "socket.io-client"; +import { Queue, Worker } from 'bullmq'; +import IORedis from 'ioredis'; +import { readFile, saveFile } from "../storage"; +import { createRemoteBrowserForRun, destroyRemoteBrowser } from '../../browser-management/controller'; +import logger from '../../logger'; +import { browserPool } from "../../server"; + +const connection = new IORedis({ + host: 'localhost', + port: 6379, + maxRetriesPerRequest: null, +}); + +connection.on('connect', () => { + console.log('Connected to Redis!'); +}); + +connection.on('error', (err) => { + console.error('Redis connection error:', err); +}); + +const workflowQueue = new Queue('workflow', { connection }); + +export const worker = new Worker('workflow', async job => { + const { fileName, runId } = job.data; + try { + const result = await handleRunRecording(fileName, runId); + return result; + } catch (error) { + logger.error('Error running workflow:', error); + throw error; + } +}, { connection }); + +worker.on('completed', async (job: any) => { + logger.log(`info`, `Job ${job.id} completed for ${job.data.fileName}_${job.data.runId}`); + + await worker.close(); + await workflowQueue.close(); + logger.log(`info`, `Worker and queue have been closed.`); +}); + +worker.on('failed', async (job: any, err) => { + logger.log(`error`, `Job ${job.id} failed for ${job.data.fileName}_${job.data.runId}:`, err); + + await worker.close(); + await workflowQueue.close(); + logger.log(`info`, `Worker and queue have been closed after failure.`); +}); + +async function jobCounts () { + const jobCounts = await workflowQueue.getJobCounts(); + console.log('Jobs:', jobCounts); +} + +jobCounts(); + +async function runWorkflow(fileName: string, runId: string) { + if (!runId) { + runId = uuid(); + } + + try { + const browserId = createRemoteBrowserForRun({ + browser: chromium, + launchOptions: { headless: true } + }); + const run_meta = { + status: 'SCHEDULED', + name: fileName, + startedAt: new Date().toLocaleString(), + finishedAt: '', + duration: '', + task: '', // Optionally set based on workflow + browserId: browserId, + interpreterSettings: { maxConcurrency: 1, maxRepeats: 1, debug: true }, + log: '', + runId: runId, + }; + + fs.mkdirSync('../storage/runs', { recursive: true }); + await saveFile( + `../storage/runs/${fileName}_${runId}.json`, + JSON.stringify(run_meta, null, 2) + ); + + logger.log('debug', `Scheduled run with name: ${fileName}_${runId}.json`); + + return { + browserId, + runId + } + + } catch (e) { + const { message } = e as Error; + logger.log('info', `Error while scheduling a run with name: ${fileName}_${runId}.json`); + console.log(message); + return { + success: false, + error: message, + }; + } +} + +async function executeRun(fileName: string, runId: string) { + try { + const recording = await readFile(`./../storage/recordings/${fileName}.waw.json`); + const parsedRecording = JSON.parse(recording); + + const run = await readFile(`./../storage/runs/${fileName}_${runId}.json`); + const parsedRun = JSON.parse(run); + + parsedRun.status = 'RUNNING'; + await saveFile( + `../storage/runs/${fileName}_${runId}.json`, + JSON.stringify(parsedRun, null, 2) + ); + + const browser = browserPool.getRemoteBrowser(parsedRun.browserId); + if (!browser) { + throw new Error('Could not access browser'); + } + + const currentPage = await browser.getCurrentPage(); + if (!currentPage) { + throw new Error('Could not create a new page'); + } + + const interpretationInfo = await browser.interpreter.InterpretRecording( + parsedRecording.recording, currentPage, parsedRun.interpreterSettings); + + const duration = Math.round((new Date().getTime() - new Date(parsedRun.startedAt).getTime()) / 1000); + const durString = duration < 60 ? `${duration} s` : `${Math.floor(duration / 60)} m ${duration % 60} s`; + + await destroyRemoteBrowser(parsedRun.browserId); + + const updated_run_meta = { + ...parsedRun, + status: interpretationInfo.result, + finishedAt: new Date().toLocaleString(), + duration: durString, + browserId: null, + log: interpretationInfo.log.join('\n'), + serializableOutput: interpretationInfo.serializableOutput, + binaryOutput: interpretationInfo.binaryOutput, + }; + + await saveFile( + `../storage/runs/${fileName}_${runId}.json`, + JSON.stringify(updated_run_meta, null, 2) + ); + + return true; + } catch (error: any) { + logger.log('info', `Error while running a recording with name: ${fileName}_${runId}.json`); + console.log(error.message); + + const errorRun = await readFile(`./../storage/runs/${fileName}_${runId}.json`); + const parsedErrorRun = JSON.parse(errorRun); + parsedErrorRun.status = 'ERROR'; + parsedErrorRun.log += `\nError: ${error.message}`; + await saveFile( + `../storage/runs/${fileName}_${runId}.json`, + JSON.stringify(parsedErrorRun, null, 2) + ); + + return false; + } +} + +async function readyForRunHandler(browserId: string, fileName: string, runId: string) { + try { + const interpretation = await executeRun(fileName, runId); + + if (interpretation) { + logger.log('info', `Interpretation of ${fileName} succeeded`); + } else { + logger.log('error', `Interpretation of ${fileName} failed`); + await destroyRemoteBrowser(browserId); + } + + resetRecordingState(browserId, fileName, runId); + + } catch (error: any) { + logger.error(`Error during readyForRunHandler: ${error.message}`); + await destroyRemoteBrowser(browserId); + } +} + +function resetRecordingState(browserId: string, fileName: string, runId: string) { + browserId = ''; + fileName = ''; + runId = ''; + logger.log(`info`, `reset values for ${browserId}, ${fileName}, and ${runId}`); +} + +async function handleRunRecording(fileName: string, runId: string) { + try { + const result = await runWorkflow(fileName, runId); + const { browserId, runId: newRunId } = result; + + if (!browserId || !newRunId) { + throw new Error('browserId or runId is undefined'); + } + + const socket = io(`http://localhost:8080/${browserId}`, { + transports: ['websocket'], + rejectUnauthorized: false + }); + + socket.on('ready-for-run', () => readyForRunHandler(browserId, fileName, newRunId)); + + logger.log('info', `Running recording: ${fileName}`); + + socket.on('disconnect', () => { + cleanupSocketListeners(socket, browserId, newRunId); + }); + + } catch (error: any) { + logger.error('Error running recording:', error); + } +} + +function cleanupSocketListeners(socket: Socket, browserId: string, runId: string) { + socket.off('ready-for-run', () => readyForRunHandler(browserId, '', runId)); + logger.log('info', `Cleaned up listeners for browserId: ${browserId}, runId: ${runId}`); +} + +export { workflowQueue, runWorkflow }; \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 7f0715a9..c6458fdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,14 @@ import React from 'react'; - +import { Routes, Route } from 'react-router-dom'; import { GlobalInfoProvider } from "./context/globalInfo"; import { PageWrapper } from "./pages/PageWrappper"; function App() { - return ( - + + } /> + ); } diff --git a/src/api/storage.ts b/src/api/storage.ts index 93e99212..913c7d85 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -1,7 +1,8 @@ import { default as axios } from "axios"; import { WorkflowFile } from "maxun-core"; import { RunSettings } from "../components/molecules/RunSettings"; -import { CreateRunResponse } from "../pages/MainPage"; +import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; +import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; export const getStoredRecordings = async (): Promise => { try { @@ -117,4 +118,18 @@ export const notifyAboutAbort = async (fileName: string, runId:string): Promise< } } - +export const scheduleStoredRecording = async (fileName: string, settings: ScheduleSettings): Promise => { + try { + const response = await axios.put( + `http://localhost:8080/storage/schedule/${fileName}`, + {...settings}); + if (response.status === 200) { + return response.data; + } else { + throw new Error(`Couldn't schedule recording ${fileName}. Please try again later.`); + } + } catch(error: any) { + console.log(error); + return {message: '', runId: ''}; + } +} diff --git a/src/components/atoms/DropdownMui.tsx b/src/components/atoms/DropdownMui.tsx index ff97ff71..c7b3e59f 100644 --- a/src/components/atoms/DropdownMui.tsx +++ b/src/components/atoms/DropdownMui.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FormControl, InputLabel, Select } from "@mui/material"; import { SelectChangeEvent } from "@mui/material/Select/Select"; +import { SxProps } from '@mui/system'; interface DropdownProps { id: string; @@ -8,11 +9,12 @@ interface DropdownProps { value: string | undefined; handleSelect: (event: SelectChangeEvent) => void; children?: React.ReactNode; + sx?: SxProps; }; -export const Dropdown = ({ id, label, value, handleSelect, children }: DropdownProps) => { +export const Dropdown = ({ id, label, value, handleSelect, children, sx }: DropdownProps) => { return ( - + {label} diff --git a/src/components/atoms/GenericModal.tsx b/src/components/atoms/GenericModal.tsx index 549d2f42..bb6dc77f 100644 --- a/src/components/atoms/GenericModal.tsx +++ b/src/components/atoms/GenericModal.tsx @@ -33,7 +33,7 @@ const defaultModalStyle = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', - width: 500, + width: '50%', bgcolor: 'background.paper', boxShadow: 24, p: 4, diff --git a/src/components/atoms/canvas.tsx b/src/components/atoms/canvas.tsx index 3e9d89c0..654c782a 100644 --- a/src/components/atoms/canvas.tsx +++ b/src/components/atoms/canvas.tsx @@ -53,10 +53,11 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { switch (event.type) { case 'mousedown': const clickCoordinates = getMappedCoordinates(event, canvasRef.current, width, height); - if (getTextRef.current === true || getListRef.current === true) { - // todo: remove console.log and return - console.log('get text or get list is true'); - } else { + if (getTextRef.current === true) { + console.log('Capturing Text...'); + } else if (getListRef.current === true){ + console.log('Capturing List...'); + }else { socket.emit('input:mousedown', clickCoordinates); } notifyLastAction('click'); diff --git a/src/components/molecules/NavBar.tsx b/src/components/molecules/NavBar.tsx index 640f09cb..15508942 100644 --- a/src/components/molecules/NavBar.tsx +++ b/src/components/molecules/NavBar.tsx @@ -7,6 +7,7 @@ import { RecordingIcon } from "../atoms/RecorderIcon"; import { SaveRecording } from "./SaveRecording"; import { Circle } from "@mui/icons-material"; import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; +import { useNavigate } from "react-router-dom"; interface NavBarProps { newRecording: () => void; @@ -14,18 +15,20 @@ interface NavBarProps { isRecording: boolean; } -export const NavBar = ({newRecording, recordingName, isRecording}:NavBarProps) => { +export const NavBar = ({ newRecording, recordingName, isRecording }: NavBarProps) => { const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore(); + const navigate = useNavigate(); // If recording is in progress, the resources and change page view by setting browserId to null // else it won't affect the page - const goToMainMenu = async() => { + const goToMainMenu = async () => { if (browserId) { await stopRecording(browserId); notify('warning', 'Current Recording was terminated'); setBrowserId(null); } + navigate('/'); }; const handleNewRecording = async () => { @@ -43,8 +46,8 @@ export const NavBar = ({newRecording, recordingName, isRecording}:NavBarProps) = display: 'flex', justifyContent: 'flex-start', }}> - -
Maxun
+ +
Maxun
- {isRecording ? 'NEW' : 'RECORD'} + {isRecording ? 'NEW' : 'RECORD'} { recordingLength > 0 - ? - :null + ? + : null } - { isRecording ? - : null } + : null}
diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index ee510761..429b8234 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -10,12 +10,12 @@ import TableRow from '@mui/material/TableRow'; import { useEffect } from "react"; import { WorkflowFile } from "maxun-core"; import { IconButton } from "@mui/material"; -import { Assignment, DeleteForever, Edit, PlayCircle } from "@mui/icons-material"; +import { Schedule, DeleteForever, Edit, PlayCircle } from "@mui/icons-material"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; interface Column { - id: 'interpret' | 'name' | 'create_date' | 'edit' | 'pairs' | 'update_date'| 'delete'; + id: 'interpret' | 'name' | 'create_date' | 'edit' | 'pairs' | 'update_date' | 'delete' | 'schedule'; label: string; minWidth?: number; align?: 'right'; @@ -41,6 +41,11 @@ const columns: readonly Column[] = [ label: 'Pairs', minWidth: 80, }, + { + id: 'schedule', + label: 'Schedule', + minWidth: 80, + }, { id: 'update_date', label: 'Updated at', @@ -65,11 +70,12 @@ interface Data { } interface RecordingsTableProps { - handleEditRecording: (fileName:string) => void; - handleRunRecording: (fileName:string, params: string[]) => void; + handleEditRecording: (fileName: string) => void; + handleRunRecording: (fileName: string, params: string[]) => void; + handleScheduleRecording: (fileName: string, params: string[]) => void; } -export const RecordingsTable = ({ handleEditRecording, handleRunRecording }: RecordingsTableProps) => { +export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording }: RecordingsTableProps) => { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rows, setRows] = React.useState([]); @@ -106,7 +112,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording }: Rec } } - useEffect( () => { + useEffect(() => { if (rows.length === 0) { fetchRecordings(); } @@ -138,7 +144,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording }: Rec {columns.map((column) => { // @ts-ignore - const value : any = row[column.id]; + const value: any = row[column.id]; if (value !== undefined) { return ( @@ -150,23 +156,29 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording }: Rec case 'interpret': return ( - handleRunRecording(row.name, row.params || [])}/> + handleRunRecording(row.name, row.params || [])} /> ); case 'edit': return ( - { + { handleEditRecording(row.name); - }} sx={{'&:hover': { color: '#1976d2', backgroundColor: 'transparent' }}}> - + }} sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + ); + case 'schedule': + return ( + + handleScheduleRecording(row.name, row.params || [])} /> + + ); case 'delete': return ( - { + { deleteRecordingFromStorage(row.name).then((result: boolean) => { if (result) { setRows([]); @@ -174,20 +186,20 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording }: Rec fetchRecordings(); } }) - }} sx={{'&:hover': { color: '#1976d2', backgroundColor: 'transparent' }}}> - + }} sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + ); default: - return null; + return null; } } })} ); }) - : null } + : null} @@ -208,13 +220,29 @@ interface InterpretButtonProps { handleInterpret: () => void; } -const InterpretButton = ( {handleInterpret}:InterpretButtonProps) => { +const InterpretButton = ({ handleInterpret }: InterpretButtonProps) => { return ( - { + { handleInterpret(); }} - sx={{'&:hover': { color: '#1976d2', backgroundColor: 'transparent' }}}> - + sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + ) } + + +interface ScheduleButtonProps { + handleSchedule: () => void; +} + +const ScheduleButton = ({ handleSchedule }: ScheduleButtonProps) => { + return ( + { + handleSchedule(); + }} + sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + + + ) +} \ No newline at end of file diff --git a/src/components/molecules/RunSettings.tsx b/src/components/molecules/RunSettings.tsx index 45a35c24..a12c2e87 100644 --- a/src/components/molecules/RunSettings.tsx +++ b/src/components/molecules/RunSettings.tsx @@ -40,32 +40,32 @@ export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, par alignItems: 'flex-start', marginLeft: '65px', }}> - { isTask + {isTask ? ( - Recording parameters: - { params?.map((item, index) => { - return setSettings( - { - ...settings, - params: settings.params - ? { - ...settings.params, - [item]: e.target.value, - } - : { - [item]: e.target.value, - }, - })} - /> - }) } + Recording parameters: + {params?.map((item, index) => { + return setSettings( + { + ...settings, + params: settings.params + ? { + ...settings.params, + [item]: e.target.value, + } + : { + [item]: e.target.value, + }, + })} + /> + })} ) : null } diff --git a/src/components/molecules/RunsTable.tsx b/src/components/molecules/RunsTable.tsx index d6489cff..578f8140 100644 --- a/src/components/molecules/RunsTable.tsx +++ b/src/components/molecules/RunsTable.tsx @@ -12,6 +12,8 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { getStoredRuns } from "../../api/storage"; import { RunSettings } from "./RunSettings"; import { CollapsibleRow } from "./ColapsibleRow"; +import { Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; interface Column { id: 'status' | 'name' | 'startedAt' | 'finishedAt' | 'duration' | 'task' | 'runId' | 'delete'; @@ -86,68 +88,81 @@ export const RunsTable = ( } else { console.log('No runs found.'); } - } + }; - useEffect( () => { + useEffect(() => { if (rows.length === 0 || rerenderRuns) { fetchRuns(); setRerenderRuns(false); } - }, [rerenderRuns]); - const handleDelete = () => { setRows([]); notify('success', 'Run deleted successfully'); fetchRuns(); - } + }; + + // Group runs by recording name + const groupedRows = rows.reduce((acc, row) => { + if (!acc[row.name]) { + acc[row.name] = []; + } + acc[row.name].push(row); + return acc; + }, {} as Record); return ( - - - - - {columns.map((column) => ( - - {column.label} - - ))} - - - - {rows.length !== 0 ? rows - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((row, index) => - - ) - : null } - -
+ {Object.entries(groupedRows).map(([name, group]) => ( + + }> + {name} + + + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {group.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row) => ( + + ))} + +
+
+
+ ))}
-
+ ); -} +}; diff --git a/src/components/molecules/SaveRecording.tsx b/src/components/molecules/SaveRecording.tsx index b4e175bb..05ff43af 100644 --- a/src/components/molecules/SaveRecording.tsx +++ b/src/components/molecules/SaveRecording.tsx @@ -8,20 +8,22 @@ import { TextField, Typography } from "@mui/material"; import { WarningText } from "../atoms/texts"; import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; import FlagIcon from '@mui/icons-material/Flag'; +import { useNavigate } from 'react-router-dom'; interface SaveRecordingProps { fileName: string; } -export const SaveRecording = ({fileName}: SaveRecordingProps) => { +export const SaveRecording = ({ fileName }: SaveRecordingProps) => { const [openModal, setOpenModal] = useState(false); const [needConfirm, setNeedConfirm] = useState(false); const [recordingName, setRecordingName] = useState(fileName); const [waitingForSave, setWaitingForSave] = useState(false); - const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); + const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); const { socket } = useSocketStore(); + const navigate = useNavigate(); const handleChangeOfTitle = (event: React.ChangeEvent) => { const { value } = event.target; @@ -41,12 +43,13 @@ export const SaveRecording = ({fileName}: SaveRecordingProps) => { } }; - const exitRecording = useCallback(async() => { + const exitRecording = useCallback(async () => { notify('success', 'Recording saved successfully'); if (browserId) { await stopRecording(browserId); } setBrowserId(null); + navigate('/'); }, [setBrowserId, browserId, notify]); // notifies backed to save the recording in progress, @@ -66,45 +69,45 @@ export const SaveRecording = ({fileName}: SaveRecordingProps) => { return (
setOpenModal(false)} modalStyle={modalStyle}> -
+ Save the recording as: - { needConfirm - ? - ( - - - - Recording already exists, please confirm the recording's overwrite. - - ) - : - } - { waitingForSave && - - - - - + {needConfirm + ? + ( + + + + Recording already exists, please confirm the recording's overwrite. + + ) + : + } + {waitingForSave && + + + + + }
@@ -119,7 +122,7 @@ const modalStyle = { width: '20%', backgroundColor: 'background.paper', p: 4, - height:'fit-content', - display:'block', + height: 'fit-content', + display: 'block', padding: '20px', }; diff --git a/src/components/molecules/ScheduleSettings.tsx b/src/components/molecules/ScheduleSettings.tsx new file mode 100644 index 00000000..ef300a91 --- /dev/null +++ b/src/components/molecules/ScheduleSettings.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react'; +import { GenericModal } from "../atoms/GenericModal"; +import { MenuItem, TextField, Typography, Box } from "@mui/material"; +import { Dropdown } from "../atoms/DropdownMui"; +import Button from "@mui/material/Button"; +import { modalStyle } from "./AddWhereCondModal"; +import { validMomentTimezones } from '../../constants/const'; + +interface ScheduleSettingsProps { + isOpen: boolean; + handleStart: (settings: ScheduleSettings) => void; + handleClose: () => void; +} + +export interface ScheduleSettings { + runEvery: number; + runEveryUnit: string; + startFrom: string; + atTime: string; + timezone: string; +} + +export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose }: ScheduleSettingsProps) => { + const [settings, setSettings] = useState({ + runEvery: 1, + runEveryUnit: 'HOURS', + startFrom: 'MONDAY', + atTime: '00:00', + timezone: 'UTC' + }); + + const handleChange = (field: keyof ScheduleSettings, value: string | number) => { + setSettings(prev => ({ ...prev, [field]: value })); + }; + + console.log(`Settings:`, settings); + + const textStyle = { + width: '150px', + height: '52px', + marginRight: '10px', + }; + + const dropDownStyle = { + marginTop: '2px', + width: '150px', + height: '59px', + marginRight: '10px', + }; + + const units = [ + 'HOURS', + 'DAYS', + 'WEEKS', + 'MONTHS' + ] + + const days = [ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY' + ] + + return ( + + *': { marginBottom: '20px' }, + }}> + Schedule Settings + + + 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} + ))} + + + + + + At around + handleChange('atTime', e.target.value)} + sx={textStyle} + /> + + + Timezone + handleChange('timezone', e.target.value)} + sx={dropDownStyle} + > + {validMomentTimezones.map((tz) => ( + {tz} + ))} + + + + + + + + ); +} + +export default ScheduleSettingsModal; \ No newline at end of file diff --git a/src/components/organisms/Recordings.tsx b/src/components/organisms/Recordings.tsx index ae993956..d5fb6bc5 100644 --- a/src/components/organisms/Recordings.tsx +++ b/src/components/organisms/Recordings.tsx @@ -2,16 +2,18 @@ import React, { useState } from 'react'; import { RecordingsTable } from "../molecules/RecordingsTable"; import { Grid } from "@mui/material"; import { RunSettings, RunSettingsModal } from "../molecules/RunSettings"; +import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings"; interface RecordingsProps { handleEditRecording: (fileName: string) => void; handleRunRecording: (settings: RunSettings) => void; + handleScheduleRecording: (settings: ScheduleSettings) => void; setFileName: (fileName: string) => void; - } -export const Recordings = ({ handleEditRecording, handleRunRecording, setFileName }: RecordingsProps) => { +export const Recordings = ({ handleEditRecording, handleRunRecording, setFileName, handleScheduleRecording }: RecordingsProps) => { const [runSettingsAreOpen, setRunSettingsAreOpen] = useState(false); + const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false); const [params, setParams] = useState([]); const handleSettingsAndRun = (fileName: string, params: string[]) => { @@ -25,28 +27,50 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setFileNam } } + const handleSettingsAndSchedule = (fileName: string, params: string[]) => { + if (params.length === 0) { + setScheduleSettingsAreOpen(true); + setFileName(fileName); + } else { + setParams(params); + setScheduleSettingsAreOpen(true); + setFileName(fileName); + } + } + const handleClose = () => { setParams([]); setRunSettingsAreOpen(false); setFileName(''); } + const handleScheduleClose = () => { + setParams([]); + setScheduleSettingsAreOpen(false); + setFileName(''); + } + return ( - handleRunRecording(settings) } - isTask={params.length !== 0} - params={params} - /> - - - + handleRunRecording(settings)} + isTask={params.length !== 0} + params={params} + /> + handleScheduleRecording(settings)} + /> + + + + - ); } diff --git a/src/constants/const.ts b/src/constants/const.ts index 8aec049e..ef213ec6 100644 --- a/src/constants/const.ts +++ b/src/constants/const.ts @@ -3,3 +3,598 @@ export const VIEWPORT_H = 720; export const ONE_PERCENT_OF_VIEWPORT_W = VIEWPORT_W / 100; export const ONE_PERCENT_OF_VIEWPORT_H = VIEWPORT_H / 100; + +export const validMomentTimezones: string[] = [ + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Asmara', + 'Africa/Asmera', + 'Africa/Bamako', + 'Africa/Bangui', + 'Africa/Banjul', + 'Africa/Bissau', + 'Africa/Blantyre', + 'Africa/Brazzaville', + 'Africa/Bujumbura', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Ceuta', + 'Africa/Conakry', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Djibouti', + 'Africa/Douala', + 'Africa/El_Aaiun', + 'Africa/Freetown', + 'Africa/Gaborone', + 'Africa/Harare', + 'Africa/Johannesburg', + 'Africa/Juba', + 'Africa/Kampala', + 'Africa/Khartoum', + 'Africa/Kigali', + 'Africa/Kinshasa', + 'Africa/Lagos', + 'Africa/Libreville', + 'Africa/Lome', + 'Africa/Luanda', + 'Africa/Lubumbashi', + 'Africa/Lusaka', + 'Africa/Malabo', + 'Africa/Maputo', + 'Africa/Maseru', + 'Africa/Mbabane', + 'Africa/Mogadishu', + 'Africa/Monrovia', + 'Africa/Nairobi', + 'Africa/Ndjamena', + 'Africa/Niamey', + 'Africa/Nouakchott', + 'Africa/Ouagadougou', + 'Africa/Porto-Novo', + 'Africa/Sao_Tome', + 'Africa/Timbuktu', + 'Africa/Tripoli', + 'Africa/Tunis', + 'Africa/Windhoek', + 'America/Adak', + 'America/Anchorage', + 'America/Anguilla', + 'America/Antigua', + 'America/Araguaina', + 'America/Argentina/Buenos_Aires', + 'America/Argentina/Catamarca', + 'America/Argentina/ComodRivadavia', + 'America/Argentina/Cordoba', + 'America/Argentina/Jujuy', + 'America/Argentina/La_Rioja', + 'America/Argentina/Mendoza', + 'America/Argentina/Rio_Gallegos', + 'America/Argentina/Salta', + 'America/Argentina/San_Juan', + 'America/Argentina/San_Luis', + 'America/Argentina/Tucuman', + 'America/Argentina/Ushuaia', + 'America/Aruba', + 'America/Asuncion', + 'America/Atikokan', + 'America/Atka', + 'America/Bahia', + 'America/Bahia_Banderas', + 'America/Barbados', + 'America/Belem', + 'America/Belize', + 'America/Blanc-Sablon', + 'America/Boa_Vista', + 'America/Bogota', + 'America/Boise', + 'America/Buenos_Aires', + 'America/Cambridge_Bay', + 'America/Campo_Grande', + 'America/Cancun', + 'America/Caracas', + 'America/Catamarca', + 'America/Cayenne', + 'America/Cayman', + 'America/Chicago', + 'America/Chihuahua', + 'America/Coral_Harbour', + 'America/Cordoba', + 'America/Costa_Rica', + 'America/Creston', + 'America/Cuiaba', + 'America/Curacao', + 'America/Danmarkshavn', + 'America/Dawson', + 'America/Dawson_Creek', + 'America/Denver', + 'America/Detroit', + 'America/Dominica', + 'America/Edmonton', + 'America/Eirunepe', + 'America/El_Salvador', + 'America/Ensenada', + 'America/Fort_Nelson', + 'America/Fort_Wayne', + 'America/Fortaleza', + 'America/Glace_Bay', + 'America/Godthab', + 'America/Goose_Bay', + 'America/Grand_Turk', + 'America/Grenada', + 'America/Guadeloupe', + 'America/Guatemala', + 'America/Guayaquil', + 'America/Guyana', + 'America/Halifax', + 'America/Havana', + 'America/Hermosillo', + 'America/Indiana/Indianapolis', + 'America/Indiana/Knox', + 'America/Indiana/Marengo', + 'America/Indiana/Petersburg', + 'America/Indiana/Tell_City', + 'America/Indiana/Vevay', + 'America/Indiana/Vincennes', + 'America/Indiana/Winamac', + 'America/Indianapolis', + 'America/Inuvik', + 'America/Iqaluit', + 'America/Jamaica', + 'America/Jujuy', + 'America/Juneau', + 'America/Kentucky/Louisville', + 'America/Kentucky/Monticello', + ' America/Knox_IN', + ' America/Kralendijk', + ' America/La_Paz', + ' America/Lima', + ' America/Los_Angeles', + ' America/Louisville', + ' America/Lower_Princes', + ' America/Maceio', + ' America/Managua', + ' America/Manaus', + ' America/Marigot', + ' America/Martinique', + ' America/Matamoros', + ' America/Mazatlan', + ' America/Mendoza', + ' America/Menominee', + ' America/Merida', + ' America/Metlakatla', + ' America/Mexico_City', + ' America/Miquelon', + ' America/Moncton', + ' America/Monterrey', + ' America/Montevideo', + 'America/Montreal', + 'America/Montserrat', + 'America/Nassau', + 'America/New_York', + 'America/Nipigon', + 'America/Nome', + 'America/Noronha', + 'America/North_Dakota/Beulah', + 'America/North_Dakota/Center', + 'America/North_Dakota/New_Salem', + 'America/Ojinaga', + 'America/Panama', + 'America/Pangnirtung', + 'America/Paramaribo', + 'America/Phoenix', + 'America/Port-au-Prince', + 'America/Port_of_Spain', + 'America/Porto_Acre', + 'America/Porto_Velho', + 'America/Puerto_Rico', + 'America/Punta_Arenas', + 'America/Rainy_River', + 'America/Rankin_Inlet', + 'America/Recife', + 'America/Regina', + 'America/Resolute', + 'America/Rio_Branco', + 'America/Rosario', + 'America/Santa_Isabel', + 'America/Santarem', + 'America/Santiago', + 'America/Santo_Domingo', + 'America/Sao_Paulo', + 'America/Scoresbysund', + 'America/Shiprock', + 'America/Sitka', + 'America/St_Barthelemy', + 'America/St_Johns', + 'America/St_Kitts', + 'America/St_Lucia', + 'America/St_Thomas', + 'America/St_Vincent', + 'America/Swift_Current', + 'America/Tegucigalpa', + 'America/Thule', + 'America/Thunder_Bay', + 'America/Tijuana', + 'America/Toronto', + 'America/Tortola', + 'America/Vancouver', + 'America/Virgin', + 'America/Whitehorse', + 'America/Winnipeg', + 'America/Yakutat', + 'America/Yellowknife', + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/DumontDUrville', + 'Antarctica/Macquarie', + 'Antarctica/Mawson', + 'Antarctica/McMurdo', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/South_Pole', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + 'Arctic/Longyearbyen', + 'Asia/Aden', + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Anadyr', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Ashgabat', + 'Asia/Ashkhabad', + 'Asia/Atyrau', + 'Asia/Baghdad', + 'Asia/Bahrain', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Barnaul', + 'Asia/Beirut', + 'Asia/Bishkek', + 'Asia/Brunei', + 'Asia/Calcutta', + 'Asia/Chita', + 'Asia/Choibalsan', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Colombo', + 'Asia/Dacca', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dili', + 'Asia/Dubai', + 'Asia/Dushanbe', + 'Asia/Famagusta', + 'Asia/Gaza', + 'Asia/Harbin', + 'Asia/Hebron', + 'Asia/Ho_Chi_Minh', + 'Asia/Hong_Kong', + 'Asia/Hovd', + 'Asia/Irkutsk', + 'Asia/Istanbul', + 'Asia/Jakarta', + 'Asia/Jayapura', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Kamchatka', + 'Asia/Karachi', + 'Asia/Kashgar', + 'Asia/Kathmandu', + 'Asia/Katmandu', + 'Asia/Khandyga', + 'Asia/Kolkata', + 'Asia/Krasnoyarsk', + 'Asia/Kuala_Lumpur', + 'Asia/Kuching', + 'Asia/Kuwait', + 'Asia/Macao', + 'Asia/Macau', + 'Asia/Magadan', + 'Asia/Makassar', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novokuznetsk', + 'Asia/Novosibirsk', + 'Asia/Omsk', + 'Asia/Oral', + 'Asia/Phnom_Penh', + 'Asia/Pontianak', + 'Asia/Pyongyang', + 'Asia/Qatar', + 'Asia/Qyzylorda', + 'Asia/Rangoon', + 'Asia/Riyadh', + 'Asia/Saigon', + 'Asia/Sakhalin', + 'Asia/Samarkand', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Srednekolymsk', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tbilisi', + 'Asia/Tehran', + 'Asia/Tel_Aviv', + 'Asia/Thimbu', + 'Asia/Thimphu', + 'Asia/Tokyo', + 'Asia/Tomsk', + 'Asia/Ujung_Pandang', + 'Asia/Ulaanbaatar', + 'Asia/Ulan_Bator', + 'Asia/Urumqi', + 'Asia/Ust-Nera', + 'Asia/Vientiane', + 'Asia/Vladivostok', + 'Asia/Yakutsk', + 'Asia/Yangon', + 'Asia/Yekaterinburg', + 'Asia/Yerevan', + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faeroe', + 'Atlantic/Faroe', + 'Atlantic/Jan_Mayen', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Atlantic/St_Helena', + 'Atlantic/Stanley', + 'Australia/ACT', + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Broken_Hill', + 'Australia/Canberra', + 'Australia/Currie', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Hobart', + 'Australia/LHI', + 'Australia/Lindeman', + 'Australia/Lord_Howe', + 'Australia/Melbourne', + 'Australia/NSW', + 'Australia/North', + 'Australia/Perth', + 'Australia/Queensland', + 'Australia/South', + 'Australia/Sydney', + 'Australia/Tasmania', + 'Australia/Victoria', + 'Australia/West', + 'Australia/Yancowinna', + 'Brazil/Acre', + 'Brazil/DeNoronha', + 'Brazil/East', + 'Brazil/West', + 'CET', + 'CST6CDT', + 'Canada/Atlantic', + 'Canada/Central', + 'Canada/Eastern', + 'Canada/Mountain', + 'Canada/Newfoundland', + 'Canada/Pacific', + 'Canada/Saskatchewan', + 'Canada/Yukon', + 'Chile/Continental', + 'Chile/EasterIsland', + 'Cuba', + 'EET', + 'EST', + 'EST5EDT', + 'Egypt', + 'Eire', + 'Etc/GMT', + 'Etc/GMT+0', + 'Etc/GMT+1', + 'Etc/GMT+10', + 'Etc/GMT+11', + 'Etc/GMT+12', + 'Etc/GMT+2', + 'Etc/GMT+3', + 'Etc/GMT+4', + 'Etc/GMT+5', + 'Etc/GMT+6', + 'Etc/GMT+7', + 'Etc/GMT+8', + 'Etc/GMT+9', + 'Etc/GMT-0', + 'Etc/GMT-1', + 'Etc/GMT-10', + 'Etc/GMT-11', + 'Etc/GMT-12', + 'Etc/GMT-13', + 'Etc/GMT-14', + 'Etc/GMT-2', + 'Etc/GMT-3', + 'Etc/GMT-4', + 'Etc/GMT-5', + 'Etc/GMT-6', + 'Etc/GMT-7', + 'Etc/GMT-8', + 'Etc/GMT-9', + 'Etc/GMT0', + 'Etc/Greenwich', + 'Etc/UCT', + 'Etc/UTC', + 'Etc/Universal', + 'Etc/Zulu', + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Astrakhan', + 'Europe/Athens', + 'Europe/Belfast', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Busingen', + 'Europe/Chisinau', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Guernsey', + 'Europe/Helsinki', + 'Europe/Isle_of_Man', + 'Europe/Istanbul', + 'Europe/Jersey', + 'Europe/Kaliningrad', + 'Europe/Kiev', + 'Europe/Kirov', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Mariehamn', + 'Europe/Minsk', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Nicosia', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Podgorica', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/Samara', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Saratov', + 'Europe/Simferopol', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Tiraspol', + 'Europe/Ulyanovsk', + 'Europe/Uzhgorod', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Volgograd', + 'Europe/Warsaw', + 'Europe/Zagreb', + 'Europe/Zaporozhye', + 'Europe/Zurich', + 'GB', + 'GB-Eire', + 'GMT', + 'GMT+0', + 'GMT-0', + 'GMT0', + 'Greenwich', + 'HST', + 'Hongkong', + 'Iceland', + 'Indian/Antananarivo', + 'Indian/Chagos', + 'Indian/Christmas', + 'Indian/Cocos', + 'Indian/Comoro', + 'Indian/Kerguelen', + 'Indian/Mahe', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Mayotte', + 'Indian/Reunion', + 'Iran', + 'Israel', + 'Jamaica', + 'Japan', + 'Kwajalein', + 'Libya', + 'MET', + 'MST', + 'MST7MDT', + 'Mexico/BajaNorte', + 'Mexico/BajaSur', + 'Mexico/General', + 'NZ', + 'NZ-CHAT', + 'Navajo', + 'PRC', + 'PST8PDT', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Bougainville', + 'Pacific/Chatham', + 'Pacific/Chuuk', + 'Pacific/Easter', + 'Pacific/Efate', + 'Pacific/Enderbury', + 'Pacific/Fakaofo', + 'Pacific/Fiji', + 'Pacific/Funafuti', + 'Pacific/Galapagos', + 'Pacific/Gambier', + 'Pacific/Guadalcanal', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Johnston', + 'Pacific/Kiritimati', + 'Pacific/Kosrae', + 'Pacific/Kwajalein', + 'Pacific/Majuro', + 'Pacific/Marquesas', + 'Pacific/Midway', + 'Pacific/Nauru', + 'Pacific/Niue', + 'Pacific/Norfolk', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'Pacific/Palau', + 'Pacific/Pitcairn', + 'Pacific/Pohnpei', + 'Pacific/Ponape', + 'Pacific/Port_Moresby', + 'Pacific/Rarotonga', + 'Pacific/Saipan', + 'Pacific/Samoa', + 'Pacific/Tahiti', + 'Pacific/Tarawa', + 'Pacific/Tongatapu', + 'Pacific/Truk', + 'Pacific/Wake', + 'Pacific/Wallis', + 'Pacific/Yap', + 'Poland', + 'Portugal', + 'ROC', + 'ROK', + 'Singapore', + 'Turkey', + 'UCT', + 'US/Alaska', + 'US/Aleutian', + 'US/Arizona', + 'US/Central', + 'US/East-Indiana', + 'US/Eastern', + 'US/Hawaii', + 'US/Indiana-Starke', + 'US/Michigan', + 'US/Mountain', + 'US/Pacific', + 'US/Pacific-New', + 'US/Samoa', + 'UTC', + 'Universal', + 'W-SU', + 'WET', + 'Zulu', +]; \ No newline at end of file diff --git a/src/context/globalInfo.tsx b/src/context/globalInfo.tsx index e7eb2ed2..e0efb678 100644 --- a/src/context/globalInfo.tsx +++ b/src/context/globalInfo.tsx @@ -16,6 +16,8 @@ interface GlobalInfo { setRerenderRuns: (rerenderRuns: boolean) => void; recordingLength: number; setRecordingLength: (recordingLength: number) => void; + recordingName: string; + setRecordingName: (recordingName: string) => void; }; class GlobalInfoStore implements Partial { @@ -29,6 +31,7 @@ class GlobalInfoStore implements Partial { }; recordings: string[] = []; rerenderRuns = false; + recordingName = ''; }; const globalInfoStore = new GlobalInfoStore(); @@ -43,6 +46,7 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { const [recordings, setRecordings] = useState(globalInfoStore.recordings); const [rerenderRuns, setRerenderRuns] = useState(globalInfoStore.rerenderRuns); const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); + const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { setNotification({ severity, message, isOpen: true }); @@ -75,6 +79,8 @@ export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { setRerenderRuns, recordingLength, setRecordingLength, + recordingName, + setRecordingName }} > {children} diff --git a/src/index.tsx b/src/index.tsx index b7688442..8c14f60a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; +import { BrowserRouter } from 'react-router-dom'; import App from './App'; const root = ReactDOM.createRoot( @@ -8,7 +9,9 @@ const root = ReactDOM.createRoot( ); root.render( - + + + ); diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index d358b1d3..9b67f629 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -4,10 +4,11 @@ import { Grid, Stack } from "@mui/material"; import { Recordings } from "../components/organisms/Recordings"; import { Runs } from "../components/organisms/Runs"; import { useGlobalInfoStore } from "../context/globalInfo"; -import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort } from "../api/storage"; +import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage"; import { io, Socket } from "socket.io-client"; import { stopRecording } from "../api/recording"; import { RunSettings } from "../components/molecules/RunSettings"; +import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; interface MainPageProps { handleEditRecording: (fileName: string) => void; @@ -18,6 +19,11 @@ export interface CreateRunResponse { runId: string; } +export interface ScheduleRunResponse { + message: string; + runId: string; +} + export const MainPage = ({ handleEditRecording }: MainPageProps) => { const [content, setContent] = React.useState('recordings'); @@ -95,6 +101,17 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { } }, [runningRecordingName, sockets, ids, readyForRunHandler, debugMessageHandler]) + const handleScheduleRecording = (settings: ScheduleSettings) => { + scheduleStoredRecording(runningRecordingName, settings) + .then(({message, runId}: ScheduleRunResponse) => { + if (message === 'success') { + notify('success', `Recording ${runningRecordingName} scheduled successfully`); + } else { + notify('error', `Failed to schedule recording ${runningRecordingName}`); + } + }); + } + const DisplayContent = () => { switch (content) { case 'recordings': @@ -102,6 +119,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => { handleEditRecording={handleEditRecording} handleRunRecording={handleRunRecording} setFileName={setFileName} + handleScheduleRecording={handleScheduleRecording} />; case 'runs': return { - const [recordingName, setRecordingName] = useState(''); const [open, setOpen] = useState(false); - const { browserId, setBrowserId, notification } = useGlobalInfoStore(); + const navigate = useNavigate(); + + const { browserId, setBrowserId, notification, recordingName, setRecordingName } = useGlobalInfoStore(); const handleNewRecording = () => { setBrowserId('new-recording'); setRecordingName(''); + navigate('/recording'); + } const handleEditRecording = (fileName: string) => { setRecordingName(fileName); setBrowserId('new-recording'); + navigate('/recording'); + } const isNotification = (): boolean => { @@ -36,6 +42,7 @@ export const PageWrapper = () => { const id = await getActiveBrowserId(); if (id) { setBrowserId(id); + navigate('/recording'); } } isRecordingInProgress(); @@ -46,18 +53,20 @@ export const PageWrapper = () => { - {browserId - ? ( - - - - - - ) - : + } /> - } + + + + } + /> + {isNotification() ?