├── maxun-core ├── README.md ├── src │ ├── types │ │ ├── logic.ts │ │ └── workflow.ts │ ├── index.ts │ └── utils │ │ ├── utils.ts │ │ ├── logger.ts │ │ └── concurrency.ts ├── .gitignore ├── tsconfig.json └── package.json ├── public └── img │ └── maxunlogo.png ├── src ├── shared │ ├── constants.ts │ └── types.ts ├── App.test.tsx ├── components │ ├── molecules │ │ ├── action-settings │ │ │ ├── index.ts │ │ │ ├── scroll.tsx │ │ │ ├── scrapeSchema.tsx │ │ │ ├── scrape.tsx │ │ │ └── screenshot.tsx │ │ ├── SidePanelHeader.tsx │ │ ├── ToggleButton.tsx │ │ ├── KeyValueForm.tsx │ │ ├── ActionSettings.tsx │ │ ├── UrlForm.tsx │ │ ├── BrowserRecordingSave.tsx │ │ ├── BrowserTabs.tsx │ │ ├── LeftSidePanelSettings.tsx │ │ ├── BrowserNavBar.tsx │ │ ├── LeftSidePanelContent.tsx │ │ ├── RunSettings.tsx │ │ ├── ActionDescriptionBox.tsx │ │ ├── DisplayWhereConditionSettings.tsx │ │ ├── SaveRecording.tsx │ │ └── AddWhatCondModal.tsx │ ├── atoms │ │ ├── texts.tsx │ │ ├── form.tsx │ │ ├── buttons │ │ │ ├── RemoveButton.tsx │ │ │ ├── EditButton.tsx │ │ │ ├── ClearButton.tsx │ │ │ ├── BreakpointButton.tsx │ │ │ ├── AddButton.tsx │ │ │ └── buttons.tsx │ │ ├── Box.tsx │ │ ├── DropdownMui.tsx │ │ ├── ConfirmationBox.tsx │ │ ├── AlertSnackbar.tsx │ │ ├── GenericModal.tsx │ │ ├── PairDisplayDiv.tsx │ │ ├── DiscordIcon.tsx │ │ ├── KeyValuePair.tsx │ │ ├── Loader.tsx │ │ ├── Highlighter.tsx │ │ └── RecorderIcon.tsx │ └── organisms │ │ ├── Runs.tsx │ │ ├── MainMenu.tsx │ │ ├── ApiKey.tsx │ │ ├── BrowserContent.tsx │ │ └── LeftSidePanel.tsx ├── routes │ └── userRoute.tsx ├── index.tsx ├── api │ ├── auth.ts │ ├── integration.ts │ ├── proxy.ts │ ├── workflow.ts │ └── recording.ts ├── context │ ├── browserDimensions.tsx │ ├── socket.tsx │ ├── auth.tsx │ ├── globalInfo.tsx │ └── browserActions.tsx ├── App.tsx ├── pages │ ├── PageWrappper.tsx │ ├── Login.tsx │ └── Register.tsx ├── index.css └── helpers │ └── inputHelpers.ts ├── docker-entrypoint.sh ├── server ├── src │ ├── utils │ │ ├── api.ts │ │ ├── env.ts │ │ ├── schedule.ts │ │ ├── analytics.ts │ │ └── auth.ts │ ├── models │ │ ├── associations.ts │ │ ├── User.ts │ │ ├── Robot.ts │ │ └── Run.ts │ ├── constants │ │ └── config.ts │ ├── routes │ │ ├── index.ts │ │ ├── integration.ts │ │ ├── record.ts │ │ └── workflow.ts │ ├── middlewares │ │ ├── api.ts │ │ └── auth.ts │ ├── index.ts │ ├── logger.ts │ ├── swagger │ │ └── config.ts │ ├── storage │ │ └── db.ts │ ├── socket-connection │ │ └── connection.ts │ ├── worker.ts │ ├── workflow-management │ │ ├── utils.ts │ │ └── storage.ts │ ├── server.ts │ └── browser-management │ │ └── classes │ │ └── BrowserPool.ts ├── .gitignore └── tsconfig.json ├── typedoc.json ├── .gitignore ├── vite.config.js ├── nginx.conf ├── tsconfig.json ├── index.html ├── docker-compose.yml ├── Dockerfile └── package.json /maxun-core/README.md: -------------------------------------------------------------------------------- 1 | ### Maxun-Core -------------------------------------------------------------------------------- /public/img/maxunlogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hacksider/maxun/HEAD/public/img/maxunlogo.png -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowFile } from "maxun-core"; 2 | 3 | export const emptyWorkflow: WorkflowFile = { workflow: [] }; 4 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Start backend server 4 | cd /app && npm run start:server & 5 | 6 | # Start nginx 7 | nginx -g 'daemon off;' -------------------------------------------------------------------------------- /server/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | export const genAPIKey = (): string => { 2 | return [...Array(30)].map(() => ((Math.random() * 36) | 0).toString(36)).join(''); 3 | }; 4 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src", "./server/src"], 4 | "sort": ["source-order"], 5 | "categorizeByGroup": false, 6 | "tsconfig": "./tsconfig.json" 7 | } 8 | -------------------------------------------------------------------------------- /server/src/models/associations.ts: -------------------------------------------------------------------------------- 1 | import Robot from './Robot'; 2 | import Run from './Run'; 3 | 4 | export default function setupAssociations() { 5 | Run.belongsTo(Robot, { foreignKey: 'robotId' }); 6 | Robot.hasMany(Run, { foreignKey: 'robotId' }); 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # misc 5 | .DS_Store 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | .env 11 | 12 | /.idea 13 | 14 | /server/logs 15 | 16 | /build 17 | 18 | package-lock.json 19 | -------------------------------------------------------------------------------- /maxun-core/src/types/logic.ts: -------------------------------------------------------------------------------- 1 | export const unaryOperators = ['$not'] as const; 2 | export const naryOperators = ['$and', '$or'] as const; 3 | 4 | export const operators = [...unaryOperators, ...naryOperators] as const; 5 | export const meta = ['$before', '$after'] as const; -------------------------------------------------------------------------------- /maxun-core/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # misc 5 | .DS_Store 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | .env 11 | 12 | /.idea 13 | 14 | /server/logs 15 | 16 | /build 17 | 18 | package-lock.json -------------------------------------------------------------------------------- /maxun-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build", 4 | "declaration": true, 5 | "allowJs": true, 6 | "target": "es6", 7 | "module": "commonjs", 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /server/src/constants/config.ts: -------------------------------------------------------------------------------- 1 | export const SERVER_PORT = process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 8080 2 | export const DEBUG = process.env.DEBUG === 'true' 3 | export const LOGS_PATH = process.env.LOGS_PATH ?? 'server/logs' 4 | export const ANALYTICS_ID = 'oss' -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # misc 5 | .DS_Store 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | .env 11 | 12 | /.idea 13 | 14 | /server/logs 15 | 16 | /build 17 | 18 | /dist 19 | 20 | package-lock.json -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig(() => { 5 | return { 6 | build: { 7 | outDir: 'build', 8 | manifest: true, 9 | chunkSizeWarningLimit: 1024, 10 | }, 11 | plugins: [react()], 12 | }; 13 | }); -------------------------------------------------------------------------------- /maxun-core/src/index.ts: -------------------------------------------------------------------------------- 1 | import Interpreter from './interpret'; 2 | 3 | export default Interpreter; 4 | export { default as Preprocessor } from './preprocessor'; 5 | export type { 6 | WorkflowFile, WhereWhatPair, Where, What, 7 | } from './types/workflow'; 8 | export { unaryOperators, naryOperators, meta as metaOperators } from './types/logic'; -------------------------------------------------------------------------------- /server/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | // Helper function to get environment variables and throw an error if they are not set 2 | export const getEnvVariable = (key: string, defaultValue?: string): string => { 3 | const value = process.env[key] || defaultValue; 4 | if (!value) { 5 | throw new Error(`Environment variable ${key} is not defined`); 6 | } 7 | return value; 8 | }; -------------------------------------------------------------------------------- /src/components/molecules/action-settings/index.ts: -------------------------------------------------------------------------------- 1 | import { ScrollSettings } from './scroll'; 2 | import { ScreenshotSettings } from "./screenshot"; 3 | import { ScrapeSettings } from "./scrape"; 4 | import { ScrapeSchemaSettings } from "./scrapeSchema"; 5 | 6 | export { 7 | ScrollSettings, 8 | ScreenshotSettings, 9 | ScrapeSettings, 10 | ScrapeSchemaSettings, 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/userRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navigate, Outlet } from 'react-router-dom'; 3 | import { useContext } from 'react'; 4 | import { AuthContext } from '../context/auth'; 5 | 6 | const UserRoute = () => { 7 | const { state } = useContext(AuthContext); 8 | 9 | return state.user ? : ; 10 | }; 11 | 12 | export default UserRoute; 13 | -------------------------------------------------------------------------------- /maxun-core/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ESLint rule in case there is only one util function 3 | * (it still does not represent the "utils" file) 4 | */ 5 | 6 | /* eslint-disable import/prefer-default-export */ 7 | 8 | /** 9 | * Converts an array of scalars to an object with **items** of the array **for keys**. 10 | */ 11 | export function arrayToObject(array : any[]) { 12 | return array.reduce((p, x) => ({ ...p, [x]: [] }), {}); 13 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import App from './App'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | 18 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { router as record } from './record'; 2 | import { router as workflow } from './workflow'; 3 | import { router as storage } from './storage'; 4 | import { router as auth } from './auth'; 5 | import { router as integration } from './integration'; 6 | import { router as proxy } from './proxy'; 7 | 8 | export { 9 | record, 10 | workflow, 11 | storage, 12 | auth, 13 | integration, 14 | proxy 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/atoms/texts.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const WarningText = styled.p` 4 | border: 1px solid orange; 5 | display: flex; 6 | margin: 10px; 7 | flex-direction: column; 8 | font-size: small; 9 | background: rgba(255,165,0,0.15); 10 | padding: 5px; 11 | font-family: "Roboto","Helvetica","Arial",sans-serif; 12 | font-weight: 400; 13 | line-height: 1.5; 14 | letter-spacing: 0.00938em; 15 | ` 16 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | try_files $uri $uri/ /index.html; 7 | } 8 | 9 | location /api { 10 | proxy_pass http://127.0.0.1:8080; 11 | proxy_http_version 1.1; 12 | proxy_set_header Upgrade $http_upgrade; 13 | proxy_set_header Connection 'upgrade'; 14 | proxy_set_header Host $host; 15 | proxy_cache_bypass $http_upgrade; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/atoms/form.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const NavBarForm = styled.form` 4 | flex: 1px; 5 | margin-left: 5px; 6 | margin-right: 5px; 7 | position: relative; 8 | `; 9 | 10 | export const NavBarInput = styled.input` 11 | box-sizing: border-box; 12 | outline: none; 13 | width: 100%; 14 | height: 24px; 15 | border-radius: 12px; 16 | border: none; 17 | padding-left: 12px; 18 | padding-right: 40px; 19 | `; 20 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { default as axios } from "axios"; 2 | 3 | export const getUserById = async (userId: string) => { 4 | try { 5 | const response = await axios.get(`http://localhost:8080/auth/user/${userId}`); 6 | if (response.status === 200) { 7 | return response.data; 8 | } else { 9 | throw new Error(`Couldn't get user with id ${userId}`); 10 | } 11 | } catch (error: any) { 12 | console.error(error); 13 | return null; 14 | } 15 | } -------------------------------------------------------------------------------- /server/src/utils/schedule.ts: -------------------------------------------------------------------------------- 1 | import cronParser from 'cron-parser'; 2 | import moment from 'moment-timezone'; 3 | 4 | // Function to compute next run date based on the cron pattern and timezone 5 | export function computeNextRun(cronExpression: string, timezone: string) { 6 | try { 7 | const interval = cronParser.parseExpression(cronExpression, { tz: timezone }); 8 | return interval.next().toDate(); 9 | } catch (err) { 10 | console.error('Error parsing cron expression:', err); 11 | return null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Remove } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface RemoveButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const RemoveButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/Box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | 4 | interface BoxProps { 5 | width: number | string, 6 | height: number | string, 7 | background: string, 8 | radius: string, 9 | children?: JSX.Element, 10 | }; 11 | 12 | export const SimpleBox = ({ width, height, background, radius, children }: BoxProps) => { 13 | return ( 14 | 22 | {children} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "outDir": "./build" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Edit } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface EditButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const EditButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/ClearButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Clear } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface ClearButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const ClearButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /server/src/middlewares/api.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import User from "../models/User"; 3 | import { AuthenticatedRequest } from "../routes/record" 4 | 5 | export const requireAPIKey = async (req: AuthenticatedRequest, res: Response, next: any) => { 6 | const apiKey = req.headers['x-api-key']; 7 | if (!apiKey) { 8 | return res.status(401).json({ error: "API key is missing" }); 9 | } 10 | const user = await User.findOne({ where: { api_key: apiKey } }); 11 | if (!user) { 12 | return res.status(403).json({ error: "Invalid API key" }); 13 | } 14 | req.user = user; 15 | next(); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { TextField } from "@mui/material"; 3 | 4 | export const ScrollSettings = forwardRef((props, ref) => { 5 | const [settings, setSettings] = React.useState(0); 6 | useImperativeHandle(ref, () => ({ 7 | getSettings() { 8 | return settings; 9 | } 10 | })); 11 | 12 | return ( 13 | setSettings(parseInt(e.target.value))} 19 | /> 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./server"; 2 | export * from "./logger"; 3 | export * from "./types"; 4 | export * from "./browser-management/controller"; 5 | export * from "./browser-management/inputHandlers"; 6 | export * from "./browser-management/classes/RemoteBrowser"; 7 | export * from "./browser-management/classes/BrowserPool"; 8 | export * from "./socket-connection/connection"; 9 | export * from "./workflow-management/selector"; 10 | export * from "./workflow-management/storage"; 11 | export * from "./workflow-management/utils"; 12 | export * from "./workflow-management/classes/Interpreter"; 13 | export * from "./workflow-management/classes/Generator"; 14 | export * from "./workflow-management/scheduler"; 15 | -------------------------------------------------------------------------------- /src/api/integration.ts: -------------------------------------------------------------------------------- 1 | import { default as axios } from "axios"; 2 | 3 | export const handleUploadCredentials = async (fileName: string, credentials: any, spreadsheetId: string, range: string): Promise => { 4 | try { 5 | const response = await axios.post('http://localhost:8080/integration/upload-credentials', { fileName, credentials: JSON.parse(credentials), spreadsheetId, range }); 6 | if (response.status === 200) { 7 | return response.data; 8 | } else { 9 | throw new Error(`Couldn't make gsheet integration for ${fileName}`); 10 | } 11 | } catch (error) { 12 | console.error('Error uploading credentials:', error); 13 | return false; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /server/src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston'; 2 | import { DEBUG, LOGS_PATH } from "./constants/config"; 3 | 4 | const { combine, timestamp, printf } = format; 5 | 6 | const logger = createLogger({ 7 | format: combine( 8 | timestamp(), 9 | printf(info => `${info.timestamp} ${info.level}: ${info.message}`), 10 | ), 11 | defaultMeta: { service: 'user-service' }, 12 | transports: [ 13 | new transports.Console({ level: DEBUG ? 'info' : 'debug' }), 14 | new transports.File({ filename: `${LOGS_PATH}/error.log`, level: 'error' }), 15 | new transports.File({ filename: `${LOGS_PATH}/combined.log`, level: 'debug' }), 16 | ], 17 | }); 18 | 19 | export default logger; 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | Maxun | Open Source No Code Web Data Extraction Platform 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { verify, JwtPayload } from "jsonwebtoken"; 3 | 4 | interface UserRequest extends Request { 5 | user?: JwtPayload | string; 6 | } 7 | 8 | export const requireSignIn = (req: UserRequest, res: Response, next: any) => { 9 | const token = req.cookies && req.cookies.token ? req.cookies.token : null; 10 | 11 | if (token === null) return res.sendStatus(401); 12 | 13 | const secret = process.env.JWT_SECRET; 14 | if (!secret) { 15 | return res.sendStatus(500); // Internal Server Error if secret is not defined 16 | } 17 | 18 | verify(token, secret, (err: any, user: any) => { 19 | console.log(err) 20 | 21 | if (err) return res.sendStatus(403) 22 | 23 | req.user = user; 24 | 25 | next() 26 | }) 27 | }; 28 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowFile } from "maxun-core"; 2 | import { Locator } from "playwright"; 3 | 4 | export type Workflow = WorkflowFile["workflow"]; 5 | 6 | export interface ScreenshotSettings { 7 | animations?: "disabled" | "allow"; 8 | caret?: "hide" | "initial"; 9 | clip?: { 10 | x: number; 11 | y: number; 12 | width: number; 13 | height: number; 14 | }; 15 | fullPage?: boolean; 16 | mask?: Locator[]; 17 | omitBackground?: boolean; 18 | // is this still needed? - maxun-core outputs to a binary output 19 | path?: string; 20 | quality?: number; 21 | scale?: "css" | "device"; 22 | timeout?: number; 23 | type?: "jpeg" | "png"; 24 | }; 25 | 26 | export declare type CustomActions = 'scrape' | 'scrapeSchema' | 'scroll' | 'screenshot' | 'script' | 'enqueueLinks' | 'flag' | 'scrapeList' | 'scrapeListAuto'; 27 | -------------------------------------------------------------------------------- /src/components/organisms/Runs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Grid } from "@mui/material"; 3 | import { RunsTable } from "../molecules/RunsTable"; 4 | 5 | interface RunsProps { 6 | currentInterpretationLog: string; 7 | abortRunHandler: () => void; 8 | runId: string; 9 | runningRecordingName: string; 10 | } 11 | 12 | export const Runs = ( 13 | { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsProps) => { 14 | 15 | return ( 16 | 17 | 18 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/molecules/SidePanelHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { InterpretationButtons } from "./InterpretationButtons"; 3 | import { useSocketStore } from "../../context/socket"; 4 | 5 | export const SidePanelHeader = () => { 6 | 7 | const [steppingIsDisabled, setSteppingIsDisabled] = useState(true); 8 | 9 | const { socket } = useSocketStore(); 10 | 11 | const handleStep = () => { 12 | socket?.emit('step'); 13 | }; 14 | 15 | return ( 16 |
17 | setSteppingIsDisabled(!isPaused)} /> 18 | {/* */} 27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /server/src/routes/integration.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import logger from "../logger"; 3 | // import { loadIntegrations, saveIntegrations } from '../workflow-management/integrations/gsheet'; 4 | import { requireSignIn } from '../middlewares/auth'; 5 | 6 | export const router = Router(); 7 | 8 | router.post('/upload-credentials', requireSignIn, async (req, res) => { 9 | try { 10 | const { fileName, credentials, spreadsheetId, range } = req.body; 11 | if (!fileName || !credentials || !spreadsheetId || !range) { 12 | return res.status(400).json({ message: 'Credentials, Spreadsheet ID, and Range are required.' }); 13 | } 14 | // *** TEMPORARILY WE STORE CREDENTIALS HERE *** 15 | } catch (error: any) { 16 | logger.log('error', `Error saving credentials: ${error.message}`); 17 | return res.status(500).json({ message: 'Failed to save credentials.', error: error.message }); 18 | } 19 | }); -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "../", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "baseUrl": "../", 13 | "paths": { 14 | "*": ["*"], 15 | "src/*": ["src/*"] 16 | }, 17 | "jsx": "react-jsx", 18 | "lib": ["dom", "dom.iterable", "esnext"], 19 | "allowJs": true 20 | }, 21 | "include": [ 22 | "src/**/*", 23 | "../src/shared/**/*", 24 | "../src/helpers/**/*" 25 | ], 26 | "exclude": [ 27 | "node_modules", 28 | "../src/components/**/*", // Exclude frontend components 29 | "../src/pages/**/*", // Exclude frontend pages 30 | "../src/app/**/*" // Exclude other frontend-specific code 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scrapeSchema.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; 2 | import { WarningText } from "../../atoms/texts"; 3 | import InfoIcon from "@mui/icons-material/Info"; 4 | import { KeyValueForm } from "../KeyValueForm"; 5 | 6 | export const ScrapeSchemaSettings = forwardRef((props, ref) => { 7 | const keyValueFormRef = useRef<{ getObject: () => object }>(null); 8 | 9 | useImperativeHandle(ref, () => ({ 10 | getSettings() { 11 | const settings = keyValueFormRef.current?.getObject() as Record 12 | return settings; 13 | } 14 | })); 15 | 16 | return ( 17 |
18 | 19 | 20 | The interpreter scrapes the data from a webpage into a "curated" table. 21 | 22 | 23 |
24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /maxun-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxun-core", 3 | "version": "0.0.3", 4 | "description": "Core package for Maxun, responsible for data extraction", 5 | "main": "build/index.js", 6 | "typings": "build/index.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "npm run clean && tsc", 10 | "lint": "eslint .", 11 | "clean": "rimraf ./build" 12 | }, 13 | "files": [ 14 | "build/*" 15 | ], 16 | "keywords": [ 17 | "maxun", 18 | "no-code scraping", 19 | "web", 20 | "automation", 21 | "workflow", 22 | "data extraction", 23 | "scraping" 24 | ], 25 | "author": "Maxun", 26 | "license": "AGPL-3.0-or-later", 27 | "dependencies": { 28 | "@cliqz/adblocker-playwright": "^1.31.3", 29 | "cross-fetch": "^4.0.0", 30 | "joi": "^17.6.0", 31 | "playwright": "^1.20.1", 32 | "playwright-extra": "^4.3.6", 33 | "puppeteer-extra-plugin-stealth": "^2.11.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/BreakpointButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Circle } from "@mui/icons-material"; 3 | 4 | interface BreakpointButtonProps { 5 | handleClick: () => void; 6 | size?: "small" | "medium" | "large"; 7 | changeColor?: boolean; 8 | } 9 | 10 | export const BreakpointButton = 11 | ({ handleClick, size, changeColor }: BreakpointButtonProps) => { 12 | return ( 13 | 20 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /server/src/swagger/config.ts: -------------------------------------------------------------------------------- 1 | import swaggerJSDoc from 'swagger-jsdoc'; 2 | import path from 'path'; 3 | 4 | const options = { 5 | definition: { 6 | openapi: '3.0.0', 7 | info: { 8 | title: 'Maxun API Documentation', 9 | version: '1.0.0', 10 | description: 'API documentation for Maxun (https://github.com/getmaxun/maxun)', 11 | }, 12 | components: { 13 | securitySchemes: { 14 | api_key: { 15 | type: 'apiKey', 16 | in: 'header', 17 | name: 'x-api-key', 18 | description: 'API key for authorization. You can find your API key in the "API Key" section on Maxun Dashboard.', 19 | }, 20 | }, 21 | }, 22 | security: [ 23 | { 24 | api_key: [], // Apply this security scheme globally 25 | }, 26 | ], 27 | }, 28 | apis: [path.join(__dirname, '../api/*.ts')], 29 | }; 30 | 31 | const swaggerSpec = swaggerJSDoc(options); 32 | 33 | export default swaggerSpec; 34 | -------------------------------------------------------------------------------- /src/components/atoms/DropdownMui.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormControl, InputLabel, Select } from "@mui/material"; 3 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 4 | import { SxProps } from '@mui/system'; 5 | 6 | interface DropdownProps { 7 | id: string; 8 | label: string; 9 | value: string | undefined; 10 | handleSelect: (event: SelectChangeEvent) => void; 11 | children?: React.ReactNode; 12 | sx?: SxProps; 13 | }; 14 | 15 | export const Dropdown = ({ id, label, value, handleSelect, children, sx }: DropdownProps) => { 16 | return ( 17 | 18 | {label} 19 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /maxun-core/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Logger class for more detailed and comprehensible logs (with colors and timestamps) 3 | */ 4 | 5 | export enum Level { 6 | DATE = 36, 7 | LOG = 0, 8 | WARN = 93, 9 | ERROR = 31, 10 | DEBUG = 95, 11 | RESET = 0, 12 | } 13 | 14 | export default function logger( 15 | message: string | Error, 16 | level: (Level.LOG | Level.WARN | Level.ERROR | Level.DEBUG) = Level.LOG, 17 | ) { 18 | let m = message; 19 | if (message.constructor.name.includes('Error') && typeof message !== 'string') { 20 | m = (message).message; 21 | } 22 | process.stdout.write(`\x1b[${Level.DATE}m[${(new Date()).toLocaleString()}]\x1b[0m `); 23 | process.stdout.write(`\x1b[${level}m`); 24 | if (level === Level.ERROR || level === Level.WARN) { 25 | process.stderr.write(m); 26 | } else { 27 | process.stdout.write(m); 28 | } 29 | process.stdout.write(`\x1b[${Level.RESET}m\n`); 30 | } -------------------------------------------------------------------------------- /src/components/atoms/ConfirmationBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Button, IconButton, Stack, Typography } from "@mui/material"; 3 | 4 | interface ConfirmationBoxProps { 5 | selector: string; 6 | onYes: () => void; 7 | onNo: () => void; 8 | } 9 | 10 | export const ConfirmationBox = ({ selector, onYes, onNo }: ConfirmationBoxProps) => { 11 | return ( 12 | 13 | 14 | Confirmation 15 | 16 | 17 | Do you want to interact with the element: {selector}? 18 | 19 | 20 | 23 | 26 | 27 | 28 | ); 29 | }; -------------------------------------------------------------------------------- /src/components/atoms/buttons/AddButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Add } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface AddButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | title?: string; 9 | disabled?: boolean; 10 | hoverEffect?: boolean; 11 | style?: React.CSSProperties; 12 | } 13 | 14 | export const AddButton: FC = ( 15 | { handleClick, 16 | size, 17 | title, 18 | disabled = false, 19 | hoverEffect = true, 20 | style 21 | }) => { 22 | return ( 23 | 33 | {title} 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scrape.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { Stack, TextField } from "@mui/material"; 3 | import { WarningText } from '../../atoms/texts'; 4 | import InfoIcon from "@mui/icons-material/Info"; 5 | 6 | export const ScrapeSettings = forwardRef((props, ref) => { 7 | const [settings, setSettings] = React.useState(''); 8 | useImperativeHandle(ref, () => ({ 9 | getSettings() { 10 | return settings; 11 | } 12 | })); 13 | 14 | return ( 15 | 16 | setSettings(e.target.value)} 21 | /> 22 | 23 | 24 | The scrape function uses heuristic algorithm to automatically scrape only important data from the page. 25 | If a selector is used it will scrape and automatically parse all available 26 | data inside of the selected element(s). 27 | 28 | 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /server/src/storage/db.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize'; 2 | import dotenv from 'dotenv'; 3 | import setupAssociations from '../models/associations'; 4 | 5 | dotenv.config(); 6 | const sequelize = new Sequelize( 7 | `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`, 8 | { 9 | host: process.env.DB_HOST, 10 | dialect: 'postgres', 11 | logging: false, 12 | } 13 | ); 14 | 15 | export const connectDB = async () => { 16 | try { 17 | await sequelize.authenticate(); 18 | console.log('Database connected successfully'); 19 | } catch (error) { 20 | console.error('Unable to connect to the database:', error); 21 | } 22 | }; 23 | 24 | export const syncDB = async () => { 25 | try { 26 | //setupAssociations(); 27 | await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run 28 | console.log('Database synced successfully!'); 29 | } catch (error) { 30 | console.error('Failed to sync database:', error); 31 | } 32 | }; 33 | 34 | 35 | export default sequelize; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: production 9 | env_file: .env 10 | ports: 11 | - "5173:80" 12 | - "8080:8080" 13 | depends_on: 14 | - db 15 | - minio 16 | - redis 17 | 18 | db: 19 | image: postgres:13 20 | environment: 21 | POSTGRES_DB: ${DB_NAME} 22 | POSTGRES_USER: ${DB_USER} 23 | POSTGRES_PASSWORD: ${DB_PASSWORD} 24 | ports: 25 | - "5432:5432" 26 | volumes: 27 | - postgres_data:/var/lib/postgresql/data 28 | 29 | minio: 30 | image: minio/minio 31 | environment: 32 | MINIO_ROOT_USER: ${MINIO_ACCESS_KEY} 33 | MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY} 34 | command: server /data 35 | ports: 36 | - "9000:9000" 37 | volumes: 38 | - minio_data:/data 39 | 40 | redis: 41 | image: redis:6 42 | environment: 43 | - REDIS_HOST=redis 44 | - REDIS_PORT=6379 45 | ports: 46 | - "6379:6379" 47 | volumes: 48 | - redis_data:/data 49 | 50 | volumes: 51 | postgres_data: 52 | minio_data: 53 | redis_data: 54 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/buttons.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const NavBarButton = styled.button<{ disabled: boolean }>` 4 | margin-left: 10px; 5 | margin-right: 5px; 6 | padding: 0; 7 | border: none; 8 | background-color: transparent; 9 | cursor: ${({ disabled }) => disabled ? 'default' : 'pointer'}; 10 | width: 24px; 11 | height: 24px; 12 | border-radius: 12px; 13 | outline: none; 14 | color: ${({ disabled }) => disabled ? '#999' : '#333'}; 15 | 16 | ${({ disabled }) => disabled ? null : ` 17 | &:hover { 18 | background-color: #ddd; 19 | } 20 | &:active { 21 | background-color: #d0d0d0; 22 | } 23 | `}; 24 | `; 25 | 26 | export const UrlFormButton = styled.button` 27 | position: absolute; 28 | top: 0; 29 | right: 10px; 30 | padding: 0; 31 | border: none; 32 | background-color: transparent; 33 | cursor: pointer; 34 | width: 24px; 35 | height: 24px; 36 | border-radius: 12px; 37 | outline: none; 38 | // color: #333; 39 | 40 | // &:hover { 41 | // background-color: #ddd; 42 | // }, 43 | 44 | // &:active { 45 | // background-color: #d0d0d0; 46 | // }, 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/atoms/AlertSnackbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Snackbar from '@mui/material/Snackbar'; 3 | import MuiAlert, { AlertProps } from '@mui/material/Alert'; 4 | import { useGlobalInfoStore } from "../../context/globalInfo"; 5 | 6 | const Alert = React.forwardRef(function Alert( 7 | props, 8 | ref, 9 | ) { 10 | return ; 11 | }); 12 | 13 | export interface AlertSnackbarProps { 14 | severity: 'error' | 'warning' | 'info' | 'success', 15 | message: string, 16 | isOpen: boolean, 17 | }; 18 | 19 | export const AlertSnackbar = ({ severity, message, isOpen }: AlertSnackbarProps) => { 20 | const [open, setOpen] = React.useState(isOpen); 21 | 22 | const { closeNotify } = useGlobalInfoStore(); 23 | 24 | const handleClose = (event?: React.SyntheticEvent | Event, reason?: string) => { 25 | if (reason === 'clickaway') { 26 | return; 27 | } 28 | 29 | closeNotify(); 30 | setOpen(false); 31 | }; 32 | 33 | return ( 34 | 35 | 36 | {message} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/context/browserDimensions.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useState } from "react"; 2 | 3 | interface BrowserDimensions { 4 | width: number; 5 | height: number; 6 | setWidth: (newWidth: number) => void; 7 | }; 8 | 9 | class BrowserDimensionsStore implements Partial { 10 | width: number = 900; 11 | height: number = 400; 12 | }; 13 | 14 | const browserDimensionsStore = new BrowserDimensionsStore(); 15 | const browserDimensionsContext = createContext(browserDimensionsStore as BrowserDimensions); 16 | 17 | export const useBrowserDimensionsStore = () => useContext(browserDimensionsContext); 18 | 19 | export const BrowserDimensionsProvider = ({ children }: { children: JSX.Element }) => { 20 | const [width, setWidth] = useState(browserDimensionsStore.width); 21 | const [height, setHeight] = useState(browserDimensionsStore.height); 22 | 23 | const setNewWidth = useCallback((newWidth: number) => { 24 | setWidth(newWidth); 25 | setHeight(Math.round(newWidth / 1.6)); 26 | }, [setWidth, setHeight]); 27 | 28 | return ( 29 | 36 | {children} 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/atoms/GenericModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Modal, IconButton, Box } from '@mui/material'; 3 | import { Clear } from "@mui/icons-material"; 4 | 5 | interface ModalProps { 6 | isOpen: boolean; 7 | onClose: () => void; 8 | children?: JSX.Element; 9 | modalStyle?: React.CSSProperties; 10 | canBeClosed?: boolean; 11 | } 12 | 13 | export const GenericModal: FC = ( 14 | { isOpen, onClose, children, modalStyle, canBeClosed = true }) => { 15 | 16 | return ( 17 | { }} > 18 | 19 | {canBeClosed ? 20 | 21 | 22 | 23 | : null 24 | } 25 | {children} 26 | 27 | 28 | ); 29 | }; 30 | 31 | const defaultModalStyle = { 32 | position: 'absolute', 33 | top: '50%', 34 | left: '50%', 35 | transform: 'translate(-50%, -50%)', 36 | width: 1000, 37 | bgcolor: 'background.paper', 38 | boxShadow: 24, 39 | p: 4, 40 | height: '50%', 41 | display: 'block', 42 | overflow: 'scroll', 43 | padding: '5px 25px 10px 25px', 44 | zIndex: 3147483647, 45 | borderRadius: 4, // Added borderRadius for rounded corners 46 | }; -------------------------------------------------------------------------------- /server/src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | import { PostHog } from 'posthog-node' 2 | import os from 'os' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { ANALYTICS_ID } from '../constants/config' 6 | 7 | const posthogClient = new PostHog( 8 | 'phc_19FEaqf2nfrvPoNcw6H7YjhERoiXJ7kamkQrvvFnQhw', 9 | { host: 'https://us.i.posthog.com' } 10 | ) 11 | 12 | const DEFAULT_DISTINCT_ID = "oss"; 13 | 14 | function getOssVersion() { 15 | try { 16 | const packageJsonPath = path.resolve(process.cwd(), 'package.json'); 17 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); 18 | 19 | return packageJson.version || 'unknown'; 20 | } catch { 21 | return 'unknown'; 22 | } 23 | } 24 | 25 | function analyticsMetadata() { 26 | return { 27 | os: os.type().toLowerCase(), 28 | oss_version: getOssVersion(), 29 | machine: os.arch(), 30 | platform: os.platform(), 31 | node_version: process.version, 32 | environment: process.env.ENV || 'production', 33 | }; 34 | } 35 | 36 | export function capture(event: any, data = {}) { 37 | if (process.env.MAXUN_TELEMETRY !== 'true') return; 38 | 39 | const distinctId = ANALYTICS_ID || DEFAULT_DISTINCT_ID; 40 | const payload = { ...data, ...analyticsMetadata() }; 41 | 42 | posthogClient.capture({ 43 | distinctId, 44 | event, 45 | properties: payload, 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/atoms/PairDisplayDiv.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import { WhereWhatPair } from "maxun-core"; 4 | import styled from "styled-components"; 5 | 6 | interface PairDisplayDivProps { 7 | index: string; 8 | pair: WhereWhatPair; 9 | } 10 | 11 | export const PairDisplayDiv: FC = ({ index, pair }) => { 12 | 13 | return ( 14 |
15 | 16 | {`Index: ${index}`} 17 | {pair.id ? `, Id: ${pair.id}` : ''} 18 | 19 | 20 | {"Where:"} 21 | 22 | 23 |
{JSON.stringify(pair?.where, undefined, 2)}
24 |
25 | 26 | {"What:"} 27 | 28 | 29 |
{JSON.stringify(pair?.what, undefined, 2)}
30 |
31 |
32 | ); 33 | } 34 | 35 | const DescriptionWrapper = styled.div` 36 | margin: 0; 37 | font-family: "Roboto","Helvetica","Arial",sans-serif; 38 | font-weight: 400; 39 | font-size: 1rem; 40 | line-height: 1.5; 41 | letter-spacing: 0.00938em; 42 | `; 43 | -------------------------------------------------------------------------------- /src/components/molecules/ToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | interface ToggleButtonProps { 5 | isChecked?: boolean; 6 | onChange: () => void; 7 | }; 8 | 9 | export const ToggleButton: FC = ({ isChecked = false, onChange }) => ( 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | const CheckBoxWrapper = styled.div` 17 | position: relative; 18 | `; 19 | const CheckBoxLabel = styled.label` 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 42px; 24 | height: 26px; 25 | border-radius: 15px; 26 | background: #bebebe; 27 | cursor: pointer; 28 | 29 | &::after { 30 | content: ""; 31 | display: block; 32 | border-radius: 50%; 33 | width: 18px; 34 | height: 18px; 35 | margin: 3px; 36 | background: #ffffff; 37 | box-shadow: 1px 3px 3px 1px rgba(0, 0, 0, 0.2); 38 | transition: 0.2s; 39 | } 40 | `; 41 | const CheckBox = styled.input` 42 | opacity: 0; 43 | z-index: 1; 44 | border-radius: 15px; 45 | width: 42px; 46 | height: 26px; 47 | 48 | &:checked + ${CheckBoxLabel} { 49 | background: #2196F3; 50 | 51 | &::after { 52 | content: ""; 53 | display: block; 54 | border-radius: 50%; 55 | width: 18px; 56 | height: 18px; 57 | margin-left: 21px; 58 | transition: 0.2s; 59 | } 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/atoms/DiscordIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; 3 | 4 | const DiscordIcon: React.FC = (props) => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default DiscordIcon; -------------------------------------------------------------------------------- /src/components/molecules/KeyValueForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle, useRef } from 'react'; 2 | import { KeyValuePair } from "../atoms/KeyValuePair"; 3 | import { AddButton } from "../atoms/buttons/AddButton"; 4 | import { RemoveButton } from "../atoms/buttons/RemoveButton"; 5 | 6 | export const KeyValueForm = forwardRef((props, ref) => { 7 | const [numberOfPairs, setNumberOfPairs] = React.useState(1); 8 | const keyValuePairRefs = useRef<{getKeyValuePair: () => { key: string, value: string }}[]>([]); 9 | 10 | useImperativeHandle(ref, () => ({ 11 | getObject() { 12 | let reducedObject = {}; 13 | for (let i = 0; i < numberOfPairs; i++) { 14 | const keyValuePair = keyValuePairRefs.current[i]?.getKeyValuePair(); 15 | if (keyValuePair) { 16 | reducedObject = { 17 | ...reducedObject, 18 | [keyValuePair.key]: keyValuePair.value 19 | } 20 | } 21 | } 22 | return reducedObject; 23 | } 24 | })); 25 | 26 | return ( 27 |
28 | { 29 | new Array(numberOfPairs).fill(1).map((_, index) => { 30 | return keyValuePairRefs.current[index] = el}/> 33 | }) 34 | } 35 | setNumberOfPairs(numberOfPairs + 1)} hoverEffect={false}/> 36 | setNumberOfPairs(numberOfPairs - 1)}/> 37 |
38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/atoms/KeyValuePair.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from "react"; 2 | import { Box, TextField } from "@mui/material"; 3 | 4 | interface KeyValueFormProps { 5 | keyLabel?: string; 6 | valueLabel?: string; 7 | } 8 | 9 | export const KeyValuePair = forwardRef(({ keyLabel, valueLabel }: KeyValueFormProps, ref) => { 10 | const [key, setKey] = React.useState(''); 11 | const [value, setValue] = React.useState(''); 12 | useImperativeHandle(ref, () => ({ 13 | getKeyValuePair() { 14 | return { key, value }; 15 | } 16 | })); 17 | return ( 18 | :not(style)': { m: 1, width: '100px' }, 22 | }} 23 | noValidate 24 | autoComplete="off" 25 | > 26 | ) => setKey(event.target.value)} 31 | size="small" 32 | required 33 | /> 34 | ) => { 39 | const num = Number(event.target.value); 40 | if (isNaN(num)) { 41 | setValue(event.target.value); 42 | } 43 | else { 44 | setValue(num); 45 | } 46 | }} 47 | size="small" 48 | required 49 | /> 50 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /src/context/socket.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; 2 | import { io, Socket } from 'socket.io-client'; 3 | 4 | const SERVER_ENDPOINT = 'http://localhost:8080'; 5 | 6 | interface SocketState { 7 | socket: Socket | null; 8 | id: string; 9 | setId: (id: string) => void; 10 | }; 11 | 12 | class SocketStore implements Partial { 13 | socket = null; 14 | id = ''; 15 | }; 16 | 17 | const socketStore = new SocketStore(); 18 | const socketStoreContext = createContext(socketStore as SocketState); 19 | 20 | export const useSocketStore = () => useContext(socketStoreContext); 21 | 22 | export const SocketProvider = ({ children }: { children: JSX.Element }) => { 23 | const [socket, setSocket] = useState(socketStore.socket); 24 | const [id, setActiveId] = useState(socketStore.id); 25 | 26 | const setId = useCallback((id: string) => { 27 | // the socket client connection is recomputed whenever id changes -> the new browser has been initialized 28 | const socket = 29 | io(`${SERVER_ENDPOINT}/${id}`, { 30 | transports: ["websocket"], 31 | rejectUnauthorized: false 32 | }); 33 | 34 | socket.on('connect', () => console.log('connected to socket')); 35 | socket.on("connect_error", (err) => console.log(`connect_error due to ${err.message}`)); 36 | 37 | setSocket(socket); 38 | setActiveId(id); 39 | }, [setSocket]); 40 | 41 | return ( 42 | 49 | {children} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /server/src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import crypto from 'crypto'; 3 | import { getEnvVariable } from './env'; 4 | 5 | export const hashPassword = (password: string): Promise => { 6 | return new Promise((resolve, reject) => { 7 | bcrypt.genSalt(12, (err, salt) => { 8 | if (err) { 9 | reject(err) 10 | } 11 | bcrypt.hash(password, salt, (err, hash) => { 12 | if (err) { 13 | reject(err) 14 | } 15 | resolve(hash) 16 | }) 17 | }) 18 | }) 19 | } 20 | 21 | // password from frontend and hash from database 22 | export const comparePassword = (password: string, hash: string): Promise => { 23 | return bcrypt.compare(password, hash) 24 | } 25 | 26 | export const encrypt = (text: string): string => { 27 | const ivLength = 16; 28 | const iv = crypto.randomBytes(ivLength); 29 | const algorithm = 'aes-256-cbc'; 30 | const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex'); 31 | const cipher = crypto.createCipheriv(algorithm, key, iv); 32 | let encrypted = cipher.update(text, 'utf8', 'hex'); 33 | encrypted += cipher.final('hex'); 34 | return `${iv.toString('hex')}:${encrypted}`; 35 | }; 36 | 37 | export const decrypt = (encryptedText: string): string => { 38 | const [iv, encrypted] = encryptedText.split(':'); 39 | const algorithm = getEnvVariable('ALGORITHM'); 40 | const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex'); 41 | const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(iv, 'hex')); 42 | let decrypted = decipher.update(encrypted, 'hex', 'utf8'); 43 | decrypted += decipher.final('utf8'); 44 | return decrypted; 45 | }; -------------------------------------------------------------------------------- /src/components/atoms/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Stack } from "@mui/material"; 3 | 4 | interface LoaderProps { 5 | text: string; 6 | } 7 | 8 | export const Loader: React.FC = ({ text }) => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {text} 18 | 19 | ); 20 | }; 21 | 22 | const StyledParagraph = styled.p` 23 | font-size: large; 24 | font-family: inherit; 25 | color: #333; 26 | margin-top: 20px; 27 | `; 28 | 29 | const DotsContainer = styled.div` 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | gap: 15px; /* Space between dots */ 34 | `; 35 | 36 | const Dot = styled.div` 37 | width: 15px; 38 | height: 15px; 39 | background-color: #ff00c3; 40 | border-radius: 50%; 41 | animation: intensePulse 1.2s infinite ease-in-out both, bounceAndPulse 1.5s infinite ease-in-out; 42 | 43 | &:nth-child(1) { 44 | animation-delay: -0.3s; 45 | } 46 | &:nth-child(2) { 47 | animation-delay: -0.2s; 48 | } 49 | &:nth-child(3) { 50 | animation-delay: -0.1s; 51 | } 52 | &:nth-child(4) { 53 | animation-delay: 0s; 54 | } 55 | 56 | @keyframes bounceAndPulse { 57 | 0%, 100% { 58 | transform: translateY(0) scale(1); 59 | } 60 | 50% { 61 | transform: translateY(-10px) scale(1.3); 62 | } 63 | } 64 | 65 | @keyframes intensePulse { 66 | 0%, 100% { 67 | box-shadow: 0 0 0 0 rgba(255, 0, 195, 0.7); 68 | } 69 | 50% { 70 | box-shadow: 0 0 15px 10px rgba(255, 0, 195, 0.3); 71 | } 72 | } 73 | `; 74 | -------------------------------------------------------------------------------- /maxun-core/src/types/workflow.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright'; 2 | import { 3 | naryOperators, unaryOperators, operators, meta, 4 | } from './logic'; 5 | 6 | export type Operator = typeof operators[number]; 7 | export type UnaryOperator = typeof unaryOperators[number]; 8 | export type NAryOperator = typeof naryOperators[number]; 9 | 10 | export type Meta = typeof meta[number]; 11 | 12 | export type SelectorArray = string[]; 13 | 14 | type RegexableString = string | { '$regex': string }; 15 | 16 | type BaseConditions = { 17 | 'url': RegexableString, 18 | 'cookies': Record, 19 | 'selectors': SelectorArray, // (CSS/Playwright) selectors use their own logic, there is no reason (and several technical difficulties) to allow regular expression notation 20 | } & Record; 21 | 22 | export type Where = 23 | Partial<{ [key in NAryOperator]: Where[] }> & // either a logic operator (arity N) 24 | Partial<{ [key in UnaryOperator]: Where }> & // or an unary operator 25 | Partial; // or one of the base conditions 26 | 27 | type MethodNames = { 28 | [K in keyof T]: T[K] extends Function ? K : never; 29 | }[keyof T]; 30 | 31 | export type CustomFunctions = 'scrape' | 'scrapeSchema' | 'scroll' | 'screenshot' | 'script' | 'enqueueLinks' | 'flag' | 'scrapeList' | 'scrapeListAuto'; 32 | 33 | export type What = { 34 | action: MethodNames | CustomFunctions, 35 | args?: any[] 36 | }; 37 | 38 | export type PageState = Partial; 39 | 40 | export type ParamType = Record; 41 | 42 | export type MetaData = { 43 | name?: string, 44 | desc?: string, 45 | }; 46 | 47 | export interface WhereWhatPair { 48 | id?: string 49 | where: Where 50 | what: What[] 51 | } 52 | 53 | export type Workflow = WhereWhatPair[]; 54 | 55 | export type WorkflowFile = { 56 | meta?: MetaData, 57 | workflow: Workflow 58 | }; -------------------------------------------------------------------------------- /server/src/socket-connection/connection.ts: -------------------------------------------------------------------------------- 1 | import { Namespace, Socket } from 'socket.io'; 2 | import logger from "../logger"; 3 | import registerInputHandlers from '../browser-management/inputHandlers' 4 | 5 | /** 6 | * Opens a websocket canal for duplex data transfer and registers all handlers for this data for the recording session. 7 | * Uses socket.io dynamic namespaces for multiplexing the traffic from different running remote browser instances. 8 | * @param io dynamic namespace on the socket.io server 9 | * @param callback function called after the connection is created providing the socket resource 10 | * @category BrowserManagement 11 | */ 12 | export const createSocketConnection = ( 13 | io: Namespace, 14 | callback: (socket: Socket) => void, 15 | ) => { 16 | const onConnection = async (socket: Socket) => { 17 | logger.log('info', "Client connected " + socket.id); 18 | registerInputHandlers(socket); 19 | socket.on('disconnect', () => logger.log('info', "Client disconnected " + socket.id)); 20 | callback(socket); 21 | } 22 | 23 | io.on('connection', onConnection); 24 | }; 25 | 26 | /** 27 | * Opens a websocket canal for duplex data transfer for the recording run. 28 | * Uses socket.io dynamic namespaces for multiplexing the traffic from different running remote browser instances. 29 | * @param io dynamic namespace on the socket.io server 30 | * @param callback function called after the connection is created providing the socket resource 31 | * @category BrowserManagement 32 | */ 33 | export const createSocketConnectionForRun = ( 34 | io: Namespace, 35 | callback: (socket: Socket) => void, 36 | ) => { 37 | const onConnection = async (socket: Socket) => { 38 | logger.log('info', "Client connected " + socket.id); 39 | socket.on('disconnect', () => logger.log('info', "Client disconnected " + socket.id)); 40 | callback(socket); 41 | } 42 | 43 | io.on('connection', onConnection); 44 | }; 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- Base Stage --- 2 | FROM node:18 AS base 3 | WORKDIR /app 4 | 5 | # Copy shared package.json and install dependencies 6 | COPY package.json package-lock.json ./ 7 | COPY maxun-core/package.json ./maxun-core/package.json 8 | RUN npm install 9 | 10 | # --- Backend Build Stage --- 11 | FROM base AS backend-build 12 | WORKDIR /app 13 | 14 | # Copy TypeScript configs 15 | COPY tsconfig*.json ./ 16 | COPY server/tsconfig.json ./server/ 17 | 18 | # Copy ALL source code (both frontend and backend) 19 | COPY src ./src 20 | # Copy backend code and maxun-core 21 | COPY server/src ./server/src 22 | COPY maxun-core ./maxun-core 23 | 24 | # Install TypeScript globally and build 25 | RUN npm install -g typescript 26 | RUN npm run build:server 27 | 28 | # --- Frontend Build Stage --- 29 | FROM base AS frontend-build 30 | WORKDIR /app 31 | 32 | # Copy frontend code and configs 33 | COPY src ./src 34 | COPY index.html ./index.html 35 | COPY public ./public 36 | COPY vite.config.js ./ 37 | COPY tsconfig.json ./ 38 | 39 | # Build frontend 40 | RUN npm run build 41 | 42 | # --- Production Stage --- 43 | FROM nginx:alpine AS production 44 | 45 | # Install Node.js in the production image 46 | RUN apk add --update nodejs npm 47 | 48 | # Copy nginx configuration 49 | COPY nginx.conf /etc/nginx/conf.d/default.conf 50 | 51 | # Copy built frontend 52 | COPY --from=frontend-build /app/build /usr/share/nginx/html 53 | COPY --from=frontend-build /app/public/img /usr/share/nginx/html/img 54 | 55 | # Copy built backend and its dependencies 56 | WORKDIR /app 57 | COPY --from=backend-build /app/package*.json ./ 58 | COPY --from=backend-build /app/server/dist ./server/dist 59 | COPY --from=backend-build /app/maxun-core ./maxun-core 60 | COPY --from=backend-build /app/node_modules ./node_modules 61 | 62 | # Copy start script 63 | COPY docker-entrypoint.sh / 64 | RUN chmod +x /docker-entrypoint.sh 65 | 66 | EXPOSE 80 8080 67 | 68 | # Start both nginx and node server 69 | ENTRYPOINT ["/docker-entrypoint.sh"] 70 | -------------------------------------------------------------------------------- /src/api/proxy.ts: -------------------------------------------------------------------------------- 1 | import { default as axios } from "axios"; 2 | 3 | export const sendProxyConfig = async (proxyConfig: { server_url: string, username?: string, password?: string }): Promise => { 4 | try { 5 | const response = await axios.post(`http://localhost:8080/proxy/config`, proxyConfig); 6 | if (response.status === 200) { 7 | return response.data; 8 | } else { 9 | throw new Error(`Failed to submit proxy configuration. Status code: ${response.status}`); 10 | } 11 | } catch (error: any) { 12 | console.error('Error sending proxy configuration:', error.message || error); 13 | return false; 14 | } 15 | } 16 | 17 | export const getProxyConfig = async (): Promise<{ proxy_url: string, auth: boolean }> => { 18 | try { 19 | const response = await axios.get(`http://localhost:8080/proxy/config`); 20 | if (response.status === 200) { 21 | return response.data; 22 | } else { 23 | throw new Error(`Failed to fetch proxy configuration. Try again.`); 24 | } 25 | } catch (error: any) { 26 | console.log(error); 27 | return { proxy_url: '', auth: false }; 28 | } 29 | } 30 | 31 | export const testProxyConfig = async (): Promise<{ success: boolean }> => { 32 | try { 33 | const response = await axios.get(`http://localhost:8080/proxy/test`); 34 | if (response.status === 200) { 35 | return response.data; 36 | } else { 37 | throw new Error(`Failed to test proxy configuration. Try again.`); 38 | } 39 | } catch (error: any) { 40 | console.log(error); 41 | return { success: false }; 42 | } 43 | } 44 | 45 | export const deleteProxyConfig = async (): Promise => { 46 | try { 47 | const response = await axios.delete(`http://localhost:8080/proxy/config`); 48 | if (response.status === 200) { 49 | return response.data; 50 | } else { 51 | throw new Error(`Failed to delete proxy configuration. Try again.`); 52 | } 53 | } catch (error: any) { 54 | console.log(error); 55 | return false; 56 | } 57 | } -------------------------------------------------------------------------------- /src/components/molecules/ActionSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styled from "styled-components"; 3 | import { Button } from "@mui/material"; 4 | //import { ActionDescription } from "../organisms/RightSidePanel"; 5 | import * as Settings from "./action-settings"; 6 | import { useSocketStore } from "../../context/socket"; 7 | 8 | interface ActionSettingsProps { 9 | action: string; 10 | } 11 | 12 | export const ActionSettings = ({ action }: ActionSettingsProps) => { 13 | 14 | const settingsRef = useRef<{ getSettings: () => object }>(null); 15 | const { socket } = useSocketStore(); 16 | 17 | const DisplaySettings = () => { 18 | switch (action) { 19 | case "screenshot": 20 | return ; 21 | case 'scroll': 22 | return ; 23 | case 'scrape': 24 | return ; 25 | case 'scrapeSchema': 26 | return ; 27 | default: 28 | return null; 29 | } 30 | } 31 | 32 | const handleSubmit = (event: React.SyntheticEvent) => { 33 | event.preventDefault(); 34 | //get the data from settings 35 | const settings = settingsRef.current?.getSettings(); 36 | //Send notification to the server and generate the pair 37 | socket?.emit(`action`, { 38 | action, 39 | settings 40 | }); 41 | } 42 | 43 | return ( 44 |
45 | {/* Action settings: */} 46 | 47 |
48 | 49 | 61 | 62 |
63 |
64 | ); 65 | }; 66 | 67 | const ActionSettingsWrapper = styled.div<{ action: string }>` 68 | display: flex; 69 | flex-direction: column; 70 | align-items: ${({ action }) => action === 'script' ? 'stretch' : 'center'};; 71 | justify-content: center; 72 | margin-top: 20px; 73 | `; 74 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 4 | import { GlobalInfoProvider } from "./context/globalInfo"; 5 | import { PageWrapper } from "./pages/PageWrappper"; 6 | 7 | const theme = createTheme({ 8 | palette: { 9 | primary: { 10 | main: "#ff00c3", 11 | contrastText: "#ffffff", 12 | }, 13 | }, 14 | components: { 15 | MuiButton: { 16 | styleOverrides: { 17 | root: { 18 | // Default styles for all buttons (optional) 19 | textTransform: "none", 20 | }, 21 | containedPrimary: { 22 | // Styles for 'contained' variant with 'primary' color 23 | '&:hover': { 24 | backgroundColor: "#ff66d9", 25 | }, 26 | }, 27 | outlined: { 28 | // Apply white background for all 'outlined' variant buttons 29 | backgroundColor: "#ffffff", 30 | '&:hover': { 31 | backgroundColor: "#f0f0f0", // Optional lighter background on hover 32 | }, 33 | }, 34 | }, 35 | }, 36 | MuiLink: { 37 | styleOverrides: { 38 | root: { 39 | '&:hover': { 40 | color: "#ff00c3", 41 | }, 42 | }, 43 | }, 44 | }, 45 | MuiIconButton: { 46 | styleOverrides: { 47 | root: { 48 | // '&:hover': { 49 | // color: "#ff66d9", 50 | // }, 51 | }, 52 | }, 53 | }, 54 | MuiTab: { 55 | styleOverrides: { 56 | root: { 57 | textTransform: "none", 58 | }, 59 | }, 60 | }, 61 | MuiAlert: { 62 | styleOverrides: { 63 | standardInfo: { 64 | backgroundColor: "#fce1f4", 65 | color: "#ff00c3", 66 | '& .MuiAlert-icon': { 67 | color: "#ff00c3", 68 | }, 69 | }, 70 | }, 71 | }, 72 | MuiAlertTitle: { 73 | styleOverrides: { 74 | root: { 75 | '& .MuiAlert-icon': { 76 | color: "#ffffff", 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | }); 83 | 84 | 85 | function App() { 86 | return ( 87 | 88 | 89 | 90 | } /> 91 | 92 | 93 | 94 | ); 95 | } 96 | 97 | export default App; 98 | -------------------------------------------------------------------------------- /src/components/atoms/Highlighter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from "styled-components"; 4 | 5 | interface HighlighterProps { 6 | unmodifiedRect: DOMRect; 7 | displayedSelector: string; 8 | width: number; 9 | height: number; 10 | canvasRect: DOMRect; 11 | }; 12 | 13 | export const Highlighter = ({ unmodifiedRect, displayedSelector = '', width, height, canvasRect }: HighlighterProps) => { 14 | if (!unmodifiedRect) { 15 | return null; 16 | } else { 17 | const rect = { 18 | top: unmodifiedRect.top + canvasRect.top + window.scrollY, 19 | left: unmodifiedRect.left + canvasRect.left + window.scrollX, 20 | right: unmodifiedRect.right + canvasRect.left, 21 | bottom: unmodifiedRect.bottom + canvasRect.top, 22 | width: unmodifiedRect.width, 23 | height: unmodifiedRect.height, 24 | }; 25 | 26 | 27 | return ( 28 |
29 | 36 | {/* 41 | {displayedSelector} 42 | */} 43 |
44 | ); 45 | } 46 | } 47 | 48 | const HighlighterOutline = styled.div` 49 | box-sizing: border-box; 50 | pointer-events: none !important; 51 | position: fixed !important; 52 | background: #ff5d5b26 !important; 53 | outline: 4px solid #ff00c3 !important; 54 | //border: 4px solid #ff5d5b !important; 55 | z-index: 2147483647 !important; 56 | //border-radius: 5px; 57 | top: ${(p: HighlighterOutlineProps) => p.top}px; 58 | left: ${(p: HighlighterOutlineProps) => p.left}px; 59 | width: ${(p: HighlighterOutlineProps) => p.width}px; 60 | height: ${(p: HighlighterOutlineProps) => p.height}px; 61 | `; 62 | 63 | const HighlighterLabel = styled.div` 64 | pointer-events: none !important; 65 | position: fixed !important; 66 | background: #080a0b !important; 67 | color: white !important; 68 | padding: 8px !important; 69 | font-family: monospace !important; 70 | border-radius: 5px !important; 71 | z-index: 2147483647 !important; 72 | top: ${(p: HighlighterLabelProps) => p.top}px; 73 | left: ${(p: HighlighterLabelProps) => p.left}px; 74 | `; 75 | 76 | interface HighlighterLabelProps { 77 | top: number; 78 | left: number; 79 | } 80 | 81 | interface HighlighterOutlineProps { 82 | top: number; 83 | left: number; 84 | width: number; 85 | height: number; 86 | } 87 | -------------------------------------------------------------------------------- /server/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes, Model, Optional } from 'sequelize'; 2 | import sequelize from '../storage/db'; 3 | import Robot from './Robot'; 4 | 5 | interface UserAttributes { 6 | id: number; 7 | email: string; 8 | password: string; 9 | api_key_name?: string | null; 10 | api_key?: string | null; 11 | proxy_url?: string | null; 12 | proxy_username?: string | null; 13 | proxy_password?: string | null; 14 | } 15 | 16 | interface UserCreationAttributes extends Optional { } 17 | 18 | class User extends Model implements UserAttributes { 19 | public id!: number; 20 | public email!: string; 21 | public password!: string; 22 | public api_key_name!: string | null; 23 | public api_key!: string | null; 24 | public proxy_url!: string | null; 25 | public proxy_username!: string | null; 26 | public proxy_password!: string | null; 27 | } 28 | 29 | User.init( 30 | { 31 | id: { 32 | type: DataTypes.INTEGER, 33 | autoIncrement: true, 34 | primaryKey: true, 35 | }, 36 | email: { 37 | type: DataTypes.STRING, 38 | allowNull: false, 39 | unique: true, 40 | validate: { 41 | isEmail: true, 42 | }, 43 | }, 44 | password: { 45 | type: DataTypes.STRING, 46 | allowNull: false, 47 | }, 48 | api_key_name: { 49 | type: DataTypes.STRING, 50 | allowNull: true, 51 | defaultValue: 'Maxun API Key', 52 | }, 53 | api_key: { 54 | type: DataTypes.STRING, 55 | allowNull: true, 56 | }, 57 | proxy_url: { 58 | type: DataTypes.STRING, 59 | allowNull: true, 60 | }, 61 | proxy_username: { 62 | type: DataTypes.STRING, 63 | allowNull: true, 64 | // validate: { 65 | // isProxyPasswordRequired(value: string | null) { 66 | // if (value && !this.proxy_password) { 67 | // throw new Error('Proxy password is required when proxy username is provided'); 68 | // } 69 | // }, 70 | // }, 71 | }, 72 | proxy_password: { 73 | type: DataTypes.STRING, 74 | allowNull: true, 75 | }, 76 | }, 77 | { 78 | sequelize, 79 | tableName: 'user', 80 | } 81 | ); 82 | 83 | // User.hasMany(Robot, { 84 | // foreignKey: 'userId', 85 | // as: 'robots', // Alias for the relation 86 | // }); 87 | 88 | export default User; 89 | -------------------------------------------------------------------------------- /src/components/molecules/UrlForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from 'react'; 2 | import type { SyntheticEvent } from 'react'; 3 | import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; 4 | import { NavBarForm, NavBarInput } from "../atoms/form"; 5 | import { UrlFormButton } from "../atoms/buttons/buttons"; 6 | import { useSocketStore } from '../../context/socket'; 7 | import { Socket } from "socket.io-client"; 8 | 9 | // TODO: Bring back REFRESHHHHHHH 10 | type Props = { 11 | currentAddress: string; 12 | handleRefresh: (socket: Socket) => void; 13 | setCurrentAddress: (address: string) => void; 14 | }; 15 | 16 | export const UrlForm = ({ 17 | currentAddress, 18 | handleRefresh, 19 | setCurrentAddress, 20 | }: Props) => { 21 | const [address, setAddress] = useState(currentAddress); 22 | const { socket } = useSocketStore(); 23 | const lastSubmittedRef = useRef(''); 24 | 25 | const onChange = useCallback((event: SyntheticEvent): void => { 26 | setAddress((event.target as HTMLInputElement).value); 27 | }, []); 28 | 29 | const submitForm = useCallback((url: string): void => { 30 | // Add protocol if missing 31 | if (!/^(?:f|ht)tps?\:\/\//.test(url)) { 32 | url = "https://" + url; 33 | setAddress(url); // Update the input field to reflect protocol addition 34 | } 35 | 36 | try { 37 | // Validate the URL 38 | new URL(url); 39 | setCurrentAddress(url); 40 | lastSubmittedRef.current = url; // Update the last submitted URL 41 | } catch (e) { 42 | //alert(`ERROR: ${url} is not a valid url!`); 43 | console.log(e) 44 | } 45 | }, [setCurrentAddress]); 46 | 47 | const onSubmit = (event: SyntheticEvent): void => { 48 | event.preventDefault(); 49 | submitForm(address); 50 | }; 51 | 52 | // Sync internal state with currentAddress prop when it changes and auto-submit once 53 | useEffect(() => { 54 | setAddress(currentAddress); 55 | if (currentAddress !== '' && currentAddress !== lastSubmittedRef.current) { 56 | submitForm(currentAddress); 57 | } 58 | }, [currentAddress, submitForm]); 59 | 60 | return ( 61 | 62 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; -------------------------------------------------------------------------------- /src/api/workflow.ts: -------------------------------------------------------------------------------- 1 | import { WhereWhatPair, WorkflowFile } from "maxun-core"; 2 | import { emptyWorkflow } from "../shared/constants"; 3 | import { default as axios, AxiosResponse } from "axios"; 4 | 5 | export const getActiveWorkflow = async(id: string) : Promise => { 6 | try { 7 | const response = await axios.get(`http://localhost:8080/workflow/${id}`) 8 | if (response.status === 200) { 9 | return response.data; 10 | } else { 11 | throw new Error('Something went wrong when fetching a recorded workflow'); 12 | } 13 | } catch(error: any) { 14 | console.log(error); 15 | return emptyWorkflow; 16 | } 17 | }; 18 | 19 | export const getParamsOfActiveWorkflow = async(id: string) : Promise => { 20 | try { 21 | const response = await axios.get(`http://localhost:8080/workflow/params/${id}`) 22 | if (response.status === 200) { 23 | return response.data; 24 | } else { 25 | throw new Error('Something went wrong when fetching the parameters of the recorded workflow'); 26 | } 27 | } catch(error: any) { 28 | console.log(error); 29 | return null; 30 | } 31 | }; 32 | 33 | export const deletePair = async(index: number): Promise => { 34 | try { 35 | const response = await axios.delete(`http://localhost:8080/workflow/pair/${index}`); 36 | if (response.status === 200) { 37 | return response.data; 38 | } else { 39 | throw new Error('Something went wrong when fetching an updated workflow'); 40 | } 41 | } catch (error: any) { 42 | console.log(error); 43 | return emptyWorkflow; 44 | } 45 | }; 46 | 47 | export const AddPair = async(index: number, pair: WhereWhatPair): Promise => { 48 | try { 49 | const response = await axios.post(`http://localhost:8080/workflow/pair/${index}`, { 50 | pair, 51 | }, {headers: {'Content-Type': 'application/json'}}); 52 | if (response.status === 200) { 53 | return response.data; 54 | } else { 55 | throw new Error('Something went wrong when fetching an updated workflow'); 56 | } 57 | } catch (error: any) { 58 | console.log(error); 59 | return emptyWorkflow; 60 | } 61 | }; 62 | 63 | export const UpdatePair = async(index: number, pair: WhereWhatPair): Promise => { 64 | try { 65 | const response = await axios.put(`http://localhost:8080/workflow/pair/${index}`, { 66 | pair, 67 | }, {headers: {'Content-Type': 'application/json'}}); 68 | if (response.status === 200) { 69 | return response.data; 70 | } else { 71 | throw new Error('Something went wrong when fetching an updated workflow'); 72 | } 73 | } catch (error: any) { 74 | console.log(error); 75 | return emptyWorkflow; 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/molecules/BrowserRecordingSave.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Grid, Button, Box, Typography } from '@mui/material'; 3 | import { SaveRecording } from "./SaveRecording"; 4 | import { useGlobalInfoStore } from '../../context/globalInfo'; 5 | import { stopRecording } from "../../api/recording"; 6 | import { useNavigate } from 'react-router-dom'; 7 | import { GenericModal } from "../atoms/GenericModal"; 8 | 9 | const BrowserRecordingSave = () => { 10 | const [openModal, setOpenModal] = useState(false); 11 | const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore(); 12 | const navigate = useNavigate(); 13 | 14 | const goToMainMenu = async () => { 15 | if (browserId) { 16 | await stopRecording(browserId); 17 | notify('warning', 'Current Recording was terminated'); 18 | setBrowserId(null); 19 | } 20 | navigate('/'); 21 | }; 22 | 23 | return ( 24 | 25 | 26 |
40 | 43 | setOpenModal(false)} modalStyle={modalStyle}> 44 | 45 | Are you sure you want to discard the recording? 46 | 47 | 50 | 53 | 54 | 55 | 56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | export default BrowserRecordingSave 64 | 65 | const modalStyle = { 66 | top: '25%', 67 | left: '50%', 68 | transform: 'translate(-50%, -50%)', 69 | width: '30%', 70 | backgroundColor: 'background.paper', 71 | p: 4, 72 | height: 'fit-content', 73 | display: 'block', 74 | padding: '20px', 75 | }; -------------------------------------------------------------------------------- /server/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { Queue, Worker } from 'bullmq'; 2 | import IORedis from 'ioredis'; 3 | import logger from './logger'; 4 | import { handleRunRecording } from "./workflow-management/scheduler"; 5 | import Robot from './models/Robot'; 6 | import { computeNextRun } from './utils/schedule'; 7 | 8 | console.log('Environment variables:', { 9 | REDIS_HOST: process.env.REDIS_HOST, 10 | REDIS_PORT: process.env.REDIS_PORT, 11 | }); 12 | 13 | const connection = new IORedis({ 14 | host: process.env.REDIS_HOST, 15 | port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT, 10) : 6379, 16 | maxRetriesPerRequest: null, 17 | }); 18 | 19 | connection.on('connect', () => { 20 | console.log('Connected to Redis!'); 21 | }); 22 | 23 | connection.on('error', (err) => { 24 | console.error('Redis connection error:', err); 25 | }); 26 | 27 | const workflowQueue = new Queue('workflow', { connection }); 28 | 29 | const worker = new Worker('workflow', async job => { 30 | const { runId, userId, id } = job.data; 31 | try { 32 | const result = await handleRunRecording(id, userId); 33 | return result; 34 | } catch (error) { 35 | logger.error('Error running workflow:', error); 36 | throw error; 37 | } 38 | }, { connection }); 39 | 40 | worker.on('completed', async (job: any) => { 41 | logger.log(`info`, `Job ${job.id} completed for ${job.data.runId}`); 42 | const robot = await Robot.findOne({ where: { 'recording_meta.id': job.data.id } }); 43 | if (robot) { 44 | // Update `lastRunAt` to the current time 45 | const lastRunAt = new Date(); 46 | 47 | // Compute the next run date 48 | if (robot.schedule && robot.schedule.cronExpression && robot.schedule.timezone) { 49 | const nextRunAt = computeNextRun(robot.schedule.cronExpression, robot.schedule.timezone) || undefined; 50 | await robot.update({ 51 | schedule: { 52 | ...robot.schedule, 53 | lastRunAt, 54 | nextRunAt, 55 | }, 56 | }); 57 | } else { 58 | logger.error('Robot schedule, cronExpression, or timezone is missing.'); 59 | } 60 | } 61 | }); 62 | 63 | worker.on('failed', async (job: any, err) => { 64 | logger.log(`error`, `Job ${job.id} failed for ${job.data.runId}:`, err); 65 | }); 66 | 67 | console.log('Worker is running...'); 68 | 69 | async function jobCounts() { 70 | const jobCounts = await workflowQueue.getJobCounts(); 71 | console.log('Jobs:', jobCounts); 72 | } 73 | 74 | jobCounts(); 75 | 76 | process.on('SIGINT', () => { 77 | console.log('Worker shutting down...'); 78 | process.exit(); 79 | }); 80 | 81 | export { workflowQueue, worker }; 82 | 83 | export const temp = () => { 84 | console.log('temp'); 85 | } -------------------------------------------------------------------------------- /src/context/auth.tsx: -------------------------------------------------------------------------------- 1 | import { useReducer, createContext, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | interface AuthProviderProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | interface ActionType { 10 | type: 'LOGIN' | 'LOGOUT'; 11 | payload?: any; 12 | } 13 | 14 | type InitialStateType = { 15 | user: any; 16 | }; 17 | 18 | const initialState = { 19 | user: null, 20 | }; 21 | 22 | const AuthContext = createContext<{ 23 | state: InitialStateType; 24 | dispatch: React.Dispatch; 25 | }>({ 26 | state: initialState, 27 | dispatch: () => null, 28 | }); 29 | 30 | const reducer = (state: InitialStateType, action: ActionType) => { 31 | switch (action.type) { 32 | case 'LOGIN': 33 | return { 34 | ...state, 35 | user: action.payload, 36 | }; 37 | case 'LOGOUT': 38 | return { 39 | ...state, 40 | user: null, 41 | }; 42 | default: 43 | return state; 44 | } 45 | }; 46 | 47 | const AuthProvider = ({ children }: AuthProviderProps) => { 48 | const [state, dispatch] = useReducer(reducer, initialState); 49 | const navigate = useNavigate(); 50 | axios.defaults.withCredentials = true; 51 | 52 | useEffect(() => { 53 | const storedUser = window.localStorage.getItem('user'); 54 | if (storedUser) { 55 | dispatch({ type: 'LOGIN', payload: JSON.parse(storedUser) }); 56 | } 57 | }, []); 58 | 59 | axios.interceptors.response.use( 60 | function (response) { 61 | return response; 62 | }, 63 | function (error) { 64 | const res = error.response; 65 | if (res.status === 401 && res.config && !res.config.__isRetryRequest) { 66 | return new Promise((resolve, reject) => { 67 | axios 68 | .get('http://localhost:8080/auth/logout') 69 | .then(() => { 70 | console.log('/401 error > logout'); 71 | dispatch({ type: 'LOGOUT' }); 72 | window.localStorage.removeItem('user'); 73 | navigate('/login'); 74 | }) 75 | .catch((err) => { 76 | console.error('AXIOS INTERCEPTORS ERROR:', err); 77 | reject(error); 78 | }); 79 | }); 80 | } 81 | return Promise.reject(error); 82 | } 83 | ); 84 | 85 | return ( 86 | 87 | {children} 88 | 89 | ); 90 | }; 91 | 92 | export { AuthContext, AuthProvider }; -------------------------------------------------------------------------------- /maxun-core/src/utils/concurrency.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Concurrency class for running concurrent tasks while managing a limited amount of resources. 3 | */ 4 | export default class Concurrency { 5 | /** 6 | * Maximum number of workers running in parallel. If set to `null`, there is no limit. 7 | */ 8 | maxConcurrency: number = 1; 9 | 10 | /** 11 | * Number of currently active workers. 12 | */ 13 | activeWorkers: number = 0; 14 | 15 | /** 16 | * Queue of jobs waiting to be completed. 17 | */ 18 | private jobQueue: Function[] = []; 19 | 20 | /** 21 | * "Resolve" callbacks of the waitForCompletion() promises. 22 | */ 23 | private waiting: Function[] = []; 24 | 25 | /** 26 | * Constructs a new instance of concurrency manager. 27 | * @param {number} maxConcurrency Maximum number of workers running in parallel. 28 | */ 29 | constructor(maxConcurrency: number) { 30 | this.maxConcurrency = maxConcurrency; 31 | } 32 | 33 | /** 34 | * Takes a waiting job out of the queue and runs it. 35 | */ 36 | private runNextJob(): void { 37 | const job = this.jobQueue.pop(); 38 | 39 | if (job) { 40 | // console.debug("Running a job..."); 41 | job().then(() => { 42 | // console.debug("Job finished, running the next waiting job..."); 43 | this.runNextJob(); 44 | }); 45 | } else { 46 | // console.debug("No waiting job found!"); 47 | this.activeWorkers -= 1; 48 | if (this.activeWorkers === 0) { 49 | // console.debug("This concurrency manager is idle!"); 50 | this.waiting.forEach((x) => x()); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Pass a job (a time-demanding async function) to the concurrency manager. \ 57 | * The time of the job's execution depends on the concurrency manager itself 58 | * (given a generous enough `maxConcurrency` value, it might be immediate, 59 | * but this is not guaranteed). 60 | * @param worker Async function to be executed (job to be processed). 61 | */ 62 | addJob(job: () => Promise): void { 63 | // console.debug("Adding a worker!"); 64 | this.jobQueue.push(job); 65 | 66 | if (!this.maxConcurrency || this.activeWorkers < this.maxConcurrency) { 67 | this.runNextJob(); 68 | this.activeWorkers += 1; 69 | } else { 70 | // console.debug("No capacity to run a worker now, waiting!"); 71 | } 72 | } 73 | 74 | /** 75 | * Waits until there is no running nor waiting job. \ 76 | * If the concurrency manager is idle at the time of calling this function, 77 | * it waits until at least one job is compeleted (can be "presubscribed"). 78 | * @returns Promise, resolved after there is no running/waiting worker. 79 | */ 80 | waitForCompletion(): Promise { 81 | return new Promise((res) => { 82 | this.waiting.push(res); 83 | }); 84 | } 85 | } -------------------------------------------------------------------------------- /src/api/recording.ts: -------------------------------------------------------------------------------- 1 | import { default as axios, AxiosResponse } from "axios"; 2 | 3 | export const startRecording = async() : Promise => { 4 | try { 5 | const response = await axios.get('http://localhost:8080/record/start') 6 | if (response.status === 200) { 7 | return response.data; 8 | } else { 9 | throw new Error('Couldn\'t start recording'); 10 | } 11 | } catch(error: any) { 12 | return ''; 13 | } 14 | }; 15 | 16 | export const stopRecording = async (id: string): Promise => { 17 | await axios.get(`http://localhost:8080/record/stop/${id}`) 18 | .then((response : AxiosResponse) => { 19 | }) 20 | .catch((error: any) => { 21 | }); 22 | }; 23 | 24 | export const getActiveBrowserId = async(): Promise => { 25 | try { 26 | const response = await axios.get('http://localhost:8080/record/active'); 27 | if (response.status === 200) { 28 | return response.data; 29 | } else { 30 | throw new Error('Couldn\'t get active browser'); 31 | } 32 | } catch(error: any) { 33 | return ''; 34 | } 35 | }; 36 | 37 | export const interpretCurrentRecording = async(): Promise => { 38 | try { 39 | const response = await axios.get('http://localhost:8080/record/interpret'); 40 | if (response.status === 200) { 41 | return true; 42 | } else { 43 | throw new Error('Couldn\'t interpret current recording'); 44 | } 45 | } catch(error: any) { 46 | console.log(error); 47 | return false; 48 | } 49 | }; 50 | 51 | export const stopCurrentInterpretation = async(): Promise => { 52 | try { 53 | const response = await axios.get('http://localhost:8080/record/interpret/stop'); 54 | if (response.status === 200) { 55 | return; 56 | } else { 57 | throw new Error('Couldn\'t interpret current recording'); 58 | } 59 | } catch(error: any) { 60 | console.log(error); 61 | } 62 | }; 63 | 64 | export const getCurrentUrl = async (): Promise => { 65 | try { 66 | const response = await axios.get('http://localhost:8080/record/active/url'); 67 | if (response.status === 200) { 68 | return response.data; 69 | } else { 70 | throw new Error('Couldn\'t retrieve stored recordings'); 71 | } 72 | } catch(error: any) { 73 | console.log(error); 74 | return null; 75 | } 76 | }; 77 | 78 | export const getCurrentTabs = async (): Promise => { 79 | try { 80 | const response = await axios.get('http://localhost:8080/record/active/tabs'); 81 | if (response.status === 200) { 82 | return response.data; 83 | } else { 84 | throw new Error('Couldn\'t retrieve stored recordings'); 85 | } 86 | } catch(error: any) { 87 | console.log(error); 88 | return null; 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/molecules/BrowserTabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, IconButton, Tab, Tabs } from "@mui/material"; 3 | import { AddButton } from "../atoms/buttons/AddButton"; 4 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 5 | import { Close } from "@mui/icons-material"; 6 | 7 | interface BrowserTabsProp { 8 | tabs: string[], 9 | handleTabChange: (index: number) => void, 10 | handleAddNewTab: () => void, 11 | handleCloseTab: (index: number) => void, 12 | handleChangeIndex: (index: number) => void; 13 | tabIndex: number 14 | } 15 | 16 | export const BrowserTabs = ( 17 | { 18 | tabs, handleTabChange, handleAddNewTab, 19 | handleCloseTab, handleChangeIndex, tabIndex 20 | }: BrowserTabsProp) => { 21 | 22 | let tabWasClosed = false; 23 | 24 | const { width } = useBrowserDimensionsStore(); 25 | 26 | const handleChange = (event: React.SyntheticEvent, newValue: number) => { 27 | if (!tabWasClosed) { 28 | handleChangeIndex(newValue); 29 | } 30 | }; 31 | 32 | return ( 33 | 39 | 40 | 44 | {tabs.map((tab, index) => { 45 | return ( 46 | { 54 | tabWasClosed = true; 55 | handleCloseTab(index); 56 | }} disabled={tabs.length === 1} 57 | />} 58 | iconPosition="end" 59 | onClick={() => { 60 | if (!tabWasClosed) { 61 | handleTabChange(index) 62 | } 63 | } 64 | } 65 | label={tab} 66 | /> 67 | ); 68 | })} 69 | 70 | 71 | {/* */} 72 | 73 | ); 74 | } 75 | 76 | interface CloseButtonProps { 77 | closeTab: () => void; 78 | disabled: boolean; 79 | } 80 | 81 | const CloseButton = ({ closeTab, disabled }: CloseButtonProps) => { 82 | return ( 83 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /server/src/workflow-management/utils.ts: -------------------------------------------------------------------------------- 1 | import { Action, ActionType, TagName } from "../types"; 2 | 3 | /** 4 | * A helper function to get the best selector for the specific user action. 5 | * @param action The user action. 6 | * @returns {string|null} 7 | * @category WorkflowManagement-Selectors 8 | */ 9 | export const getBestSelectorForAction = (action: Action) => { 10 | switch (action.type) { 11 | case ActionType.Click: 12 | case ActionType.Hover: 13 | case ActionType.DragAndDrop: { 14 | const selectors = action.selectors; 15 | // less than 25 characters, and element only has text inside 16 | const textSelector = 17 | selectors?.text?.length != null && 18 | selectors?.text?.length < 25 && 19 | action.hasOnlyText 20 | ? `text=${selectors.text}` 21 | : null; 22 | 23 | if (action.tagName === TagName.Input) { 24 | return ( 25 | selectors.testIdSelector ?? 26 | selectors?.id ?? 27 | selectors?.formSelector ?? 28 | selectors?.accessibilitySelector ?? 29 | selectors?.generalSelector ?? 30 | selectors?.attrSelector ?? 31 | null 32 | ); 33 | } 34 | if (action.tagName === TagName.A) { 35 | return ( 36 | selectors.testIdSelector ?? 37 | selectors?.id ?? 38 | selectors?.hrefSelector ?? 39 | selectors?.accessibilitySelector ?? 40 | selectors?.generalSelector ?? 41 | selectors?.attrSelector ?? 42 | null 43 | ); 44 | } 45 | 46 | // Prefer text selectors for spans, ems over general selectors 47 | if ( 48 | action.tagName === TagName.Span || 49 | action.tagName === TagName.EM || 50 | action.tagName === TagName.Cite || 51 | action.tagName === TagName.B || 52 | action.tagName === TagName.Strong 53 | ) { 54 | return ( 55 | selectors.testIdSelector ?? 56 | selectors?.id ?? 57 | selectors?.accessibilitySelector ?? 58 | selectors?.hrefSelector ?? 59 | textSelector ?? 60 | selectors?.generalSelector ?? 61 | selectors?.attrSelector ?? 62 | null 63 | ); 64 | } 65 | return ( 66 | selectors.testIdSelector ?? 67 | selectors?.id ?? 68 | selectors?.accessibilitySelector ?? 69 | selectors?.hrefSelector ?? 70 | selectors?.generalSelector ?? 71 | selectors?.attrSelector ?? 72 | null 73 | ); 74 | } 75 | case ActionType.Input: 76 | case ActionType.Keydown: { 77 | const selectors = action.selectors; 78 | return ( 79 | selectors.testIdSelector ?? 80 | selectors?.id ?? 81 | selectors?.formSelector ?? 82 | selectors?.accessibilitySelector ?? 83 | selectors?.generalSelector ?? 84 | selectors?.attrSelector ?? 85 | null 86 | ); 87 | } 88 | default: 89 | break; 90 | } 91 | return null; 92 | } -------------------------------------------------------------------------------- /src/components/molecules/LeftSidePanelSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, MenuItem, TextField, Typography } from "@mui/material"; 3 | import { Dropdown } from "../atoms/DropdownMui"; 4 | import { RunSettings } from "./RunSettings"; 5 | import { useSocketStore } from "../../context/socket"; 6 | 7 | interface LeftSidePanelSettingsProps { 8 | params: any[] 9 | settings: RunSettings, 10 | setSettings: (setting: RunSettings) => void 11 | } 12 | 13 | export const LeftSidePanelSettings = ({params, settings, setSettings}: LeftSidePanelSettingsProps) => { 14 | const { socket } = useSocketStore(); 15 | 16 | return ( 17 |
18 | { params.length !== 0 && ( 19 | 20 | Parameters: 21 | { params?.map((item: string, index: number) => { 22 | return setSettings( 30 | { 31 | ...settings, 32 | params: settings.params 33 | ? { 34 | ...settings.params, 35 | [item]: e.target.value, 36 | } 37 | : { 38 | [item]: e.target.value, 39 | }, 40 | })} 41 | /> 42 | }) } 43 | 44 | )} 45 | Interpreter: 46 | setSettings( 51 | { 52 | ...settings, 53 | maxConcurrency: parseInt(e.target.value), 54 | })} 55 | defaultValue={settings.maxConcurrency} 56 | /> 57 | setSettings( 63 | { 64 | ...settings, 65 | maxRepeats: parseInt(e.target.value), 66 | })} 67 | defaultValue={settings.maxRepeats} 68 | /> 69 | setSettings( 74 | { 75 | ...settings, 76 | debug: e.target.value === "true", 77 | })} 78 | > 79 | true 80 | false 81 | 82 | 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/pages/PageWrappper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { NavBar } from "../components/molecules/NavBar"; 3 | import { SocketProvider } from "../context/socket"; 4 | import { BrowserDimensionsProvider } from "../context/browserDimensions"; 5 | import { AuthProvider } from '../context/auth'; 6 | import { RecordingPage } from "./RecordingPage"; 7 | import { MainPage } from "./MainPage"; 8 | import { useGlobalInfoStore } from "../context/globalInfo"; 9 | import { getActiveBrowserId } from "../api/recording"; 10 | import { AlertSnackbar } from "../components/atoms/AlertSnackbar"; 11 | import Login from './Login'; 12 | import Register from './Register'; 13 | import UserRoute from '../routes/userRoute'; 14 | import { Routes, Route, useNavigate } from 'react-router-dom'; 15 | 16 | export const PageWrapper = () => { 17 | const [open, setOpen] = useState(false); 18 | 19 | const navigate = useNavigate(); 20 | 21 | const { browserId, setBrowserId, notification, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore(); 22 | 23 | const handleEditRecording = (recordingId: string, fileName: string) => { 24 | setRecordingName(fileName); 25 | setRecordingId(recordingId); 26 | setBrowserId('new-recording'); 27 | navigate('/recording'); 28 | } 29 | 30 | const isNotification = (): boolean => { 31 | if (notification.isOpen && !open) { 32 | setOpen(true); 33 | } 34 | return notification.isOpen; 35 | } 36 | 37 | useEffect(() => { 38 | const isRecordingInProgress = async () => { 39 | const id = await getActiveBrowserId(); 40 | if (id) { 41 | setBrowserId(id); 42 | navigate('/recording'); 43 | } 44 | } 45 | isRecordingInProgress(); 46 | }, []); 47 | 48 | return ( 49 |
50 | 51 | 52 | 53 | {!browserId && } 54 | 55 | }> 56 | } /> 57 | 58 | }> 59 | 61 | 62 | 63 | } /> 64 | 65 | } 68 | /> 69 | } 72 | /> 73 | 74 | 75 | 76 | 77 | {isNotification() ? 78 | 81 | : null 82 | } 83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /server/src/models/Robot.ts: -------------------------------------------------------------------------------- 1 | import { Model, DataTypes, Optional } from 'sequelize'; 2 | import sequelize from '../storage/db'; 3 | import { WorkflowFile, Where, What, WhereWhatPair } from 'maxun-core'; 4 | 5 | interface RobotMeta { 6 | name: string; 7 | id: string; 8 | createdAt: string; 9 | pairs: number; 10 | updatedAt: string; 11 | params: any[]; 12 | } 13 | 14 | interface RobotWorkflow { 15 | workflow: WhereWhatPair[]; 16 | } 17 | 18 | interface RobotAttributes { 19 | id: string; 20 | userId?: number; 21 | recording_meta: RobotMeta; 22 | recording: RobotWorkflow; 23 | google_sheet_email?: string | null; 24 | google_sheet_name?: string | null; 25 | google_sheet_id?: string | null; 26 | google_access_token?: string | null; 27 | google_refresh_token?: string | null; 28 | schedule?: ScheduleConfig | null; 29 | } 30 | 31 | interface ScheduleConfig { 32 | runEvery: number; 33 | runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS'; 34 | startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; 35 | atTimeStart?: string; 36 | atTimeEnd?: string; 37 | timezone: string; 38 | lastRunAt?: Date; 39 | nextRunAt?: Date; 40 | dayOfMonth?: string; 41 | cronExpression?: string; 42 | } 43 | 44 | interface RobotCreationAttributes extends Optional { } 45 | 46 | class Robot extends Model implements RobotAttributes { 47 | public id!: string; 48 | public userId!: number; 49 | public recording_meta!: RobotMeta; 50 | public recording!: RobotWorkflow; 51 | public google_sheet_email!: string | null; 52 | public google_sheet_name?: string | null; 53 | public google_sheet_id?: string | null; 54 | public google_access_token!: string | null; 55 | public google_refresh_token!: string | null; 56 | public schedule!: ScheduleConfig | null; 57 | } 58 | 59 | Robot.init( 60 | { 61 | id: { 62 | type: DataTypes.UUID, 63 | defaultValue: DataTypes.UUIDV4, 64 | primaryKey: true, 65 | }, 66 | userId: { 67 | type: DataTypes.INTEGER, 68 | allowNull: false, 69 | }, 70 | recording_meta: { 71 | type: DataTypes.JSONB, 72 | allowNull: false, 73 | }, 74 | recording: { 75 | type: DataTypes.JSONB, 76 | allowNull: false, 77 | }, 78 | google_sheet_email: { 79 | type: DataTypes.STRING, 80 | allowNull: true, 81 | }, 82 | google_sheet_name: { 83 | type: DataTypes.STRING, 84 | allowNull: true, 85 | }, 86 | google_sheet_id: { 87 | type: DataTypes.STRING, 88 | allowNull: true, 89 | }, 90 | google_access_token: { 91 | type: DataTypes.STRING, 92 | allowNull: true, 93 | }, 94 | google_refresh_token: { 95 | type: DataTypes.STRING, 96 | allowNull: true, 97 | }, 98 | schedule: { 99 | type: DataTypes.JSONB, 100 | allowNull: true, 101 | }, 102 | }, 103 | { 104 | sequelize, 105 | tableName: 'robot', 106 | timestamps: false, 107 | } 108 | ); 109 | 110 | // Robot.hasMany(Run, { 111 | // foreignKey: 'robotId', 112 | // as: 'runs', // Alias for the relation 113 | // }); 114 | 115 | export default Robot; -------------------------------------------------------------------------------- /server/src/models/Run.ts: -------------------------------------------------------------------------------- 1 | import { Model, DataTypes, Optional } from 'sequelize'; 2 | import sequelize from '../storage/db'; 3 | import Robot from './Robot'; 4 | 5 | interface InterpreterSettings { 6 | maxConcurrency: number; 7 | maxRepeats: number; 8 | debug: boolean; 9 | } 10 | 11 | interface RunAttributes { 12 | id: string; 13 | status: string; 14 | name: string; 15 | robotId: string; 16 | robotMetaId: string; 17 | startedAt: string; 18 | finishedAt: string; 19 | browserId: string; 20 | interpreterSettings: InterpreterSettings; 21 | log: string; 22 | runId: string; 23 | runByUserId?: string; 24 | runByScheduleId?: string; 25 | runByAPI?: boolean; 26 | serializableOutput: Record; 27 | binaryOutput: Record; 28 | } 29 | 30 | interface RunCreationAttributes extends Optional { } 31 | 32 | class Run extends Model implements RunAttributes { 33 | public id!: string; 34 | public status!: string; 35 | public name!: string; 36 | public robotId!: string; 37 | public robotMetaId!: string; 38 | public startedAt!: string; 39 | public finishedAt!: string; 40 | public browserId!: string; 41 | public interpreterSettings!: InterpreterSettings; 42 | public log!: string; 43 | public runId!: string; 44 | public runByUserId!: string; 45 | public runByScheduleId!: string; 46 | public runByAPI!: boolean; 47 | public serializableOutput!: Record; 48 | public binaryOutput!: Record; 49 | } 50 | 51 | Run.init( 52 | { 53 | id: { 54 | type: DataTypes.UUID, 55 | defaultValue: DataTypes.UUIDV4, 56 | primaryKey: true, 57 | }, 58 | status: { 59 | type: DataTypes.STRING(50), 60 | allowNull: false, 61 | }, 62 | name: { 63 | type: DataTypes.STRING(255), 64 | allowNull: false, 65 | }, 66 | robotId: { 67 | type: DataTypes.UUID, 68 | allowNull: false, 69 | references: { 70 | model: Robot, 71 | key: 'id', 72 | }, 73 | }, 74 | robotMetaId: { 75 | type: DataTypes.UUID, 76 | allowNull: false, 77 | }, 78 | startedAt: { 79 | type: DataTypes.STRING(255), 80 | allowNull: false, 81 | }, 82 | finishedAt: { 83 | type: DataTypes.STRING(255), 84 | allowNull: false, 85 | }, 86 | browserId: { 87 | type: DataTypes.UUID, 88 | allowNull: false, 89 | }, 90 | interpreterSettings: { 91 | type: DataTypes.JSONB, 92 | allowNull: false, 93 | }, 94 | log: { 95 | type: DataTypes.TEXT, 96 | allowNull: true, 97 | }, 98 | runId: { 99 | type: DataTypes.UUID, 100 | allowNull: false, 101 | }, 102 | runByUserId: { 103 | type: DataTypes.INTEGER, 104 | allowNull: true, 105 | }, 106 | runByScheduleId: { 107 | type: DataTypes.UUID, 108 | allowNull: true, 109 | }, 110 | runByAPI: { 111 | type: DataTypes.BOOLEAN, 112 | allowNull: true, 113 | }, 114 | serializableOutput: { 115 | type: DataTypes.JSONB, 116 | allowNull: true, 117 | }, 118 | binaryOutput: { 119 | type: DataTypes.JSONB, 120 | allowNull: true, 121 | defaultValue: {}, 122 | }, 123 | }, 124 | { 125 | sequelize, 126 | tableName: 'run', 127 | timestamps: false, 128 | } 129 | ); 130 | 131 | export default Run; -------------------------------------------------------------------------------- /src/components/molecules/BrowserNavBar.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | FC, 3 | } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import ReplayIcon from '@mui/icons-material/Replay'; 7 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 8 | import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; 9 | 10 | import { NavBarButton } from '../atoms/buttons/buttons'; 11 | import { UrlForm } from './UrlForm'; 12 | import { useCallback, useEffect, useState } from "react"; 13 | import { useSocketStore } from "../../context/socket"; 14 | import { getCurrentUrl } from "../../api/recording"; 15 | import { useGlobalInfoStore } from '../../context/globalInfo'; 16 | 17 | const StyledNavBar = styled.div<{ browserWidth: number }>` 18 | display: flex; 19 | padding: 12px 0px; 20 | background-color: #f6f6f6; 21 | width: ${({ browserWidth }) => browserWidth}px; 22 | border-radius: 0px 5px 0px 0px; 23 | `; 24 | 25 | interface NavBarProps { 26 | browserWidth: number; 27 | handleUrlChanged: (url: string) => void; 28 | }; 29 | 30 | const BrowserNavBar: FC = ({ 31 | browserWidth, 32 | handleUrlChanged, 33 | }) => { 34 | 35 | const { socket } = useSocketStore(); 36 | const { recordingUrl, setRecordingUrl } = useGlobalInfoStore(); 37 | 38 | const handleRefresh = useCallback((): void => { 39 | socket?.emit('input:refresh'); 40 | }, [socket]); 41 | 42 | const handleGoTo = useCallback((address: string): void => { 43 | socket?.emit('input:url', address); 44 | }, [socket]); 45 | 46 | const handleCurrentUrlChange = useCallback((url: string) => { 47 | handleUrlChanged(url); 48 | setRecordingUrl(url); 49 | }, [handleUrlChanged, recordingUrl]); 50 | 51 | useEffect(() => { 52 | getCurrentUrl().then((response) => { 53 | if (response) { 54 | handleUrlChanged(response); 55 | } 56 | }).catch((error) => { 57 | console.log("Fetching current url failed"); 58 | }) 59 | }, []); 60 | 61 | useEffect(() => { 62 | if (socket) { 63 | socket.on('urlChanged', handleCurrentUrlChange); 64 | } 65 | return () => { 66 | if (socket) { 67 | socket.off('urlChanged', handleCurrentUrlChange); 68 | } 69 | } 70 | }, [socket, handleCurrentUrlChange]) 71 | 72 | const addAddress = (address: string) => { 73 | if (socket) { 74 | handleUrlChanged(address); 75 | setRecordingUrl(address); 76 | handleGoTo(address); 77 | } 78 | }; 79 | 80 | return ( 81 | 82 | { 85 | socket?.emit('input:back'); 86 | }} 87 | disabled={false} 88 | > 89 | 90 | 91 | 92 | { 95 | socket?.emit('input:forward'); 96 | }} 97 | disabled={false} 98 | > 99 | 100 | 101 | 102 | { 105 | if (socket) { 106 | handleRefresh() 107 | } 108 | }} 109 | disabled={false} 110 | > 111 | 112 | 113 | 114 | 119 | 120 | ); 121 | } 122 | 123 | export default BrowserNavBar; 124 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import path from 'path'; 3 | import http from 'http'; 4 | import cors from 'cors'; 5 | import dotenv from 'dotenv'; 6 | dotenv.config(); 7 | import { record, workflow, storage, auth, integration, proxy } from './routes'; 8 | import { BrowserPool } from "./browser-management/classes/BrowserPool"; 9 | import logger from './logger'; 10 | import { connectDB, syncDB } from './storage/db' 11 | import bodyParser from 'body-parser'; 12 | import cookieParser from 'cookie-parser'; 13 | import csrf from 'csurf'; 14 | import { SERVER_PORT } from "./constants/config"; 15 | import { Server } from "socket.io"; 16 | import { readdirSync } from "fs" 17 | import { fork } from 'child_process'; 18 | import { capture } from "./utils/analytics"; 19 | import swaggerUi from 'swagger-ui-express'; 20 | import swaggerSpec from './swagger/config'; 21 | 22 | const app = express(); 23 | app.use(cors({ 24 | origin: 'http://localhost:5173', 25 | credentials: true, 26 | })); 27 | app.use(express.json()); 28 | 29 | const server = http.createServer(app); 30 | 31 | /** 32 | * Globally exported singleton instance of socket.io for socket communication with the client. 33 | * @type {Server} 34 | */ 35 | export const io = new Server(server); 36 | 37 | /** 38 | * {@link BrowserPool} globally exported singleton instance for managing browsers. 39 | */ 40 | export const browserPool = new BrowserPool(); 41 | 42 | app.use(bodyParser.json({ limit: '10mb' })) 43 | app.use(bodyParser.urlencoded({ extended: true, limit: '10mb', parameterLimit: 9000 })); 44 | // parse cookies - "cookie" is true in csrfProtection 45 | app.use(cookieParser()) 46 | 47 | app.use('/record', record); 48 | app.use('/workflow', workflow); 49 | app.use('/storage', storage); 50 | app.use('/auth', auth); 51 | app.use('/integration', integration); 52 | app.use('/proxy', proxy); 53 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); 54 | 55 | readdirSync(path.join(__dirname, 'api')).forEach((r) => { 56 | const route = require(path.join(__dirname, 'api', r)); 57 | const router = route.default || route; // Use .default if available, fallback to route 58 | if (typeof router === 'function') { 59 | app.use('/api', router); // Use the default export or named router 60 | } else { 61 | console.error(`Error: ${r} does not export a valid router`); 62 | } 63 | }); 64 | 65 | // Check if we're running in production or development 66 | const isProduction = process.env.NODE_ENV === 'production'; 67 | const workerPath = path.resolve(__dirname, isProduction ? './worker.js' : '/worker.ts'); 68 | 69 | // Fork the worker process 70 | const workerProcess = fork(workerPath, [], { 71 | execArgv: isProduction ? ['--inspect=8081'] : ['--inspect=5859'], 72 | }); 73 | 74 | workerProcess.on('message', (message) => { 75 | console.log(`Message from worker: ${message}`); 76 | }); 77 | workerProcess.on('error', (error) => { 78 | console.error(`Error in worker: ${error}`); 79 | }); 80 | workerProcess.on('exit', (code) => { 81 | console.log(`Worker exited with code: ${code}`); 82 | }); 83 | 84 | app.get('/', function (req, res) { 85 | capture( 86 | 'maxun-oss-server-run', { 87 | event: 'server_started', 88 | } 89 | ); 90 | return res.send('Maxun server started 🚀'); 91 | }); 92 | 93 | server.listen(SERVER_PORT, async () => { 94 | await connectDB(); 95 | await syncDB(); 96 | logger.log('info', `Server listening on port ${SERVER_PORT}`); 97 | }); 98 | 99 | process.on('SIGINT', () => { 100 | console.log('Main app shutting down...'); 101 | workerProcess.kill(); 102 | process.exit(); 103 | }); 104 | -------------------------------------------------------------------------------- /src/components/organisms/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Tabs from '@mui/material/Tabs'; 3 | import Tab from '@mui/material/Tab'; 4 | import Box from '@mui/material/Box'; 5 | import { Paper, Button } from "@mui/material"; 6 | import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, Link, CloudQueue } from "@mui/icons-material"; 7 | 8 | interface MainMenuProps { 9 | value: string; 10 | handleChangeContent: (newValue: string) => void; 11 | } 12 | 13 | export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { 14 | 15 | const handleChange = (event: React.SyntheticEvent, newValue: string) => { 16 | handleChangeContent(newValue); 17 | }; 18 | 19 | return ( 20 | 30 | 34 | 42 | } 51 | iconPosition="start" 52 | /> 53 | } 62 | iconPosition="start" 63 | /> 64 | } 73 | iconPosition="start" 74 | /> 75 | } 84 | iconPosition="start" 85 | /> 86 | 87 |
88 | 89 | 92 | 95 | 96 |
97 |
98 | ); 99 | } 100 | 101 | const buttonStyles = { 102 | justifyContent: 'flex-start', 103 | textAlign: 'left', 104 | fontSize: 'medium', 105 | padding: '6px 16px 6px 22px', 106 | minHeight: '48px', 107 | minWidth: '100%', 108 | display: 'flex', 109 | alignItems: 'center', 110 | textTransform: 'none', 111 | color: '#6C6C6C !important', 112 | }; -------------------------------------------------------------------------------- /server/src/workflow-management/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A group of functions for storing recordings on the file system. 3 | * Functions are asynchronous to unload the server from heavy file system operations. 4 | */ 5 | import fs from 'fs'; 6 | import * as path from "path"; 7 | 8 | /** 9 | * Reads a file from path and returns its content as a string. 10 | * @param path The path to the file. 11 | * @returns {Promise} 12 | * @category WorkflowManagement-Storage 13 | */ 14 | export const readFile = (path: string): Promise => { 15 | return new Promise((resolve, reject) => { 16 | fs.readFile(path, 'utf8', (err, data) => { 17 | if (err) { 18 | reject(err); 19 | } else { 20 | resolve(data); 21 | } 22 | }); 23 | }); 24 | }; 25 | 26 | /** 27 | * Writes a string to a file. If the file already exists, it is overwritten. 28 | * @param path The path to the file. 29 | * @param data The data to write to the file. 30 | * @returns {Promise} 31 | * @category WorkflowManagement-Storage 32 | */ 33 | export const saveFile = (path: string, data: string): Promise => { 34 | return new Promise((resolve, reject) => { 35 | fs.writeFile(path, data, (err) => { 36 | if (err) { 37 | reject(err); 38 | } else { 39 | resolve(); 40 | } 41 | }); 42 | }); 43 | }; 44 | 45 | /** 46 | * Deletes a file from the file system. 47 | * @param path The path to the file. 48 | * @returns {Promise} 49 | * @category WorkflowManagement-Storage 50 | */ 51 | export const deleteFile = (path: string): Promise => { 52 | return new Promise((resolve, reject) => { 53 | fs.unlink(path, (err) => { 54 | if (err) { 55 | reject(err); 56 | } else { 57 | resolve(); 58 | } 59 | }); 60 | }); 61 | }; 62 | 63 | /** 64 | * A helper function to apply a callback to the all resolved 65 | * promises made out of an array of the items. 66 | * @param items An array of items. 67 | * @param block The function to call for each item after the promise for it was resolved. 68 | * @returns {Promise} 69 | * @category WorkflowManagement-Storage 70 | */ 71 | function promiseAllP(items: any, block: any) { 72 | let promises: any = []; 73 | items.forEach(function(item : any, index: number) { 74 | promises.push( function(item,i) { 75 | return new Promise(function(resolve, reject) { 76 | // @ts-ignore 77 | return block.apply(this,[item,index,resolve,reject]); 78 | }); 79 | }(item,index)) 80 | }); 81 | return Promise.all(promises); 82 | } 83 | 84 | /** 85 | * Reads all files from a directory and returns an array of their contents. 86 | * @param dirname The path to the directory. 87 | * @category WorkflowManagement-Storage 88 | * @returns {Promise} 89 | */ 90 | export const readFiles = (dirname: string): Promise => { 91 | return new Promise((resolve, reject) => { 92 | fs.readdir(dirname, function(err, filenames) { 93 | if (err) return reject(err); 94 | promiseAllP(filenames.filter((filename: string) => !filename.startsWith('.')), 95 | (filename: string, index : number, resolve: any, reject: any) => { 96 | fs.readFile(path.resolve(dirname, filename), 'utf-8', function(err, content) { 97 | if (err) return reject(err); 98 | return resolve(content); 99 | }); 100 | }) 101 | .then(results => { 102 | return resolve(results); 103 | }) 104 | .catch(error => { 105 | return reject(error); 106 | }); 107 | }); 108 | }); 109 | } 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maxun", 3 | "version": "0.0.1", 4 | "author": "Maxun", 5 | "license": "AGPL-3.0-or-later", 6 | "dependencies": { 7 | "@cliqz/adblocker-playwright": "^1.30.0", 8 | "@emotion/react": "^11.9.0", 9 | "@emotion/styled": "^11.8.1", 10 | "@mui/icons-material": "^5.5.1", 11 | "@mui/lab": "^5.0.0-alpha.80", 12 | "@mui/material": "^5.6.2", 13 | "@react-oauth/google": "^0.12.1", 14 | "@testing-library/react": "^13.1.1", 15 | "@testing-library/user-event": "^13.5.0", 16 | "@types/bcrypt": "^5.0.2", 17 | "@types/body-parser": "^1.19.5", 18 | "@types/csurf": "^1.11.5", 19 | "@types/jsonwebtoken": "^9.0.7", 20 | "@types/node": "22.7.9", 21 | "@types/react": "^18.0.5", 22 | "@types/react-dom": "^18.0.1", 23 | "@types/uuid": "^8.3.4", 24 | "axios": "^0.26.0", 25 | "bcrypt": "^5.1.1", 26 | "body-parser": "^1.20.3", 27 | "buffer": "^6.0.3", 28 | "bullmq": "^5.12.15", 29 | "cookie-parser": "^1.4.6", 30 | "cors": "^2.8.5", 31 | "cron-parser": "^4.9.0", 32 | "cross-fetch": "^4.0.0", 33 | "csurf": "^1.11.0", 34 | "dotenv": "^16.0.0", 35 | "express": "^4.17.2", 36 | "fortawesome": "^0.0.1-security", 37 | "google-auth-library": "^9.14.1", 38 | "googleapis": "^144.0.0", 39 | "ioredis": "^5.4.1", 40 | "joi": "^17.6.0", 41 | "jsonwebtoken": "^9.0.2", 42 | "loglevel": "^1.8.0", 43 | "loglevel-plugin-remote": "^0.6.8", 44 | "maxun-core": "^0.0.3", 45 | "minio": "^8.0.1", 46 | "moment-timezone": "^0.5.45", 47 | "node-cron": "^3.0.3", 48 | "pg": "^8.13.0", 49 | "playwright": "^1.20.1", 50 | "playwright-extra": "^4.3.6", 51 | "posthog-node": "^4.2.1", 52 | "prismjs": "^1.28.0", 53 | "puppeteer-extra-plugin-stealth": "^2.11.2", 54 | "react": "^18.0.0", 55 | "react-dom": "^18.0.0", 56 | "react-highlight": "0.15.0", 57 | "react-router-dom": "^6.26.1", 58 | "react-simple-code-editor": "^0.11.2", 59 | "react-transition-group": "^4.4.2", 60 | "sequelize": "^6.37.3", 61 | "sequelize-typescript": "^2.1.6", 62 | "socket.io": "^4.4.1", 63 | "socket.io-client": "^4.4.1", 64 | "styled-components": "^5.3.3", 65 | "swagger-jsdoc": "^6.2.8", 66 | "swagger-ui-express": "^5.0.1", 67 | "typedoc": "^0.23.8", 68 | "typescript": "^4.6.3", 69 | "uuid": "^8.3.2", 70 | "uuidv4": "^6.2.12", 71 | "web-vitals": "^2.1.4", 72 | "winston": "^3.5.1" 73 | }, 74 | "scripts": { 75 | "start": "concurrently -k \"npm run server\" \"npm run client\"", 76 | "server": "./node_modules/.bin/nodemon server/src/server.ts", 77 | "client": "vite", 78 | "build": "vite build", 79 | "build:server": "tsc -p server/tsconfig.json", 80 | "start:server": "node server/dist/server/src/server.js", 81 | "preview": "vite preview", 82 | "lint": "./node_modules/.bin/eslint ." 83 | }, 84 | "eslintConfig": { 85 | "extends": [ 86 | "react-app" 87 | ] 88 | }, 89 | "devDependencies": { 90 | "@types/cookie-parser": "^1.4.7", 91 | "@types/express": "^4.17.13", 92 | "@types/loglevel": "^1.6.3", 93 | "@types/node": "22.7.9", 94 | "@types/node-cron": "^3.0.11", 95 | "@types/prismjs": "^1.26.0", 96 | "@types/react-highlight": "^0.12.5", 97 | "@types/react-transition-group": "^4.4.4", 98 | "@types/styled-components": "^5.1.23", 99 | "@types/swagger-jsdoc": "^6.0.4", 100 | "@types/swagger-ui-express": "^4.1.6", 101 | "@vitejs/plugin-react": "^4.3.3", 102 | "ajv": "^8.8.2", 103 | "concurrently": "^7.0.0", 104 | "nodemon": "^2.0.15", 105 | "ts-node": "^10.4.0", 106 | "vite": "^5.4.10" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /server/src/browser-management/classes/BrowserPool.ts: -------------------------------------------------------------------------------- 1 | import { RemoteBrowser } from "./RemoteBrowser"; 2 | import logger from "../../logger"; 3 | 4 | /** 5 | * @category Types 6 | */ 7 | interface BrowserPoolInfo { 8 | /** 9 | * The instance of remote browser. 10 | */ 11 | browser: RemoteBrowser, 12 | /** 13 | * States if the browser's instance is being actively used. 14 | * Helps to persist the progress on the frontend when the application has been reloaded. 15 | * @default false 16 | */ 17 | active: boolean, 18 | } 19 | 20 | /** 21 | * Dictionary of all the active remote browser's instances indexed by their id. 22 | * The value in this dictionary is of type BrowserPoolInfo, 23 | * which provides additional information about the browser's usage. 24 | * @category Types 25 | */ 26 | interface PoolDictionary { 27 | [key: string]: BrowserPoolInfo, 28 | } 29 | 30 | /** 31 | * A browser pool is a collection of remote browsers that are initialized and ready to be used. 32 | * Adds the possibility to add, remove and retrieve remote browsers from the pool. 33 | * It is possible to manage multiple browsers for creating or running a recording. 34 | * @category BrowserManagement 35 | */ 36 | export class BrowserPool { 37 | 38 | /** 39 | * Holds all the instances of remote browsers. 40 | */ 41 | private pool: PoolDictionary = {}; 42 | 43 | /** 44 | * Adds a remote browser instance to the pool indexed by the id. 45 | * @param id remote browser instance's id 46 | * @param browser remote browser instance 47 | * @param active states if the browser's instance is being actively used 48 | */ 49 | public addRemoteBrowser = (id: string, browser: RemoteBrowser, active: boolean = false): void => { 50 | this.pool = { 51 | ...this.pool, 52 | [id]: { 53 | browser, 54 | active, 55 | }, 56 | } 57 | logger.log('debug', `Remote browser with id: ${id} added to the pool`); 58 | }; 59 | 60 | /** 61 | * Removes the remote browser instance from the pool. 62 | * @param id remote browser instance's id 63 | * @returns true if the browser was removed successfully, false otherwise 64 | */ 65 | public deleteRemoteBrowser = (id: string): boolean => { 66 | if (!this.pool[id]) { 67 | logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`); 68 | return false; 69 | } 70 | delete (this.pool[id]); 71 | logger.log('debug', `Remote browser with id: ${id} deleted from the pool`); 72 | return true; 73 | }; 74 | 75 | /** 76 | * Returns the remote browser instance from the pool. 77 | * @param id remote browser instance's id 78 | * @returns remote browser instance or undefined if it does not exist in the pool 79 | */ 80 | public getRemoteBrowser = (id: string): RemoteBrowser | undefined => { 81 | logger.log('debug', `Remote browser with id: ${id} retrieved from the pool`); 82 | return this.pool[id]?.browser; 83 | }; 84 | 85 | /** 86 | * Returns the active browser's instance id from the pool. 87 | * If there is no active browser, it returns undefined. 88 | * If there are multiple active browsers, it returns the first one. 89 | * @returns the first remote active browser instance's id from the pool 90 | */ 91 | public getActiveBrowserId = (): string | null => { 92 | for (const id of Object.keys(this.pool)) { 93 | if (this.pool[id].active) { 94 | return id; 95 | } 96 | } 97 | logger.log('warn', `No active browser in the pool`); 98 | return null; 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | scrollbar-gutter: stable; 13 | overflow-y: auto; 14 | } 15 | 16 | html { 17 | width: 100%; 18 | height: 100%; 19 | overflow-y: auto; 20 | } 21 | 22 | a { 23 | color: #ff00c3; 24 | &:hover { 25 | color: #ff00c3; 26 | } 27 | } 28 | 29 | code { 30 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 31 | monospace; 32 | } 33 | 34 | #browser-actions { 35 | right: 0; 36 | overflow-x: hidden; 37 | } 38 | 39 | #browser-recorder { 40 | display: flex; 41 | justify-content: center; 42 | align-items: center; 43 | overflow: hidden; 44 | position: relative; 45 | } 46 | 47 | #browser-content { 48 | height: 100%; 49 | width: 100%; 50 | display: flex; 51 | flex-direction: column; 52 | transform: scale(1); /* Ensure no scaling */ 53 | transform-origin: top left; /* Keep the position fixed */ 54 | } 55 | 56 | #browser { 57 | } 58 | 59 | #browser-window { 60 | overflow-y: auto; 61 | height: 100%; 62 | } 63 | 64 | .right-side-panel { 65 | margin: 0; 66 | transform: scale(1); 67 | transform-origin: top left; 68 | overflow: hidden; 69 | position: relative; 70 | } 71 | 72 | @media (min-width: 1024px) and (max-width: 1211px) { 73 | #browser-recorder { 74 | box-sizing: border-box; 75 | height: 100vh; 76 | margin: 0; 77 | } 78 | } 79 | 80 | /* For laptops (between 1024px and 1440px) */ 81 | @media (min-width: 1211px) and (max-width: 1440px) { 82 | #browser-recorder { 83 | box-sizing: border-box; 84 | height: calc(100vh - 0.6rem); 85 | margin: 0.3rem; 86 | } 87 | } 88 | 89 | /* For desktops (between 1441px and 1920px) */ 90 | @media (min-width: 1441px) and (max-width: 1500px) { 91 | #browser-recorder { 92 | box-sizing: border-box; 93 | height: calc(100vh - 2rem); 94 | margin: 1rem; 95 | } 96 | } 97 | 98 | @media (min-width: 1501px) and (max-width: 1700px) { 99 | #browser-recorder { 100 | box-sizing: border-box; 101 | height: calc(100vh - 2rem); 102 | margin: 1rem 8rem; 103 | } 104 | } 105 | 106 | @media (min-width: 1701px) and (max-width: 1800px) { 107 | #browser-recorder { 108 | box-sizing: border-box; 109 | height: calc(100vh - 2rem); 110 | margin: 1rem 14rem; 111 | } 112 | } 113 | 114 | @media (min-width: 1801px) and (max-width: 1900px) { 115 | #browser-recorder { 116 | box-sizing: border-box; 117 | height: calc(100vh - 2rem); 118 | margin: 1rem 18.5rem; 119 | } 120 | } 121 | 122 | @media (min-width: 1900px) and (max-width: 1920px) { 123 | #browser-recorder { 124 | box-sizing: border-box; 125 | height: calc(100vh - 2rem); 126 | margin: 1rem 20rem; 127 | } 128 | } 129 | 130 | /* For very large desktops (greater than 1920px) */ 131 | @media (min-width: 1921px) and (max-width: 2000px) { 132 | #browser-recorder { 133 | box-sizing: border-box; 134 | height: calc(100vh - 2rem); 135 | margin: 1rem 20rem; 136 | } 137 | } 138 | 139 | @media (min-width: 2001px) and (max-width: 2500px) { 140 | #browser-recorder { 141 | box-sizing: border-box; 142 | height: calc(100vh - 2rem); 143 | margin: 1rem 24rem; 144 | } 145 | } 146 | 147 | @media (min-width: 2501px) and (max-width: 2999px) { 148 | #browser-recorder { 149 | box-sizing: border-box; 150 | height: calc(100vh - 2rem); 151 | margin: 1rem 40rem; 152 | } 153 | } 154 | 155 | @media (min-width: 3000px) { 156 | #browser-recorder { 157 | box-sizing: border-box; 158 | height: calc(100vh - 2rem); 159 | margin: 1rem 55rem; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/components/molecules/LeftSidePanelContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import Box from "@mui/material/Box"; 3 | import { Pair } from "./Pair"; 4 | import { WhereWhatPair, WorkflowFile } from "maxun-core"; 5 | import { useSocketStore } from "../../context/socket"; 6 | import { Add } from "@mui/icons-material"; 7 | import { Socket } from "socket.io-client"; 8 | import { AddButton } from "../atoms/buttons/AddButton"; 9 | import { AddPair } from "../../api/workflow"; 10 | import { GenericModal } from "../atoms/GenericModal"; 11 | import { PairEditForm } from "./PairEditForm"; 12 | import { Fab, Tooltip, Typography } from "@mui/material"; 13 | 14 | interface LeftSidePanelContentProps { 15 | workflow: WorkflowFile; 16 | updateWorkflow: (workflow: WorkflowFile) => void; 17 | recordingName: string; 18 | handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; 19 | } 20 | 21 | export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, handleSelectPairForEdit}: LeftSidePanelContentProps) => { 22 | const [activeId, setActiveId] = React.useState(0); 23 | const [breakpoints, setBreakpoints] = React.useState([]); 24 | const [showEditModal, setShowEditModal] = useState(false); 25 | 26 | const { socket } = useSocketStore(); 27 | 28 | const activePairIdHandler = useCallback((data: string, socket: Socket) => { 29 | setActiveId(parseInt(data) + 1); 30 | // -1 is specially emitted when the interpretation finishes 31 | if (parseInt(data) === -1) { 32 | return; 33 | } 34 | socket.emit('activeIndex', data); 35 | }, [activeId]) 36 | 37 | const addPair = (pair: WhereWhatPair, index: number) => { 38 | AddPair((index - 1), pair).then((updatedWorkflow) => { 39 | updateWorkflow(updatedWorkflow); 40 | }).catch((error) => { 41 | console.error(error); 42 | }); 43 | setShowEditModal(false); 44 | }; 45 | 46 | useEffect(() => { 47 | socket?.on("activePairId", (data) => activePairIdHandler(data, socket)); 48 | return () => { 49 | socket?.off("activePairId", (data) => activePairIdHandler(data, socket)); 50 | } 51 | }, [socket, setActiveId]); 52 | 53 | 54 | const handleBreakpointClick = (id: number) => { 55 | setBreakpoints(oldBreakpoints => { 56 | const newArray = [...oldBreakpoints, ...Array(workflow.workflow.length - oldBreakpoints.length).fill(false)]; 57 | newArray[id] = !newArray[id]; 58 | socket?.emit("breakpoints", newArray); 59 | return newArray; 60 | }); 61 | }; 62 | 63 | const handleAddPair = () => { 64 | setShowEditModal(true); 65 | }; 66 | 67 | return ( 68 |
69 | 70 |
71 | 77 |
78 |
79 | setShowEditModal(false)} 82 | > 83 | 87 | 88 |
89 | { 90 | workflow.workflow.map((pair, i, workflow, ) => 91 | handleBreakpointClick(i)} 93 | isActive={ activeId === i + 1} 94 | key={workflow.length - i} 95 | index={workflow.length - i} 96 | pair={pair} 97 | updateWorkflow={updateWorkflow} 98 | numberOfPairs={workflow.length} 99 | handleSelectPairForEdit={handleSelectPairForEdit} 100 | />) 101 | } 102 |
103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/helpers/inputHelpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ONE_PERCENT_OF_VIEWPORT_H, 3 | ONE_PERCENT_OF_VIEWPORT_W, 4 | VIEWPORT_W, 5 | VIEWPORT_H, 6 | } from "../constants/const"; 7 | import { Coordinates } from '../components/atoms/canvas'; 8 | 9 | export const throttle = (callback: any, limit: number) => { 10 | let wait = false; 11 | return (...args: any[]) => { 12 | if (!wait) { 13 | callback(...args); 14 | wait = true; 15 | setTimeout(function () { 16 | wait = false; 17 | }, limit); 18 | } 19 | } 20 | } 21 | 22 | export const getMappedCoordinates = ( 23 | event: MouseEvent, 24 | canvas: HTMLCanvasElement | null, 25 | browserWidth: number, 26 | browserHeight: number, 27 | ): Coordinates => { 28 | const clientCoordinates = getCoordinates(event, canvas); 29 | const mappedX = mapPixelFromSmallerToLarger( 30 | browserWidth / 100, 31 | ONE_PERCENT_OF_VIEWPORT_W, 32 | clientCoordinates.x, 33 | ); 34 | const mappedY = mapPixelFromSmallerToLarger( 35 | browserHeight / 100, 36 | ONE_PERCENT_OF_VIEWPORT_H, 37 | clientCoordinates.y, 38 | ); 39 | 40 | return { 41 | x: mappedX, 42 | y: mappedY 43 | }; 44 | }; 45 | 46 | const getCoordinates = (event: MouseEvent, canvas: HTMLCanvasElement | null): Coordinates => { 47 | if (!canvas) { 48 | return { x: 0, y: 0 }; 49 | } 50 | return { 51 | x: event.pageX - canvas.offsetLeft, 52 | y: event.pageY - canvas.offsetTop 53 | }; 54 | }; 55 | 56 | export const mapRect = ( 57 | rect: DOMRect, 58 | browserWidth: number, 59 | browserHeight: number, 60 | ) => { 61 | const mappedX = mapPixelFromSmallerToLarger( 62 | browserWidth / 100, 63 | ONE_PERCENT_OF_VIEWPORT_W, 64 | rect.x, 65 | ); 66 | const mappedLeft = mapPixelFromSmallerToLarger( 67 | browserWidth / 100, 68 | ONE_PERCENT_OF_VIEWPORT_W, 69 | rect.left, 70 | ); 71 | const mappedRight = mapPixelFromSmallerToLarger( 72 | browserWidth / 100, 73 | ONE_PERCENT_OF_VIEWPORT_W, 74 | rect.right, 75 | ); 76 | const mappedWidth = mapPixelFromSmallerToLarger( 77 | browserWidth / 100, 78 | ONE_PERCENT_OF_VIEWPORT_W, 79 | rect.width, 80 | ); 81 | const mappedY = mapPixelFromSmallerToLarger( 82 | browserHeight / 100, 83 | ONE_PERCENT_OF_VIEWPORT_H, 84 | rect.y, 85 | ); 86 | const mappedTop = mapPixelFromSmallerToLarger( 87 | browserHeight / 100, 88 | ONE_PERCENT_OF_VIEWPORT_H, 89 | rect.top, 90 | ); 91 | const mappedBottom = mapPixelFromSmallerToLarger( 92 | browserHeight / 100, 93 | ONE_PERCENT_OF_VIEWPORT_H, 94 | rect.bottom, 95 | ); 96 | const mappedHeight = mapPixelFromSmallerToLarger( 97 | browserHeight / 100, 98 | ONE_PERCENT_OF_VIEWPORT_H, 99 | rect.height, 100 | ); 101 | 102 | console.log('Mapped:', { 103 | x: mappedX, 104 | y: mappedY, 105 | width: mappedWidth, 106 | height: mappedHeight, 107 | top: mappedTop, 108 | right: mappedRight, 109 | bottom: mappedBottom, 110 | left: mappedLeft, 111 | }) 112 | 113 | return { 114 | x: mappedX, 115 | y: mappedY, 116 | width: mappedWidth, 117 | height: mappedHeight, 118 | top: mappedTop, 119 | right: mappedRight, 120 | bottom: mappedBottom, 121 | left: mappedLeft, 122 | }; 123 | }; 124 | 125 | const mapPixelFromSmallerToLarger = ( 126 | onePercentOfSmallerScreen: number, 127 | onePercentOfLargerScreen: number, 128 | pixel: number 129 | ): number => { 130 | const xPercentOfScreen = pixel / onePercentOfSmallerScreen; 131 | return xPercentOfScreen * onePercentOfLargerScreen; 132 | }; 133 | 134 | const mapPixelFromLargerToSmaller = ( 135 | onePercentOfSmallerScreen: number, 136 | onePercentOfLargerScreen: number, 137 | pixel: number 138 | ): number => { 139 | const xPercentOfScreen = pixel / onePercentOfLargerScreen; 140 | return Math.round(xPercentOfScreen * onePercentOfSmallerScreen); 141 | }; 142 | -------------------------------------------------------------------------------- /src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useState, useContext, useEffect } from 'react'; 3 | import { useNavigate, Link } from 'react-router-dom'; 4 | import { AuthContext } from '../context/auth'; 5 | import { 6 | Box, 7 | Typography, 8 | TextField, 9 | Button, 10 | CircularProgress, 11 | } from '@mui/material'; 12 | import { useGlobalInfoStore } from "../context/globalInfo"; 13 | 14 | const Login = () => { 15 | const [form, setForm] = useState({ 16 | email: '', 17 | password: '', 18 | }); 19 | const [loading, setLoading] = useState(false); 20 | const { notify } = useGlobalInfoStore(); 21 | const { email, password } = form; 22 | 23 | const { state, dispatch } = useContext(AuthContext); 24 | const { user } = state; 25 | 26 | const navigate = useNavigate(); 27 | 28 | useEffect(() => { 29 | if (user) { 30 | navigate('/'); 31 | } 32 | }, [user, navigate]); 33 | 34 | const handleChange = (e: any) => { 35 | const { name, value } = e.target; 36 | setForm({ ...form, [name]: value }); 37 | }; 38 | 39 | const submitForm = async (e: any) => { 40 | e.preventDefault(); 41 | setLoading(true); 42 | try { 43 | const { data } = await axios.post(`http://localhost:8080/auth/login`, { email, password }); 44 | dispatch({ type: 'LOGIN', payload: data }); 45 | notify('success', 'Welcome to Maxun!'); 46 | window.localStorage.setItem('user', JSON.stringify(data)); 47 | navigate('/'); 48 | } catch (err: any) { 49 | notify('error', err.response.data || 'Login Failed. Please try again.'); 50 | setLoading(false); 51 | } 52 | }; 53 | 54 | return ( 55 | 63 | 64 | Welcome Back! 65 | 66 | 67 | 77 | 88 | 89 | 106 | 107 | 108 | Don’t have an account?{' '} 109 | 110 | Register 111 | 112 | 113 | 114 | 115 | ); 116 | }; 117 | 118 | export default Login; 119 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { InputLabel, MenuItem, TextField, Select, FormControl } from "@mui/material"; 3 | import { ScreenshotSettings as Settings } from "../../../shared/types"; 4 | import styled from "styled-components"; 5 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 6 | import { Dropdown } from "../../atoms/DropdownMui"; 7 | 8 | export const ScreenshotSettings = forwardRef((props, ref) => { 9 | const [settings, setSettings] = React.useState({}); 10 | useImperativeHandle(ref, () => ({ 11 | getSettings() { 12 | return settings; 13 | } 14 | })); 15 | 16 | const handleInput = (event: React.ChangeEvent) => { 17 | const { id, value, type } = event.target; 18 | let parsedValue: any = value; 19 | if (type === "number") { 20 | parsedValue = parseInt(value); 21 | }; 22 | setSettings({ 23 | ...settings, 24 | [id]: parsedValue, 25 | }); 26 | }; 27 | 28 | const handleSelect = (event: SelectChangeEvent) => { 29 | const { name, value } = event.target; 30 | let parsedValue: any = value; 31 | if (value === "true" || value === "false") { 32 | parsedValue = value === "true"; 33 | }; 34 | setSettings({ 35 | ...settings, 36 | [name]: parsedValue, 37 | }); 38 | }; 39 | 40 | return ( 41 | 42 | 48 | jpeg 49 | png 50 | 51 | {settings.type === "jpeg" ? 52 | : null 60 | } 61 | 69 | 75 | disabled 76 | allow 77 | 78 | {settings.type === "png" ? 79 | 85 | true 86 | false 87 | 88 | : null 89 | } 90 | 96 | hide 97 | initial 98 | 99 | 105 | true 106 | false 107 | 108 | 114 | css 115 | device 116 | 117 | 118 | ); 119 | }); 120 | 121 | const SettingsWrapper = styled.div` 122 | margin-left: 15px; 123 | * { 124 | margin-bottom: 10px; 125 | } 126 | `; 127 | -------------------------------------------------------------------------------- /src/pages/Register.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext, useEffect } from 'react'; 2 | import { useNavigate, Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import { AuthContext } from '../context/auth'; 5 | import { TextField, Button, CircularProgress, Typography, Box, Container } from '@mui/material'; 6 | import { useGlobalInfoStore } from "../context/globalInfo"; 7 | 8 | const Register = () => { 9 | const [form, setForm] = useState({ 10 | email: '', 11 | password: '', 12 | }); 13 | const [loading, setLoading] = useState(false); 14 | const { notify } = useGlobalInfoStore(); 15 | const { email, password } = form; 16 | 17 | const { state, dispatch } = useContext(AuthContext); 18 | const { user } = state; 19 | const navigate = useNavigate(); 20 | 21 | useEffect(() => { 22 | if (user !== null) navigate('/'); 23 | }, [user, navigate]); 24 | 25 | const handleChange = (e: any) => { 26 | const { name, value } = e.target; 27 | setForm({ ...form, [name]: value }); 28 | }; 29 | 30 | const submitForm = async (e: any) => { 31 | e.preventDefault(); 32 | setLoading(true); 33 | try { 34 | const { data } = await axios.post('http://localhost:8080/auth/register', { 35 | email, 36 | password, 37 | }); 38 | dispatch({ 39 | type: 'LOGIN', 40 | payload: data, 41 | }); 42 | notify('success', 'Welcome to Maxun!'); 43 | window.localStorage.setItem('user', JSON.stringify(data)); 44 | navigate('/'); 45 | } catch (err: any) { 46 | notify('error', err.response.data || 'Registration Failed. Please try again.'); 47 | } finally { 48 | setLoading(false); 49 | } 50 | }; 51 | 52 | return ( 53 | 61 | 62 | Create an account 63 | 64 | 65 | 76 | 88 | 105 | 106 | Already have an account?{' '} 107 | 108 | Login 109 | 110 | 111 | 112 | 113 | ); 114 | }; 115 | 116 | export default Register; 117 | -------------------------------------------------------------------------------- /src/components/molecules/RunSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { GenericModal } from "../atoms/GenericModal"; 3 | import { MenuItem, TextField, Typography, Switch, FormControlLabel } from "@mui/material"; 4 | import { Dropdown } from "../atoms/DropdownMui"; 5 | import Button from "@mui/material/Button"; 6 | import { modalStyle } from "./AddWhereCondModal"; 7 | 8 | interface RunSettingsProps { 9 | isOpen: boolean; 10 | handleStart: (settings: RunSettings) => void; 11 | handleClose: () => void; 12 | isTask: boolean; 13 | params?: string[]; 14 | } 15 | 16 | export interface RunSettings { 17 | maxConcurrency: number; 18 | maxRepeats: number; 19 | debug: boolean; 20 | params?: any; 21 | } 22 | 23 | export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, params }: RunSettingsProps) => { 24 | const [settings, setSettings] = useState({ 25 | maxConcurrency: 1, 26 | maxRepeats: 1, 27 | debug: true, 28 | }); 29 | 30 | const [showInterpreterSettings, setShowInterpreterSettings] = useState(false); 31 | 32 | return ( 33 | 38 |
44 | {isTask && ( 45 | 46 | Recording parameters: 47 | {params?.map((item, index) => ( 48 | 55 | setSettings({ 56 | ...settings, 57 | params: settings.params 58 | ? { ...settings.params, [item]: e.target.value } 59 | : { [item]: e.target.value }, 60 | }) 61 | } 62 | /> 63 | ))} 64 | 65 | )} 66 | 67 | setShowInterpreterSettings(!showInterpreterSettings)} />} 69 | label="Developer Mode Settings" 70 | sx={{ margin: '20px 0px' }} 71 | /> 72 | 73 | {showInterpreterSettings && ( 74 | 75 | 81 | setSettings({ 82 | ...settings, 83 | maxConcurrency: parseInt(e.target.value, 10), 84 | }) 85 | } 86 | defaultValue={settings.maxConcurrency} 87 | /> 88 | 94 | setSettings({ 95 | ...settings, 96 | maxRepeats: parseInt(e.target.value, 10), 97 | }) 98 | } 99 | defaultValue={settings.maxRepeats} 100 | /> 101 | 106 | setSettings({ 107 | ...settings, 108 | debug: e.target.value === "true", 109 | }) 110 | } 111 | > 112 | true 113 | false 114 | 115 | 116 | )} 117 | 118 | 119 |
120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /src/components/molecules/ActionDescriptionBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material'; 4 | import { useActionContext } from '../../context/browserActions'; 5 | 6 | const CustomBoxContainer = styled.div` 7 | position: relative; 8 | min-width: 250px; 9 | width: auto; 10 | min-height: 100px; 11 | height: auto; 12 | // border: 2px solid #ff00c3; 13 | border-radius: 5px; 14 | background-color: white; 15 | margin: 80px 13px 25px 13px; 16 | `; 17 | 18 | const Triangle = styled.div` 19 | position: absolute; 20 | top: -15px; 21 | left: 50%; 22 | transform: translateX(-50%); 23 | width: 0; 24 | height: 0; 25 | border-left: 20px solid transparent; 26 | border-right: 20px solid transparent; 27 | border-bottom: 20px solid white; 28 | `; 29 | 30 | const Logo = styled.img` 31 | position: absolute; 32 | top: -80px; 33 | left: 50%; 34 | transform: translateX(-50%); 35 | width: 70px; 36 | height: auto; 37 | border-radius: 5px; 38 | `; 39 | 40 | const Content = styled.div` 41 | padding: 20px; 42 | text-align: left; 43 | `; 44 | 45 | const ActionDescriptionBox = () => { 46 | const { getText, getScreenshot, getList, captureStage } = useActionContext() as { 47 | getText: boolean; 48 | getScreenshot: boolean; 49 | getList: boolean; 50 | captureStage: 'initial' | 'pagination' | 'limit' | 'complete'; 51 | }; 52 | 53 | const messages = [ 54 | { stage: 'initial' as const, text: 'Select the list you want to extract along with the texts inside it' }, 55 | { stage: 'pagination' as const, text: 'Select how the robot can capture the rest of the list' }, 56 | { stage: 'limit' as const, text: 'Choose the number of items to extract' }, 57 | { stage: 'complete' as const, text: 'Capture is complete' }, 58 | ]; 59 | 60 | const stages = messages.map(({ stage }) => stage); // Create a list of stages 61 | const currentStageIndex = stages.indexOf(captureStage); // Get the index of the current stage 62 | 63 | const renderActionDescription = () => { 64 | if (getText) { 65 | return ( 66 | <> 67 | Capture Text 68 | Hover over the texts you want to extract and click to select them 69 | 70 | ); 71 | } else if (getScreenshot) { 72 | return ( 73 | <> 74 | Capture Screenshot 75 | Capture a partial or full page screenshot of the current page. 76 | 77 | ); 78 | } else if (getList) { 79 | return ( 80 | <> 81 | Capture List 82 | 83 | Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them. 84 | 85 | 86 | {messages.map(({ stage, text }, index) => ( 87 | 94 | } 95 | label={{text}} 96 | /> 97 | ))} 98 | 99 | 100 | ); 101 | } else { 102 | return ( 103 | <> 104 | What data do you want to extract? 105 | A robot is designed to perform one action at a time. You can choose any of the options below. 106 | 107 | ); 108 | } 109 | }; 110 | 111 | return ( 112 | 113 | 114 | 115 | 116 | {renderActionDescription()} 117 | 118 | 119 | ); 120 | }; 121 | 122 | export default ActionDescriptionBox; 123 | -------------------------------------------------------------------------------- /src/context/globalInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from "react"; 2 | import { AlertSnackbarProps } from "../components/atoms/AlertSnackbar"; 3 | 4 | 5 | interface GlobalInfo { 6 | browserId: string | null; 7 | setBrowserId: (newId: string | null) => void; 8 | lastAction: string; 9 | setLastAction: (action: string) => void; 10 | notification: AlertSnackbarProps; 11 | notify: (severity: 'error' | 'warning' | 'info' | 'success', message: string) => void; 12 | closeNotify: () => void; 13 | recordings: string[]; 14 | setRecordings: (recordings: string[]) => void; 15 | rerenderRuns: boolean; 16 | setRerenderRuns: (rerenderRuns: boolean) => void; 17 | recordingLength: number; 18 | setRecordingLength: (recordingLength: number) => void; 19 | recordingId: string | null; 20 | setRecordingId: (newId: string | null) => void; 21 | recordingName: string; 22 | setRecordingName: (recordingName: string) => void; 23 | recordingUrl: string; 24 | setRecordingUrl: (recordingUrl: string) => void; 25 | currentWorkflowActionsState: { 26 | hasScrapeListAction: boolean; 27 | hasScreenshotAction: boolean; 28 | hasScrapeSchemaAction: boolean; 29 | }; 30 | setCurrentWorkflowActionsState: (actionsState: { 31 | hasScrapeListAction: boolean; 32 | hasScreenshotAction: boolean; 33 | hasScrapeSchemaAction: boolean; 34 | }) => void; 35 | }; 36 | 37 | class GlobalInfoStore implements Partial { 38 | browserId = null; 39 | lastAction = ''; 40 | recordingLength = 0; 41 | notification: AlertSnackbarProps = { 42 | severity: 'info', 43 | message: '', 44 | isOpen: false, 45 | }; 46 | recordingId = null; 47 | recordings: string[] = []; 48 | rerenderRuns = false; 49 | recordingName = ''; 50 | recordingUrl = 'https://'; 51 | currentWorkflowActionsState = { 52 | hasScrapeListAction: false, 53 | hasScreenshotAction: false, 54 | hasScrapeSchemaAction: false, 55 | }; 56 | }; 57 | 58 | const globalInfoStore = new GlobalInfoStore(); 59 | const globalInfoContext = createContext(globalInfoStore as GlobalInfo); 60 | 61 | export const useGlobalInfoStore = () => useContext(globalInfoContext); 62 | 63 | export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { 64 | const [browserId, setBrowserId] = useState(globalInfoStore.browserId); 65 | const [lastAction, setLastAction] = useState(globalInfoStore.lastAction); 66 | const [notification, setNotification] = useState(globalInfoStore.notification); 67 | const [recordings, setRecordings] = useState(globalInfoStore.recordings); 68 | const [rerenderRuns, setRerenderRuns] = useState(globalInfoStore.rerenderRuns); 69 | const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); 70 | const [recordingId, setRecordingId] = useState(globalInfoStore.recordingId); 71 | const [recordingName, setRecordingName] = useState(globalInfoStore.recordingName); 72 | const [recordingUrl, setRecordingUrl] = useState(globalInfoStore.recordingUrl); 73 | const [currentWorkflowActionsState, setCurrentWorkflowActionsState] = useState(globalInfoStore.currentWorkflowActionsState); 74 | 75 | const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { 76 | setNotification({ severity, message, isOpen: true }); 77 | } 78 | 79 | const closeNotify = () => { 80 | setNotification(globalInfoStore.notification); 81 | } 82 | 83 | const setBrowserIdWithValidation = (browserId: string | null) => { 84 | setBrowserId(browserId); 85 | if (!browserId) { 86 | setRecordingLength(0); 87 | } 88 | } 89 | 90 | return ( 91 | 116 | {children} 117 | 118 | ); 119 | }; 120 | -------------------------------------------------------------------------------- /src/components/atoms/RecorderIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const RecordingIcon = () => { 4 | return ( 5 | 6 | 8 | 9 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/molecules/DisplayWhereConditionSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; 3 | import { Checkbox, FormControlLabel, FormGroup, MenuItem, Stack, TextField } from "@mui/material"; 4 | import { AddButton } from "../atoms/buttons/AddButton"; 5 | import { RemoveButton } from "../atoms/buttons/RemoveButton"; 6 | import { KeyValueForm } from "./KeyValueForm"; 7 | import { WarningText } from "../atoms/texts"; 8 | 9 | interface DisplayConditionSettingsProps { 10 | whereProp: string; 11 | additionalSettings: string; 12 | setAdditionalSettings: (value: any) => void; 13 | newValue: any; 14 | setNewValue: (value: any) => void; 15 | keyValueFormRef: React.RefObject<{getObject: () => object}>; 16 | whereKeys: string[]; 17 | checked: boolean[]; 18 | setChecked: (value: boolean[]) => void; 19 | } 20 | 21 | export const DisplayConditionSettings = ( 22 | {whereProp, setAdditionalSettings, additionalSettings, 23 | setNewValue, newValue, keyValueFormRef, whereKeys, checked, setChecked} 24 | : DisplayConditionSettingsProps) => { 25 | switch (whereProp) { 26 | case 'url': 27 | return ( 28 | 29 | setAdditionalSettings(e.target.value)}> 34 | string 35 | regex 36 | 37 | { additionalSettings ? setNewValue(e.target.value)} 41 | value={newValue} 42 | /> : null} 43 | 44 | ) 45 | case 'selectors': 46 | return ( 47 | 48 | 49 | { 50 | newValue.map((selector: string, index: number) => { 51 | return setNewValue([ 56 | ...newValue.slice(0, index), 57 | e.target.value, 58 | ...newValue.slice(index + 1) 59 | ])}/> 60 | }) 61 | } 62 | 63 | setNewValue([...newValue, ''])}/> 64 | { 65 | const arr = newValue; 66 | arr.splice(-1); 67 | setNewValue([...arr]); 68 | }}/> 69 | 70 | ) 71 | case 'cookies': 72 | return 73 | case 'before': 74 | return setNewValue(e.target.value)} 79 | /> 80 | case 'after': 81 | return setNewValue(e.target.value)} 86 | /> 87 | case 'boolean': 88 | return ( 89 | 90 | setAdditionalSettings(e.target.value)}> 95 | and 96 | or 97 | 98 | 99 | { 100 | whereKeys.map((key: string, index: number) => { 101 | return ( 102 | setChecked([ 106 | ...checked.slice(0, index), 107 | !checked[index], 108 | ...checked.slice(index + 1) 109 | ])} 110 | key={`checkbox-${key}-${index}`} 111 | /> 112 | } label={key} key={`control-label-form-${key}-${index}`}/> 113 | ) 114 | }) 115 | } 116 | 117 | 118 | Choose at least 2 where conditions. Nesting of boolean operators 119 | is possible by adding more conditions. 120 | 121 | 122 | ) 123 | default: 124 | return null; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/context/browserActions.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, ReactNode } from 'react'; 2 | import { useSocketStore } from './socket'; 3 | 4 | export type PaginationType = 'scrollDown' | 'scrollUp' | 'clickNext' | 'clickLoadMore' | 'none' | ''; 5 | export type LimitType = '10' | '100' | 'custom' | ''; 6 | export type CaptureStage = 'initial' | 'pagination' | 'limit' | 'complete' | ''; 7 | 8 | interface ActionContextProps { 9 | getText: boolean; 10 | getList: boolean; 11 | getScreenshot: boolean; 12 | paginationMode: boolean; 13 | limitMode: boolean; 14 | paginationType: PaginationType; 15 | limitType: LimitType; 16 | customLimit: string; 17 | captureStage: CaptureStage; // New captureStage property 18 | setCaptureStage: (stage: CaptureStage) => void; // Setter for captureStage 19 | startPaginationMode: () => void; 20 | startGetText: () => void; 21 | stopGetText: () => void; 22 | startGetList: () => void; 23 | stopGetList: () => void; 24 | startGetScreenshot: () => void; 25 | stopGetScreenshot: () => void; 26 | stopPaginationMode: () => void; 27 | updatePaginationType: (type: PaginationType) => void; 28 | startLimitMode: () => void; 29 | stopLimitMode: () => void; 30 | updateLimitType: (type: LimitType) => void; 31 | updateCustomLimit: (limit: string) => void; 32 | } 33 | 34 | const ActionContext = createContext(undefined); 35 | 36 | export const ActionProvider = ({ children }: { children: ReactNode }) => { 37 | const [getText, setGetText] = useState(false); 38 | const [getList, setGetList] = useState(false); 39 | const [getScreenshot, setGetScreenshot] = useState(false); 40 | const [paginationMode, setPaginationMode] = useState(false); 41 | const [limitMode, setLimitMode] = useState(false); 42 | const [paginationType, setPaginationType] = useState(''); 43 | const [limitType, setLimitType] = useState(''); 44 | const [customLimit, setCustomLimit] = useState(''); 45 | const [captureStage, setCaptureStage] = useState('initial'); 46 | 47 | const { socket } = useSocketStore(); 48 | 49 | const updatePaginationType = (type: PaginationType) => setPaginationType(type); 50 | const updateLimitType = (type: LimitType) => setLimitType(type); 51 | const updateCustomLimit = (limit: string) => setCustomLimit(limit); 52 | 53 | const startPaginationMode = () => { 54 | setPaginationMode(true); 55 | setCaptureStage('pagination'); 56 | }; 57 | 58 | const stopPaginationMode = () => setPaginationMode(false); 59 | 60 | const startLimitMode = () => { 61 | setLimitMode(true); 62 | setCaptureStage('limit'); 63 | }; 64 | 65 | const stopLimitMode = () => setLimitMode(false); 66 | 67 | const startGetText = () => setGetText(true); 68 | const stopGetText = () => setGetText(false); 69 | 70 | const startGetList = () => { 71 | setGetList(true); 72 | socket?.emit('setGetList', { getList: true }); 73 | setCaptureStage('initial'); 74 | } 75 | 76 | const stopGetList = () => { 77 | setGetList(false); 78 | socket?.emit('setGetList', { getList: false }); 79 | setPaginationType(''); 80 | setLimitType(''); 81 | setCustomLimit(''); 82 | setCaptureStage('complete'); 83 | }; 84 | 85 | const startGetScreenshot = () => setGetScreenshot(true); 86 | const stopGetScreenshot = () => setGetScreenshot(false); 87 | 88 | return ( 89 | 114 | {children} 115 | 116 | ); 117 | }; 118 | 119 | export const useActionContext = () => { 120 | const context = useContext(ActionContext); 121 | if (context === undefined) { 122 | throw new Error('useActionContext must be used within an ActionProvider'); 123 | } 124 | return context; 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/organisms/ApiKey.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Box, 4 | Button, 5 | Typography, 6 | IconButton, 7 | CircularProgress, 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableContainer, 12 | TableHead, 13 | TableRow, 14 | Tooltip, 15 | Paper, 16 | } from '@mui/material'; 17 | import { ContentCopy, Visibility, Delete } from '@mui/icons-material'; 18 | import styled from 'styled-components'; 19 | import axios from 'axios'; 20 | import { useGlobalInfoStore } from '../../context/globalInfo'; 21 | 22 | const Container = styled(Box)` 23 | display: flex; 24 | flex-direction: column; 25 | align-items: center; 26 | margin-top: 50px; 27 | margin-left: 50px; 28 | `; 29 | 30 | const ApiKeyManager = () => { 31 | const [apiKey, setApiKey] = useState(null); 32 | const [apiKeyName, setApiKeyName] = useState('Maxun API Key'); 33 | const [loading, setLoading] = useState(true); 34 | const [showKey, setShowKey] = useState(false); 35 | const [copySuccess, setCopySuccess] = useState(false); 36 | const { notify } = useGlobalInfoStore(); 37 | 38 | useEffect(() => { 39 | const fetchApiKey = async () => { 40 | try { 41 | const { data } = await axios.get('http://localhost:8080/auth/api-key'); 42 | setApiKey(data.api_key); 43 | } catch (error: any) { 44 | notify('error', `Failed to fetch API Key - ${error.message}`); 45 | } finally { 46 | setLoading(false); 47 | } 48 | }; 49 | 50 | fetchApiKey(); 51 | }, []); 52 | 53 | const generateApiKey = async () => { 54 | setLoading(true); 55 | try { 56 | const { data } = await axios.post('http://localhost:8080/auth/generate-api-key'); 57 | setApiKey(data.api_key); 58 | notify('success', `Generated API Key successfully`); 59 | } catch (error: any) { 60 | notify('error', `Failed to generate API Key - ${error.message}`); 61 | } finally { 62 | setLoading(false); 63 | } 64 | }; 65 | 66 | const deleteApiKey = async () => { 67 | setLoading(true); 68 | try { 69 | await axios.delete('http://localhost:8080/auth/delete-api-key'); 70 | setApiKey(null); 71 | notify('success', 'API Key deleted successfully'); 72 | } catch (error: any) { 73 | notify('error', `Failed to delete API Key - ${error.message}`); 74 | } finally { 75 | setLoading(false); 76 | } 77 | }; 78 | 79 | const copyToClipboard = () => { 80 | if (apiKey) { 81 | navigator.clipboard.writeText(apiKey); 82 | setCopySuccess(true); 83 | setTimeout(() => setCopySuccess(false), 2000); 84 | notify('info', 'Copied to clipboard'); 85 | } 86 | }; 87 | 88 | if (loading) return ; 89 | 90 | return ( 91 | 92 | 93 | Manage Your API Key 94 | 95 | {apiKey ? ( 96 | 97 | 98 | 99 | 100 | API Key Name 101 | API Key 102 | Actions 103 | 104 | 105 | 106 | 107 | {apiKeyName} 108 | {showKey ? `${apiKey?.substring(0, 10)}...` : '***************'} 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | setShowKey(!showKey)}> 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 |
129 |
130 | ) : ( 131 | <> 132 | You haven't generated an API key yet. 133 | 136 | 137 | )} 138 |
139 | ); 140 | }; 141 | 142 | export default ApiKeyManager; -------------------------------------------------------------------------------- /src/components/organisms/BrowserContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import styled from "styled-components"; 3 | import BrowserNavBar from "../molecules/BrowserNavBar"; 4 | import { BrowserWindow } from "./BrowserWindow"; 5 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 6 | import { BrowserTabs } from "../molecules/BrowserTabs"; 7 | import { useSocketStore } from "../../context/socket"; 8 | import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording"; 9 | import { Box } from '@mui/material'; 10 | import { InterpretationLog } from "../molecules/InterpretationLog"; 11 | 12 | // TODO: Tab !show currentUrl after recordingUrl global state 13 | export const BrowserContent = () => { 14 | const { width } = useBrowserDimensionsStore(); 15 | const { socket } = useSocketStore(); 16 | 17 | const [tabs, setTabs] = useState(['current']); 18 | const [tabIndex, setTabIndex] = React.useState(0); 19 | const [showOutputData, setShowOutputData] = useState(false); 20 | 21 | const handleChangeIndex = useCallback((index: number) => { 22 | setTabIndex(index); 23 | }, [tabIndex]) 24 | 25 | const handleCloseTab = useCallback((index: number) => { 26 | // the tab needs to be closed on the backend 27 | socket?.emit('closeTab', { 28 | index, 29 | isCurrent: tabIndex === index, 30 | }); 31 | // change the current index as current tab gets closed 32 | if (tabIndex === index) { 33 | if (tabs.length > index + 1) { 34 | handleChangeIndex(index); 35 | } else { 36 | handleChangeIndex(index - 1); 37 | } 38 | } else { 39 | handleChangeIndex(tabIndex - 1); 40 | } 41 | // update client tabs 42 | setTabs((prevState) => [ 43 | ...prevState.slice(0, index), 44 | ...prevState.slice(index + 1) 45 | ]) 46 | }, [tabs, socket, tabIndex]); 47 | 48 | const handleAddNewTab = useCallback(() => { 49 | // Adds new tab by pressing the plus button 50 | socket?.emit('addTab'); 51 | // Adds a new tab to the end of the tabs array and shifts focus 52 | setTabs((prevState) => [...prevState, 'new tab']); 53 | handleChangeIndex(tabs.length); 54 | }, [socket, tabs]); 55 | 56 | const handleNewTab = useCallback((tab: string) => { 57 | // Adds a new tab to the end of the tabs array and shifts focus 58 | setTabs((prevState) => [...prevState, tab]); 59 | // changes focus on the new tab - same happens in the remote browser 60 | handleChangeIndex(tabs.length); 61 | handleTabChange(tabs.length); 62 | }, [tabs]); 63 | 64 | const handleTabChange = useCallback((index: number) => { 65 | // page screencast and focus needs to be changed on backend 66 | socket?.emit('changeTab', index); 67 | }, [socket]); 68 | 69 | const handleUrlChanged = (url: string) => { 70 | const parsedUrl = new URL(url); 71 | if (parsedUrl.hostname) { 72 | const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.') 73 | if (host && host !== tabs[tabIndex]) { 74 | setTabs((prevState) => [ 75 | ...prevState.slice(0, tabIndex), 76 | host, 77 | ...prevState.slice(tabIndex + 1) 78 | ]) 79 | } 80 | } else { 81 | if (tabs[tabIndex] !== 'new tab') { 82 | setTabs((prevState) => [ 83 | ...prevState.slice(0, tabIndex), 84 | 'new tab', 85 | ...prevState.slice(tabIndex + 1) 86 | ]) 87 | } 88 | } 89 | 90 | }; 91 | 92 | const tabHasBeenClosedHandler = useCallback((index: number) => { 93 | handleCloseTab(index); 94 | }, [handleCloseTab]) 95 | 96 | useEffect(() => { 97 | if (socket) { 98 | socket.on('newTab', handleNewTab); 99 | socket.on('tabHasBeenClosed', tabHasBeenClosedHandler); 100 | } 101 | return () => { 102 | if (socket) { 103 | socket.off('newTab', handleNewTab); 104 | socket.off('tabHasBeenClosed', tabHasBeenClosedHandler); 105 | } 106 | } 107 | }, [socket, handleNewTab]) 108 | 109 | useEffect(() => { 110 | getCurrentTabs().then((response) => { 111 | if (response) { 112 | setTabs(response); 113 | } 114 | }).catch((error) => { 115 | console.log("Fetching current url failed"); 116 | }) 117 | }, []) 118 | 119 | return ( 120 |
121 | 129 | 134 | 135 |
136 | ); 137 | } 138 | 139 | const BrowserContentWrapper = styled.div` 140 | `; -------------------------------------------------------------------------------- /server/src/routes/record.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RESTful API endpoints handling remote browser recording sessions. 3 | */ 4 | import { Router, Request, Response } from 'express'; 5 | 6 | import { 7 | initializeRemoteBrowserForRecording, 8 | destroyRemoteBrowser, 9 | getActiveBrowserId, 10 | interpretWholeWorkflow, 11 | stopRunningInterpretation, 12 | getRemoteBrowserCurrentUrl, getRemoteBrowserCurrentTabs, 13 | } from '../browser-management/controller' 14 | import { chromium } from 'playwright-extra'; 15 | import stealthPlugin from 'puppeteer-extra-plugin-stealth'; 16 | import logger from "../logger"; 17 | import { getDecryptedProxyConfig } from './proxy'; 18 | import { requireSignIn } from '../middlewares/auth'; 19 | 20 | export const router = Router(); 21 | chromium.use(stealthPlugin()); 22 | 23 | 24 | export interface AuthenticatedRequest extends Request { 25 | user?: any; 26 | } 27 | 28 | /** 29 | * Logs information about remote browser recording session. 30 | */ 31 | router.all('/', requireSignIn, (req, res, next) => { 32 | logger.log('debug', `The record API was invoked: ${req.url}`) 33 | next() // pass control to the next handler 34 | }) 35 | 36 | /** 37 | * GET endpoint for starting the remote browser recording session. 38 | * returns session's id 39 | */ 40 | router.get('/start', requireSignIn, async (req: AuthenticatedRequest, res: Response) => { 41 | if (!req.user) { 42 | return res.status(401).send('User not authenticated'); 43 | } 44 | const proxyConfig = await getDecryptedProxyConfig(req.user.id); 45 | // Prepare the proxy options dynamically based on the user's proxy configuration 46 | let proxyOptions: any = {}; // Default to no proxy 47 | 48 | if (proxyConfig.proxy_url) { 49 | // Set the server, and if username & password exist, set those as well 50 | proxyOptions = { 51 | server: proxyConfig.proxy_url, 52 | ...(proxyConfig.proxy_username && proxyConfig.proxy_password && { 53 | username: proxyConfig.proxy_username, 54 | password: proxyConfig.proxy_password, 55 | }), 56 | }; 57 | } 58 | 59 | const id = initializeRemoteBrowserForRecording({ 60 | browser: chromium, 61 | launchOptions: { 62 | headless: true, 63 | proxy: proxyOptions.server ? proxyOptions : undefined, 64 | } 65 | }, req.user.id); 66 | return res.send(id); 67 | }); 68 | 69 | /** 70 | * POST endpoint for starting the remote browser recording session accepting browser launch options. 71 | * returns session's id 72 | */ 73 | router.post('/start', requireSignIn, (req: AuthenticatedRequest, res:Response) => { 74 | if (!req.user) { 75 | return res.status(401).send('User not authenticated'); 76 | } 77 | const id = initializeRemoteBrowserForRecording({ 78 | browser: chromium, 79 | launchOptions: req.body, 80 | }, req.user.id); 81 | return res.send(id); 82 | }); 83 | 84 | /** 85 | * GET endpoint for terminating the remote browser recording session. 86 | * returns whether the termination was successful 87 | */ 88 | router.get('/stop/:browserId', requireSignIn, async (req, res) => { 89 | const success = await destroyRemoteBrowser(req.params.browserId); 90 | return res.send(success); 91 | }); 92 | 93 | /** 94 | * GET endpoint for getting the id of the active remote browser. 95 | */ 96 | router.get('/active', requireSignIn, (req, res) => { 97 | const id = getActiveBrowserId(); 98 | return res.send(id); 99 | }); 100 | 101 | /** 102 | * GET endpoint for getting the current url of the active remote browser. 103 | */ 104 | router.get('/active/url', requireSignIn, (req, res) => { 105 | const id = getActiveBrowserId(); 106 | if (id) { 107 | const url = getRemoteBrowserCurrentUrl(id); 108 | return res.send(url); 109 | } 110 | return res.send(null); 111 | }); 112 | 113 | /** 114 | * GET endpoint for getting the current tabs of the active remote browser. 115 | */ 116 | router.get('/active/tabs', requireSignIn, (req, res) => { 117 | const id = getActiveBrowserId(); 118 | if (id) { 119 | const hosts = getRemoteBrowserCurrentTabs(id); 120 | return res.send(hosts); 121 | } 122 | return res.send([]); 123 | }); 124 | 125 | /** 126 | * GET endpoint for starting an interpretation of the currently generated workflow. 127 | */ 128 | router.get('/interpret', requireSignIn, async (req, res) => { 129 | try { 130 | await interpretWholeWorkflow(); 131 | return res.send('interpretation done'); 132 | } catch (e) { 133 | return res.send('interpretation failed'); 134 | } 135 | }); 136 | 137 | /** 138 | * GET endpoint for stopping an ongoing interpretation of the currently generated workflow. 139 | */ 140 | router.get('/interpret/stop', requireSignIn, async (req, res) => { 141 | await stopRunningInterpretation(); 142 | return res.send('interpretation stopped'); 143 | }); 144 | -------------------------------------------------------------------------------- /src/components/organisms/LeftSidePanel.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper, Tab, Tabs } from "@mui/material"; 2 | import React, { useCallback, useEffect, useState } from "react"; 3 | import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow"; 4 | import { useSocketStore } from '../../context/socket'; 5 | import { WhereWhatPair, WorkflowFile } from "maxun-core"; 6 | import { SidePanelHeader } from "../molecules/SidePanelHeader"; 7 | import { emptyWorkflow } from "../../shared/constants"; 8 | import { LeftSidePanelContent } from "../molecules/LeftSidePanelContent"; 9 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 10 | import { useGlobalInfoStore } from "../../context/globalInfo"; 11 | import { TabContext, TabPanel } from "@mui/lab"; 12 | import { LeftSidePanelSettings } from "../molecules/LeftSidePanelSettings"; 13 | import { RunSettings } from "../molecules/RunSettings"; 14 | 15 | const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { 16 | getActiveWorkflow(id).then( 17 | (response) => { 18 | if (response) { 19 | callback(response); 20 | } else { 21 | throw new Error("No workflow found"); 22 | } 23 | } 24 | ).catch((error) => { console.log(error.message) }) 25 | }; 26 | 27 | interface LeftSidePanelProps { 28 | sidePanelRef: HTMLDivElement | null; 29 | alreadyHasScrollbar: boolean; 30 | recordingName: string; 31 | handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; 32 | } 33 | 34 | export const LeftSidePanel = ( 35 | { sidePanelRef, alreadyHasScrollbar, recordingName, handleSelectPairForEdit }: LeftSidePanelProps) => { 36 | 37 | const [workflow, setWorkflow] = useState(emptyWorkflow); 38 | const [hasScrollbar, setHasScrollbar] = useState(alreadyHasScrollbar); 39 | const [tab, setTab] = useState('recording'); 40 | const [params, setParams] = useState([]); 41 | const [settings, setSettings] = React.useState({ 42 | maxConcurrency: 1, 43 | maxRepeats: 1, 44 | debug: false, 45 | }); 46 | 47 | const { id, socket } = useSocketStore(); 48 | const { setWidth, width } = useBrowserDimensionsStore(); 49 | const { setRecordingLength } = useGlobalInfoStore(); 50 | 51 | const workflowHandler = useCallback((data: WorkflowFile) => { 52 | setWorkflow(data); 53 | setRecordingLength(data.workflow.length); 54 | }, [workflow]) 55 | 56 | useEffect(() => { 57 | // fetch the workflow every time the id changes 58 | if (id) { 59 | fetchWorkflow(id, workflowHandler); 60 | } 61 | // fetch workflow in 15min intervals 62 | let interval = setInterval(() => { 63 | if (id) { 64 | fetchWorkflow(id, workflowHandler); 65 | } 66 | }, (900 * 60 * 15)); 67 | return () => clearInterval(interval) 68 | }, [id]); 69 | 70 | useEffect(() => { 71 | if (socket) { 72 | socket.on("workflow", workflowHandler); 73 | } 74 | 75 | if (sidePanelRef) { 76 | const workflowListHeight = sidePanelRef.clientHeight; 77 | const innerHeightWithoutNavbar = window.innerHeight - 70; 78 | if (innerHeightWithoutNavbar <= workflowListHeight) { 79 | if (!hasScrollbar) { 80 | setWidth(width - 10); 81 | setHasScrollbar(true); 82 | } 83 | } else { 84 | if (hasScrollbar && !alreadyHasScrollbar) { 85 | setWidth(width + 10); 86 | setHasScrollbar(false); 87 | } 88 | } 89 | } 90 | 91 | return () => { 92 | socket?.off('workflow', workflowHandler); 93 | } 94 | }, [socket, workflowHandler]); 95 | 96 | return ( 97 | 108 | {/* */} 109 | 110 | setTab(newTab)}> 111 | 112 | { 113 | getParamsOfActiveWorkflow(id).then((response) => { 114 | if (response) { 115 | setParams(response); 116 | } 117 | }) 118 | }} /> 119 | 120 | 121 | 127 | 128 | 129 | 131 | 132 | 133 | 134 | ); 135 | 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/molecules/SaveRecording.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState, useContext } from 'react'; 2 | import { Button, Box, LinearProgress, Tooltip } from "@mui/material"; 3 | import { GenericModal } from "../atoms/GenericModal"; 4 | import { stopRecording } from "../../api/recording"; 5 | import { useGlobalInfoStore } from "../../context/globalInfo"; 6 | import { AuthContext } from '../../context/auth'; 7 | import { useSocketStore } from "../../context/socket"; 8 | import { TextField, Typography } from "@mui/material"; 9 | import { WarningText } from "../atoms/texts"; 10 | import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; 11 | import { useNavigate } from 'react-router-dom'; 12 | 13 | interface SaveRecordingProps { 14 | fileName: string; 15 | } 16 | 17 | export const SaveRecording = ({ fileName }: SaveRecordingProps) => { 18 | 19 | const [openModal, setOpenModal] = useState(false); 20 | const [needConfirm, setNeedConfirm] = useState(false); 21 | const [recordingName, setRecordingName] = useState(fileName); 22 | const [waitingForSave, setWaitingForSave] = useState(false); 23 | 24 | const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); 25 | const { socket } = useSocketStore(); 26 | const { state, dispatch } = useContext(AuthContext); 27 | const { user } = state; 28 | const navigate = useNavigate(); 29 | 30 | const handleChangeOfTitle = (event: React.ChangeEvent) => { 31 | const { value } = event.target; 32 | if (needConfirm) { 33 | setNeedConfirm(false); 34 | } 35 | setRecordingName(value); 36 | } 37 | 38 | const handleSaveRecording = async (event: React.SyntheticEvent) => { 39 | event.preventDefault(); 40 | if (recordings.includes(recordingName)) { 41 | if (needConfirm) { return; } 42 | setNeedConfirm(true); 43 | } else { 44 | await saveRecording(); 45 | } 46 | }; 47 | 48 | const exitRecording = useCallback(async () => { 49 | notify('success', 'Recording saved successfully'); 50 | if (browserId) { 51 | await stopRecording(browserId); 52 | } 53 | setBrowserId(null); 54 | navigate('/'); 55 | }, [setBrowserId, browserId, notify]); 56 | 57 | // notifies backed to save the recording in progress, 58 | // releases resources and changes the view for main page by clearing the global browserId 59 | const saveRecording = async () => { 60 | if (user) { 61 | const payload = { fileName: recordingName, userId: user.id }; 62 | socket?.emit('save', payload); 63 | setWaitingForSave(true); 64 | console.log(`Saving the recording as ${recordingName} for userId ${user.id}`); 65 | } else { 66 | console.error('User not logged in. Cannot save recording.'); 67 | } 68 | }; 69 | 70 | useEffect(() => { 71 | socket?.on('fileSaved', exitRecording); 72 | return () => { 73 | socket?.off('fileSaved', exitRecording); 74 | } 75 | }, [socket, exitRecording]); 76 | 77 | return ( 78 |
79 | 82 | 83 | setOpenModal(false)} modalStyle={modalStyle}> 84 |
85 | Save Robot 86 | 95 | {needConfirm 96 | ? 97 | ( 98 | 99 | 100 | 101 | Robot with this name already exists, please confirm the Robot's overwrite. 102 | 103 | ) 104 | : 105 | } 106 | {waitingForSave && 107 | 108 | 109 | 110 | 111 | 112 | } 113 | 114 |
115 |
116 | ); 117 | } 118 | 119 | const modalStyle = { 120 | top: '25%', 121 | left: '50%', 122 | transform: 'translate(-50%, -50%)', 123 | width: '30%', 124 | backgroundColor: 'background.paper', 125 | p: 4, 126 | height: 'fit-content', 127 | display: 'block', 128 | padding: '20px', 129 | }; 130 | -------------------------------------------------------------------------------- /src/components/molecules/AddWhatCondModal.tsx: -------------------------------------------------------------------------------- 1 | import { WhereWhatPair } from "maxun-core"; 2 | import { GenericModal } from "../atoms/GenericModal"; 3 | import { modalStyle } from "./AddWhereCondModal"; 4 | import { Button, MenuItem, TextField, Typography } from "@mui/material"; 5 | import React, { useRef } from "react"; 6 | import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; 7 | import { KeyValueForm } from "./KeyValueForm"; 8 | import { ClearButton } from "../atoms/buttons/ClearButton"; 9 | import { useSocketStore } from "../../context/socket"; 10 | 11 | interface AddWhatCondModalProps { 12 | isOpen: boolean; 13 | onClose: () => void; 14 | pair: WhereWhatPair; 15 | index: number; 16 | } 17 | 18 | export const AddWhatCondModal = ({isOpen, onClose, pair, index}: AddWhatCondModalProps) => { 19 | const [action, setAction] = React.useState(''); 20 | const [objectIndex, setObjectIndex] = React.useState(0); 21 | const [args, setArgs] = React.useState<({type: string, value: (string|number|object|unknown)})[]>([]); 22 | 23 | const objectRefs = useRef<({getObject: () => object}|unknown)[]>([]); 24 | 25 | const {socket} = useSocketStore(); 26 | 27 | const handleSubmit = () => { 28 | const argsArray: (string|number|object|unknown)[] = []; 29 | args.map((arg, index) => { 30 | switch (arg.type) { 31 | case 'string': 32 | case 'number': 33 | argsArray[index] = arg.value; 34 | break; 35 | case 'object': 36 | // @ts-ignore 37 | argsArray[index] = objectRefs.current[arg.value].getObject(); 38 | } 39 | }) 40 | setArgs([]); 41 | onClose(); 42 | pair.what.push({ 43 | // @ts-ignore 44 | action, 45 | args: argsArray, 46 | }) 47 | socket?.emit('updatePair', {index: index-1, pair: pair}); 48 | } 49 | 50 | return ( 51 | { 52 | setArgs([]); 53 | onClose(); 54 | }} modalStyle={modalStyle}> 55 |
56 | Add what condition: 57 |
58 | Action: 59 | setAction(e.target.value)} 63 | value={action} 64 | label='action' 65 | /> 66 |
67 | Add new argument of type: 68 | 69 | 70 | 74 |
75 | args: 76 | {args.map((arg, index) => { 77 | // @ts-ignore 78 | return ( 79 |
81 | { 82 | args.splice(index,1); 83 | setArgs([...args]); 84 | }}/> 85 | {index}: 86 | {arg.type === 'string' ? 87 | setArgs([ 91 | ...args.slice(0, index), 92 | {type: arg.type, value: e.target.value}, 93 | ...args.slice(index + 1) 94 | ])} 95 | value={args[index].value || ''} 96 | label="string" 97 | key={`arg-${arg.type}-${index}`} 98 | /> : arg.type === 'number' ? 99 | setArgs([ 104 | ...args.slice(0, index), 105 | {type: arg.type, value: Number(e.target.value)}, 106 | ...args.slice(index + 1) 107 | ])} 108 | value={args[index].value || ''} 109 | label="number" 110 | /> : 111 | 112 | //@ts-ignore 113 | objectRefs.current[arg.value] = el} key={`arg-${arg.type}-${index}`}/> 114 | } 115 |
116 | )})} 117 | 129 |
130 |
131 |
132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /server/src/routes/workflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RESTful API endpoints handling currently generated workflow management. 3 | */ 4 | 5 | import { Router } from 'express'; 6 | import logger from "../logger"; 7 | import { browserPool } from "../server"; 8 | import { requireSignIn } from '../middlewares/auth'; 9 | import Robot from '../models/Robot'; 10 | 11 | export const router = Router(); 12 | 13 | /** 14 | * Logs information about workflow API. 15 | */ 16 | router.all('/', requireSignIn, (req, res, next) => { 17 | logger.log('debug', `The workflow API was invoked: ${req.url}`) 18 | next() // pass control to the next handler 19 | }) 20 | 21 | /** 22 | * GET endpoint for a recording linked to a remote browser instance. 23 | * returns session's id 24 | */ 25 | router.get('/:browserId', requireSignIn, (req, res) => { 26 | const activeBrowser = browserPool.getRemoteBrowser(req.params.browserId); 27 | let workflowFile = null; 28 | if (activeBrowser && activeBrowser.generator) { 29 | workflowFile = activeBrowser.generator.getWorkflowFile(); 30 | } 31 | return res.send(workflowFile); 32 | }); 33 | 34 | /** 35 | * Get endpoint returning the parameter array of the recording associated with the browserId browser instance. 36 | */ 37 | router.get('/params/:browserId', requireSignIn, (req, res) => { 38 | const activeBrowser = browserPool.getRemoteBrowser(req.params.browserId); 39 | let params = null; 40 | if (activeBrowser && activeBrowser.generator) { 41 | params = activeBrowser.generator.getParams(); 42 | } 43 | return res.send(params); 44 | }); 45 | 46 | /** 47 | * DELETE endpoint for deleting a pair from the generated workflow. 48 | */ 49 | router.delete('/pair/:index', requireSignIn, (req, res) => { 50 | const id = browserPool.getActiveBrowserId(); 51 | if (id) { 52 | const browser = browserPool.getRemoteBrowser(id); 53 | if (browser) { 54 | browser.generator?.removePairFromWorkflow(parseInt(req.params.index)); 55 | const workflowFile = browser.generator?.getWorkflowFile(); 56 | return res.send(workflowFile); 57 | } 58 | } 59 | return res.send(null); 60 | }); 61 | 62 | /** 63 | * POST endpoint for adding a pair to the generated workflow. 64 | */ 65 | router.post('/pair/:index', requireSignIn, (req, res) => { 66 | const id = browserPool.getActiveBrowserId(); 67 | if (id) { 68 | const browser = browserPool.getRemoteBrowser(id); 69 | logger.log('debug', `Adding pair to workflow`); 70 | if (browser) { 71 | logger.log('debug', `Adding pair to workflow: ${JSON.stringify(req.body)}`); 72 | if (req.body.pair) { 73 | browser.generator?.addPairToWorkflow(parseInt(req.params.index), req.body.pair); 74 | const workflowFile = browser.generator?.getWorkflowFile(); 75 | return res.send(workflowFile); 76 | } 77 | } 78 | } 79 | return res.send(null); 80 | }); 81 | 82 | /** 83 | * PUT endpoint for updating a pair in the generated workflow. 84 | */ 85 | router.put('/pair/:index', requireSignIn, (req, res) => { 86 | const id = browserPool.getActiveBrowserId(); 87 | if (id) { 88 | const browser = browserPool.getRemoteBrowser(id); 89 | logger.log('debug', `Updating pair in workflow`); 90 | if (browser) { 91 | logger.log('debug', `New value: ${JSON.stringify(req.body)}`); 92 | if (req.body.pair) { 93 | browser.generator?.updatePairInWorkflow(parseInt(req.params.index), req.body.pair); 94 | const workflowFile = browser.generator?.getWorkflowFile(); 95 | return res.send(workflowFile); 96 | } 97 | } 98 | } 99 | return res.send(null); 100 | }); 101 | 102 | /** 103 | * PUT endpoint for updating the currently generated workflow file from the one in the storage. 104 | */ 105 | router.put('/:browserId/:id', requireSignIn, async (req, res) => { 106 | try { 107 | const browser = browserPool.getRemoteBrowser(req.params.browserId); 108 | logger.log('debug', `Updating workflow for Robot: ${req.params.id}`); 109 | 110 | if (browser && browser.generator) { 111 | const robot = await Robot.findOne({ 112 | where: { 113 | 'recording_meta.id': req.params.id 114 | }, 115 | raw: true 116 | }); 117 | 118 | if (!robot) { 119 | logger.log('info', `Robot not found with ID: ${req.params.id}`); 120 | return res.status(404).send({ error: 'Robot not found' }); 121 | } 122 | 123 | const { recording, recording_meta } = robot; 124 | 125 | if (recording && recording.workflow) { 126 | browser.generator.updateWorkflowFile(recording, recording_meta); 127 | const workflowFile = browser.generator.getWorkflowFile(); 128 | return res.send(workflowFile); 129 | } else { 130 | logger.log('info', `Invalid recording data for Robot ID: ${req.params.id}`); 131 | return res.status(400).send({ error: 'Invalid recording data' }); 132 | } 133 | } 134 | 135 | logger.log('info', `Browser or generator not available for ID: ${req.params.id}`); 136 | return res.status(400).send({ error: 'Browser or generator not available' }); 137 | } catch (e) { 138 | const { message } = e as Error; 139 | logger.log('error', `Error while updating workflow for Robot ID: ${req.params.id}. Error: ${message}`); 140 | return res.status(500).send({ error: 'Internal server error' }); 141 | } 142 | }); 143 | 144 | export default router; --------------------------------------------------------------------------------