From 8eedca1b1e7591db3cdcbc1ee25c59f929c68c0d Mon Sep 17 00:00:00 2001 From: AmitChauhan63390 Date: Fri, 15 Nov 2024 16:16:01 +0530 Subject: [PATCH 1/4] Recording deletion warning while runs are active --- server/src/api/record.ts | 3 +- src/api/storage.ts | 44 +++++++++++++++++++- src/components/molecules/RecordingsTable.tsx | 16 ++++++- src/components/organisms/ApiKey.tsx | 3 ++ src/components/organisms/BrowserWindow.tsx | 3 +- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/server/src/api/record.ts b/server/src/api/record.ts index b55f06bc..573a8edb 100644 --- a/server/src/api/record.ts +++ b/server/src/api/record.ts @@ -289,7 +289,7 @@ router.get("/robots/:id", requireAPIKey, async (req: Request, res: Response) => * type: string * example: "Failed to retrieve runs" */ -router.get("/robots/:id/runs", requireAPIKey, async (req: Request, res: Response) => { +router.get("/robots/:id/runs",requireAPIKey, async (req: Request, res: Response) => { try { const runs = await Run.findAll({ where: { @@ -321,6 +321,7 @@ router.get("/robots/:id/runs", requireAPIKey, async (req: Request, res: Response } ); + function formatRunResponse(run: any) { const formattedRun = { id: run.id, diff --git a/src/api/storage.ts b/src/api/storage.ts index 9b4b06b2..22cd9a28 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -5,6 +5,10 @@ import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; import { CreateRunResponse, ScheduleRunResponse } from "../pages/MainPage"; import { apiUrl } from "../apiConfig"; + + + + export const getStoredRecordings = async (): Promise => { try { const response = await axios.get(`${apiUrl}/storage/recordings`); @@ -47,16 +51,52 @@ export const getStoredRecording = async (id: string) => { } } + +export const checkRunsForRecording = async (id: string): Promise => { + const apiKey = localStorage.getItem('x-api-key'); + + // Check if the API key exists + if (!apiKey) { + console.error('API key is missing.'); + return false; + } + + try { + const response = await axios.get(`${apiUrl}/api/robots/${id}/runs`, { + headers: { + 'x-api-key': apiKey, // Pass the valid API key in the header + }, + withCredentials: true, + }); + + const runs = response.data; + return runs.runs.totalCount > 0; + } catch (error) { + console.error('Error checking runs for recording:', error); + return false; + } +}; + export const deleteRecordingFromStorage = async (id: string): Promise => { + + const hasRuns = await checkRunsForRecording(id); + + if (hasRuns) { + + return false; + } + try { const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`); if (response.status === 200) { - return response.data; + + return true; } else { throw new Error(`Couldn't delete stored recording ${id}`); } } catch (error: any) { console.log(error); + return false; } }; @@ -93,7 +133,7 @@ export const createRunForStoredRecording = async (id: string, settings: RunSetti try { const response = await axios.put( `${apiUrl}/storage/runs/${id}`, - { ...settings }); + { ...settings }); if (response.status === 200) { return response.data; } else { diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index c5895193..557b0b70 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -13,7 +13,7 @@ import { IconButton, Button, Box, Typography, TextField } from "@mui/material"; import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power } from "@mui/icons-material"; import LinkIcon from '@mui/icons-material/Link'; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; +import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { Add } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { stopRecording } from "../../api/recording"; @@ -159,6 +159,13 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl } }, []); + const hasAssociatedRuns = async (robotId: string): Promise => { + + const associatedRuns = await fetch(`/api/robot/${robotId}/runs`); + const data = await associatedRuns.json(); + return data.length > 0; + }; + return ( @@ -252,6 +259,13 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl return ( { + checkRunsForRecording(row.id).then((result: boolean) => { + if (result) { + notify('warning', 'Recording has associated runs, please delete them first'); + } + + }) + deleteRecordingFromStorage(row.id).then((result: boolean) => { if (result) { setRows([]); diff --git a/src/components/organisms/ApiKey.tsx b/src/components/organisms/ApiKey.tsx index d9f4c6fe..49675538 100644 --- a/src/components/organisms/ApiKey.tsx +++ b/src/components/organisms/ApiKey.tsx @@ -36,6 +36,8 @@ const ApiKeyManager = () => { const [copySuccess, setCopySuccess] = useState(false); const { notify } = useGlobalInfoStore(); + + useEffect(() => { const fetchApiKey = async () => { try { @@ -56,6 +58,7 @@ const ApiKeyManager = () => { try { const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); setApiKey(data.api_key); + localStorage.setItem('x-api-key', data.api_key); notify('success', `Generated API Key successfully`); } catch (error: any) { notify('error', `Failed to generate API Key - ${error.message}`); diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/organisms/BrowserWindow.tsx index cbc46731..697b4adb 100644 --- a/src/components/organisms/BrowserWindow.tsx +++ b/src/components/organisms/BrowserWindow.tsx @@ -8,6 +8,7 @@ import { useActionContext } from '../../context/browserActions'; import { useBrowserSteps, TextStep } from '../../context/browserSteps'; import { useGlobalInfoStore } from '../../context/globalInfo'; + interface ElementInfo { tagName: string; hasOnlyText?: boolean; @@ -316,7 +317,7 @@ export const BrowserWindow = () => { } }, [paginationMode, resetPaginationSelector]); - + return (
{ From 8d4994c8c723b96bf386667d5020f49b58fcf045 Mon Sep 17 00:00:00 2001 From: AmitChauhan63390 Date: Fri, 15 Nov 2024 22:26:36 +0530 Subject: [PATCH 2/4] Route corrected --- server/src/routes/storage.ts | 55 ++++++++++++++++++++ src/api/storage.ts | 26 ++++----- src/components/molecules/RecordingsTable.tsx | 39 ++++++++------ src/components/organisms/ApiKey.tsx | 5 +- 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 228f60de..833e6e6b 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -16,6 +16,7 @@ import { workflowQueue } from '../worker'; import { AuthenticatedRequest } from './record'; import { computeNextRun } from '../utils/schedule'; import { capture } from "../utils/analytics"; +import { tryCatch } from 'bullmq'; export const router = Router(); @@ -57,6 +58,60 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => { } }) +router.get(('/recordings/:id/runs'), requireSignIn, async (req, res) => { + try { + const runs = await Run.findAll({ + where: { + robotMetaId: req.params.id + }, + raw: true + }); + const formattedRuns = runs.map(formatRunResponse); + const response = { + statusCode: 200, + messageCode: "success", + runs: { + totalCount: formattedRuns.length, + items: formattedRuns, + }, + }; + + res.status(200).json(response); +} catch (error) { + console.error("Error fetching runs:", error); + res.status(500).json({ + statusCode: 500, + messageCode: "error", + message: "Failed to retrieve runs", + }); +} +}) + +function formatRunResponse(run: any) { + const formattedRun = { + id: run.id, + status: run.status, + name: run.name, + robotId: run.robotMetaId, // Renaming robotMetaId to robotId + startedAt: run.startedAt, + finishedAt: run.finishedAt, + runId: run.runId, + runByUserId: run.runByUserId, + runByScheduleId: run.runByScheduleId, + runByAPI: run.runByAPI, + data: {}, + screenshot: null, + }; + + if (run.serializableOutput && run.serializableOutput['item-0']) { + formattedRun.data = run.serializableOutput['item-0']; + } else if (run.binaryOutput && run.binaryOutput['item-0']) { + formattedRun.screenshot = run.binaryOutput['item-0']; + } + + return formattedRun; +} + /** * DELETE endpoint for deleting a recording from the storage. */ diff --git a/src/api/storage.ts b/src/api/storage.ts index 22cd9a28..e48c7091 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -9,6 +9,7 @@ import { apiUrl } from "../apiConfig"; + export const getStoredRecordings = async (): Promise => { try { const response = await axios.get(`${apiUrl}/storage/recordings`); @@ -52,24 +53,15 @@ export const getStoredRecording = async (id: string) => { } + export const checkRunsForRecording = async (id: string): Promise => { - const apiKey = localStorage.getItem('x-api-key'); - - // Check if the API key exists - if (!apiKey) { - console.error('API key is missing.'); - return false; - } - + + try { - const response = await axios.get(`${apiUrl}/api/robots/${id}/runs`, { - headers: { - 'x-api-key': apiKey, // Pass the valid API key in the header - }, - withCredentials: true, - }); + const response = await axios.get(`${apiUrl}/storage/recordings/${id}/runs`); const runs = response.data; + console.log(runs.runs.totalCount) return runs.runs.totalCount > 0; } catch (error) { console.error('Error checking runs for recording:', error); @@ -77,6 +69,7 @@ export const checkRunsForRecording = async (id: string): Promise => { } }; + export const deleteRecordingFromStorage = async (id: string): Promise => { const hasRuns = await checkRunsForRecording(id); @@ -85,7 +78,6 @@ export const deleteRecordingFromStorage = async (id: string): Promise = return false; } - try { const response = await axios.delete(`${apiUrl}/storage/recordings/${id}`); if (response.status === 200) { @@ -99,6 +91,10 @@ export const deleteRecordingFromStorage = async (id: string): Promise = return false; } + + + + }; export const deleteRunFromStorage = async (id: string): Promise => { diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 557b0b70..36a66fbe 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -18,6 +18,8 @@ import { Add } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; import { stopRecording } from "../../api/recording"; import { GenericModal } from '../atoms/GenericModal'; +import axios from 'axios'; +import { apiUrl } from '../../apiConfig'; /** TODO: @@ -159,12 +161,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl } }, []); - const hasAssociatedRuns = async (robotId: string): Promise => { - - const associatedRuns = await fetch(`/api/robot/${robotId}/runs`); - const data = await associatedRuns.json(); - return data.length > 0; - }; + return ( @@ -258,21 +255,31 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl case 'delete': return ( - { + { + checkRunsForRecording(row.id).then((result: boolean) => { if (result) { - notify('warning', 'Recording has associated runs, please delete them first'); + notify('warning', 'Cannot delete recording as it has active runs'); } - }) + + + deleteRecordingFromStorage(row.id).then((result: boolean) => { + if (result) { + setRows([]); + notify('success', 'Recording deleted successfully'); + fetchRecordings(); + } + }) + - deleteRecordingFromStorage(row.id).then((result: boolean) => { - if (result) { - setRows([]); - notify('success', 'Recording deleted successfully'); - fetchRecordings(); - } - }) + + + + + + + }}> diff --git a/src/components/organisms/ApiKey.tsx b/src/components/organisms/ApiKey.tsx index 49675538..675edb72 100644 --- a/src/components/organisms/ApiKey.tsx +++ b/src/components/organisms/ApiKey.tsx @@ -38,6 +38,8 @@ const ApiKeyManager = () => { + + useEffect(() => { const fetchApiKey = async () => { try { @@ -51,6 +53,7 @@ const ApiKeyManager = () => { }; fetchApiKey(); + }, []); const generateApiKey = async () => { @@ -58,7 +61,7 @@ const ApiKeyManager = () => { try { const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`); setApiKey(data.api_key); - localStorage.setItem('x-api-key', data.api_key); + notify('success', `Generated API Key successfully`); } catch (error: any) { notify('error', `Failed to generate API Key - ${error.message}`); From 9fa40e41893f9c5032fec732c30a621ba71ed9b7 Mon Sep 17 00:00:00 2001 From: RohitR311 Date: Tue, 19 Nov 2024 02:40:00 +0530 Subject: [PATCH 3/4] fix: resolved merge conflict --- server/src/routes/storage.ts | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 833e6e6b..95ca2709 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -112,6 +112,165 @@ function formatRunResponse(run: any) { return formattedRun; } +/** + * PUT endpoint to update the name and limit of a robot. + */ +router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + const { name, limit } = req.body; + + // Validate input + if (!name && limit === undefined) { + return res.status(400).json({ error: 'Either "name" or "limit" must be provided.' }); + } + + // Fetch the robot by ID + const robot = await Robot.findOne({ where: { 'recording_meta.id': id } }); + + if (!robot) { + return res.status(404).json({ error: 'Robot not found.' }); + } + + // Update fields if provided + if (name) { + robot.set('recording_meta', { ...robot.recording_meta, name }); + } + + // Update the limit + if (limit !== undefined) { + const workflow = [...robot.recording.workflow]; // Create a copy of the workflow + + // Ensure the workflow structure is valid before updating + if ( + workflow.length > 0 && + workflow[0]?.what?.[0] + ) { + // Create a new workflow object with the updated limit + const updatedWorkflow = workflow.map((step, index) => { + if (index === 0) { // Assuming you want to update the first step + return { + ...step, + what: step.what.map((action, actionIndex) => { + if (actionIndex === 0) { // Assuming the first action needs updating + return { + ...action, + args: (action.args ?? []).map((arg, argIndex) => { + if (argIndex === 0) { // Assuming the first argument needs updating + return { ...arg, limit }; + } + return arg; + }), + }; + } + return action; + }), + }; + } + return step; + }); + + // Replace the workflow in the recording object + robot.set('recording', { ...robot.recording, workflow: updatedWorkflow }); + } else { + return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' }); + } + } + + await robot.save(); + + const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } }); + console.log('After save:', updatedRobot); + + // Log the update + logger.log('info', `Robot with ID ${id} was updated successfully.`); + + return res.status(200).json({ message: 'Robot updated successfully', robot }); + } catch (error) { + // Safely handle the error type + if (error instanceof Error) { + logger.log('error', `Error updating robot with ID ${req.params.id}: ${error.message}`); + return res.status(500).json({ error: error.message }); + } else { + logger.log('error', `Unknown error updating robot with ID ${req.params.id}`); + return res.status(500).json({ error: 'An unknown error occurred.' }); + } + } +}); + + +/** + * 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. */ From 77dd866985a0622d3203afb7f8c49d91eae2c732 Mon Sep 17 00:00:00 2001 From: RohitR311 Date: Tue, 19 Nov 2024 02:41:02 +0530 Subject: [PATCH 4/4] fix: resolved merge conflict --- src/components/molecules/RecordingsTable.tsx | 127 +++++++++++++------ 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 36a66fbe..16f72149 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -9,8 +9,8 @@ import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; import { useEffect } from "react"; import { WorkflowFile } from "maxun-core"; -import { IconButton, Button, Box, Typography, TextField } from "@mui/material"; -import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power } from "@mui/icons-material"; +import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText } from "@mui/material"; +import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, } from "@mui/icons-material"; import LinkIcon from '@mui/icons-material/Link'; import { useGlobalInfoStore } from "../../context/globalInfo"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; @@ -20,6 +20,7 @@ import { stopRecording } from "../../api/recording"; import { GenericModal } from '../atoms/GenericModal'; import axios from 'axios'; import { apiUrl } from '../../apiConfig'; +import { Menu as MenuIcon } from '@mui/icons-material'; /** TODO: @@ -28,7 +29,7 @@ import { apiUrl } from '../../apiConfig'; */ interface Column { - id: 'interpret' | 'name' | 'delete' | 'schedule' | 'integrate' | 'settings'; + id: 'interpret' | 'name' | 'options' | 'schedule' | 'integrate' | 'settings'; label: string; minWidth?: number; align?: 'right'; @@ -71,8 +72,8 @@ const columns: readonly Column[] = [ minWidth: 80, }, { - id: 'delete', - label: 'Delete', + id: 'options', + label: 'Options', minWidth: 80, }, ]; @@ -92,9 +93,11 @@ interface RecordingsTableProps { handleScheduleRecording: (id: string, fileName: string, params: string[]) => void; 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 }: 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([]); @@ -252,39 +255,31 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl handleIntegrateRecording(row.id, row.name, row.params || [])} /> ); - case 'delete': - return ( - - { - - checkRunsForRecording(row.id).then((result: boolean) => { - if (result) { - notify('warning', 'Cannot delete recording as it has active runs'); - } - }) - - - deleteRecordingFromStorage(row.id).then((result: boolean) => { - if (result) { - setRows([]); - notify('success', 'Recording deleted successfully'); - fetchRecordings(); - } - }) - - - - - - - - - - }}> - - - - ); + case 'options': + return ( + + handleEditRobot(row.id, row.name, row.params || [])} + handleDelete={() => { + checkRunsForRecording(row.id).then((result: boolean) => { + if (result) { + notify('warning', 'Cannot delete recording as it has active runs'); + } + }) + deleteRecordingFromStorage(row.id).then((result: boolean) => { + if (result) { + setRows([]); + notify('success', 'Recording deleted successfully'); + fetchRecordings(); + } + }) + }} + handleDuplicate={() => { + handleDuplicateRobot(row.id, row.name, row.params || []); + }} + /> + + ); case 'settings': return ( @@ -398,6 +393,60 @@ const SettingsButton = ({ handleSettings }: SettingsButtonProps) => { ) } +interface OptionsButtonProps { + handleEdit: () => void; + handleDelete: () => void; + handleDuplicate: () => void; +} + +const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsButtonProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + { handleEdit(); handleClose(); }}> + + + + Edit + + { handleDelete(); handleClose(); }}> + + + + Delete + + { handleDuplicate(); handleClose(); }}> + + + + Duplicate + + + + ); +}; + const modalStyle = { top: '50%', left: '50%',