Merge pull request #181 from getmaxun/robot-duplication

feat: robot duplication
This commit is contained in:
Karishma Shukla
2024-11-19 02:06:18 +05:30
committed by GitHub
9 changed files with 308 additions and 74 deletions

View File

@@ -16,12 +16,12 @@ export const requireSignIn = (req: UserRequest, res: Response, next: any) => {
}
verify(token, secret, (err: any, user: any) => {
console.log(err)
if (err) return res.sendStatus(403)
req.user = user;
next()
})
if (err) {
console.log('JWT verification error:', err);
return res.sendStatus(403);
}
req.user = { ...user, id: user.userId }; // Normalize to include `id`
next();
});
};

View File

@@ -145,6 +145,80 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
});
/**
* POST endpoint to duplicate a robot and update its target URL.
*/
router.post('/recordings/:id/duplicate', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
const { id } = req.params;
const { targetUrl } = req.body;
if (!targetUrl) {
return res.status(400).json({ error: 'The "targetUrl" field is required.' });
}
const originalRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
if (!originalRobot) {
return res.status(404).json({ error: 'Original robot not found.' });
}
const lastWord = targetUrl.split('/').filter(Boolean).pop() || 'Unnamed';
const workflow = originalRobot.recording.workflow.map((step) => {
if (step.where?.url && step.where.url !== "about:blank") {
step.where.url = targetUrl;
}
step.what.forEach((action) => {
if (action.action === "goto" && action.args?.length) {
action.args[0] = targetUrl;
}
});
return step;
});
const currentTimestamp = new Date().toISOString();
const newRobot = await Robot.create({
id: uuid(),
userId: originalRobot.userId,
recording_meta: {
...originalRobot.recording_meta,
id: uuid(),
name: `${originalRobot.recording_meta.name} (${lastWord})`,
createdAt: currentTimestamp,
updatedAt: currentTimestamp,
},
recording: { ...originalRobot.recording, workflow },
google_sheet_email: null,
google_sheet_name: null,
google_sheet_id: null,
google_access_token: null,
google_refresh_token: null,
schedule: null,
});
logger.log('info', `Robot with ID ${id} duplicated successfully as ${newRobot.id}.`);
return res.status(201).json({
message: 'Robot duplicated and target URL updated successfully.',
robot: newRobot,
});
} catch (error) {
if (error instanceof Error) {
logger.log('error', `Error duplicating robot with ID ${req.params.id}: ${error.message}`);
return res.status(500).json({ error: error.message });
} else {
logger.log('error', `Unknown error duplicating robot with ID ${req.params.id}`);
return res.status(500).json({ error: 'An unknown error occurred.' });
}
}
});
/**
* DELETE endpoint for deleting a recording from the storage.

View File

@@ -33,6 +33,22 @@ export const updateRecording = async (id: string, data: { name?: string; limit?:
}
};
export const duplicateRecording = async (id: string, targetUrl: string): Promise<any> => {
try {
const response = await axios.post(`${apiUrl}/storage/recordings/${id}/duplicate`, {
targetUrl,
});
if (response.status === 201) {
return response.data; // Returns the duplicated robot details
} else {
throw new Error(`Couldn't duplicate recording with id ${id}`);
}
} catch (error: any) {
console.error(`Error duplicating recording: ${error.message}`);
return null;
}
};
export const getStoredRuns = async (): Promise<string[] | null> => {
try {
const response = await axios.get(`${apiUrl}/storage/runs`);

View File

@@ -91,9 +91,10 @@ interface RecordingsTableProps {
handleIntegrateRecording: (id: string, fileName: string, params: string[]) => void;
handleSettingsRecording: (id: string, fileName: string, params: string[]) => void;
handleEditRobot: (id: string, name: string, params: string[]) => void;
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
}
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot }: RecordingsTableProps) => {
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const [rows, setRows] = React.useState<Data[]>([]);
@@ -264,8 +265,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
})
}}
handleDuplicate={() => {
notify('info', 'Duplicating recording...');
// Implement duplication logic here
handleDuplicateRobot(row.id, row.name, row.params || []);
}}
/>
</TableCell>

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button, Chip } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo';
import { duplicateRecording, getStoredRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core';
import { getUserById } from "../../api/auth";
interface RobotMeta {
name: string;
id: string;
createdAt: string;
pairs: number;
updatedAt: string;
params: any[];
}
interface RobotWorkflow {
workflow: WhereWhatPair[];
}
interface ScheduleConfig {
runEvery: number;
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
atTimeStart?: string;
atTimeEnd?: string;
timezone: string;
lastRunAt?: Date;
nextRunAt?: Date;
cronExpression?: string;
}
export interface RobotSettings {
id: string;
userId?: number;
recording_meta: RobotMeta;
recording: RobotWorkflow;
google_sheet_email?: string | null;
google_sheet_name?: string | null;
google_sheet_id?: string | null;
google_access_token?: string | null;
google_refresh_token?: string | null;
schedule?: ScheduleConfig | null;
}
interface RobotSettingsProps {
isOpen: boolean;
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
const { recordingId, notify } = useGlobalInfoStore();
useEffect(() => {
if (isOpen) {
getRobot();
}
}, [isOpen]);
useEffect(() => {
// Update the targetUrl when the robot data is loaded
if (robot) {
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
setTargetUrl(url);
}
}, [robot]);
const getRobot = async () => {
if (recordingId) {
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
}
}
// const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
// // Find the `goto` action in `what` and retrieve its arguments
// const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTargetUrl(e.target.value);
};
const handleSave = async () => {
if (!robot || !targetUrl) {
notify('error', 'Target URL is required.');
return;
}
console.log("handle save");
try {
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
if (success) {
notify('success', 'Target URL updated successfully.');
handleStart(robot); // Inform parent about the updated robot
handleClose(); // Close the modal
window.location.reload();
} else {
notify('error', 'Failed to update the Target URL. Please try again.');
}
} catch (error) {
notify('error', 'An error occurred while updating the Target URL.');
console.error('Error updating Target URL:', error);
}
};
return (
<GenericModal
isOpen={isOpen}
onClose={handleClose}
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
<br />
<span>
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
</span>
<br />
<span>
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
</span>
<TextField
label="Robot Target URL"
key="Robot Target URL"
value={targetUrl}
onChange={handleTargetUrlChange}
style={{ marginBottom: '20px', marginTop: '30px' }}
/>
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Duplicate Robot
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
</Button>
</Box>
</>
)
}
</Box>
</>
</GenericModal>
);
};

View File

@@ -54,10 +54,11 @@ interface RobotSettingsProps {
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
console.log("robot edit");
const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore();

View File

@@ -6,6 +6,7 @@ import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSe
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings";
import { RobotEditModal } from '../molecules/RobotEdit';
import { RobotDuplicationModal } from '../molecules/RobotDuplicate';
interface RecordingsProps {
handleEditRecording: (id: string, fileName: string) => void;
@@ -20,11 +21,13 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false);
const [robotEditAreOpen, setRobotEditAreOpen] = useState(false);
const [robotDuplicateAreOpen, setRobotDuplicateAreOpen] = useState(false);
const [params, setParams] = useState<string[]>([]);
const [selectedRecordingId, setSelectedRecordingId] = useState<string>('');
const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {};
const handleSettingsRecording = (id: string, settings: RobotSettings) => {};
const handleEditRobot = (id: string, settings: RobotSettings) => {};
const handleDuplicateRobot = (id: string, settings: RobotSettings) => {};
const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => {
if (params.length === 0) {
@@ -91,6 +94,19 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
}
}
const handleDuplicateRobotOption = (id: string, name: string, params: string[]) => {
if (params.length === 0) {
setRobotDuplicateAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
} else {
setParams(params);
setRobotDuplicateAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
}
}
const handleClose = () => {
setParams([]);
setRunSettingsAreOpen(false);
@@ -126,6 +142,13 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
setSelectedRecordingId('');
}
const handleRobotDuplicateClose = () => {
setParams([]);
setRobotDuplicateAreOpen(false);
setRecordingInfo('', '');
setSelectedRecordingId('');
}
return (
<React.Fragment>
<RunSettingsModal isOpen={runSettingsAreOpen}
@@ -150,6 +173,10 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleClose={handleRobotEditClose}
handleStart={(settings) => handleEditRobot(selectedRecordingId,settings)}
/>
<RobotDuplicationModal isOpen={robotDuplicateAreOpen}
handleClose={handleRobotDuplicateClose}
handleStart={(settings) => handleDuplicateRobot(selectedRecordingId, settings)}
/>
<Grid container direction="column" sx={{ padding: '30px' }}>
<Grid item xs>
<RecordingsTable
@@ -159,6 +186,7 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleIntegrateRecording={handleSettingsAndIntegrate}
handleSettingsRecording={handleRobotSettings}
handleEditRobot={handleEditRobotOption}
handleDuplicateRobot={handleDuplicateRobotOption}
/>
</Grid>
</Grid>

View File

@@ -1,7 +1,6 @@
import { useReducer, createContext, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import { apiUrl } from "../apiConfig";
interface AuthProviderProps {
@@ -51,74 +50,28 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
const navigate = useNavigate();
axios.defaults.withCredentials = true;
const logoutUser = () => {
dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user');
window.localStorage.removeItem('logoutTimeout');
navigate('/login');
};
const checkTokenExpiration = (token: string) => {
if (!token) return;
try {
const decodedToken: any = jwtDecode(token);
const currentTime = Date.now();
const tokenExpiryTime = decodedToken.exp * 1000;
if (tokenExpiryTime > currentTime) {
// Calculate remaining time until token expires
const remainingTime = tokenExpiryTime - currentTime;
// Check if a logout timeout already exists in local storage
const existingTimeout = window.localStorage.getItem('logoutTimeout');
if (!existingTimeout) {
// Set a timeout for auto-logout
const timeoutId = setTimeout(logoutUser, remainingTime);
window.localStorage.setItem('logoutTimeout', JSON.stringify(timeoutId));
}
} else {
logoutUser(); // Immediately logout if token is expired
}
} catch (error) {
console.error("Error decoding token:", error);
logoutUser();
}
};
useEffect(() => {
const storedUser = window.localStorage.getItem('user');
if (storedUser) {
const userData = JSON.parse(storedUser);
dispatch({ type: 'LOGIN', payload: userData });
// Run expiration check only if a token exists
if (userData.token) {
checkTokenExpiration(userData.token);
}
dispatch({ type: 'LOGIN', payload: JSON.parse(storedUser) });
}
// Clean up timeout on component unmount
return () => {
const timeoutId = window.localStorage.getItem('logoutTimeout');
if (timeoutId) {
clearTimeout(JSON.parse(timeoutId));
window.localStorage.removeItem('logoutTimeout');
}
};
}, []); // Only run this effect once on mount
}, []);
axios.interceptors.response.use(
(response) => response,
(error) => {
function (response) {
return response;
},
function (error) {
const res = error.response;
if (res?.status === 401 && !res.config.__isRetryRequest) {
if (res.status === 401 && res.config && !res.config.__isRetryRequest) {
return new Promise((resolve, reject) => {
axios
.get(`${apiUrl}/auth/logout`)
.then(() => {
console.log('/401 error > logout');
logoutUser();
dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user');
navigate('/login');
})
.catch((err) => {
console.error('AXIOS INTERCEPTORS ERROR:', err);
@@ -129,7 +82,7 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
return Promise.reject(error);
}
);
return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}

View File

@@ -28,7 +28,8 @@ a {
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
monospace;
color: #ff00c3;
}
#browser-actions {
@@ -53,9 +54,6 @@ code {
transform-origin: top left; /* Keep the position fixed */
}
#browser {
}
#browser-window {
overflow-y: auto;
height: 100%;