import { useEffect, useRef, useState } from "react"; import * as React from "react"; import TableRow from "@mui/material/TableRow"; import TableCell from "@mui/material/TableCell"; import { Box, Collapse, IconButton, Typography, Chip, TextField } from "@mui/material"; import { Button } from "@mui/material"; import { DeleteForever, KeyboardArrowDown, KeyboardArrowUp, Settings } from "@mui/icons-material"; import { deleteRunFromStorage } from "../../api/storage"; import { columns, Data } from "./RunsTable"; import { RunContent } from "./RunContent"; import { GenericModal } from "../ui/GenericModal"; import { getUserById } from "../../api/auth"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { io, Socket } from "socket.io-client"; import { apiUrl } from "../../apiConfig"; const socketCache = new Map(); const progressCallbacks = new Map void>>(); function getOrCreateSocket(browserId: string): Socket { if (socketCache.has(browserId)) { return socketCache.get(browserId)!; } const socket = io(`${apiUrl}/${browserId}`, { transports: ["websocket"], rejectUnauthorized: false }); socket.on('workflowProgress', (data: any) => { const callbacks = progressCallbacks.get(browserId); if (callbacks) { callbacks.forEach(cb => cb(data)); } }); socketCache.set(browserId, socket); return socket; } function cleanupSocketIfUnused(browserId: string) { const callbacks = progressCallbacks.get(browserId); if (!callbacks || callbacks.size === 0) { const socket = socketCache.get(browserId); if (socket) { socket.disconnect(); socketCache.delete(browserId); progressCallbacks.delete(browserId); } } } interface RunTypeChipProps { runByUserId?: string; runByScheduledId?: string; runByAPI: boolean; } const RunTypeChip: React.FC = ({ runByUserId, runByScheduledId, runByAPI }) => { const { t } = useTranslation(); if (runByScheduledId) return ; if (runByAPI) return ; if (runByUserId) return ; return ; }; interface CollapsibleRowProps { row: Data; handleDelete: () => void; isOpen: boolean; onToggleExpanded: (shouldExpand: boolean) => void; currentLog: string; abortRunHandler: (runId: string, robotName: string, browserId: string) => void; runningRecordingName: string; urlRunId: string | null; } export const CollapsibleRow = ({ row, handleDelete, isOpen, onToggleExpanded, currentLog, abortRunHandler, runningRecordingName, urlRunId }: CollapsibleRowProps) => { const { t } = useTranslation(); const theme = useTheme(); const [isDeleteOpen, setDeleteOpen] = useState(false); const [openSettingsModal, setOpenSettingsModal] = useState(false); const [userEmail, setUserEmail] = useState(null); const runByLabel = row.runByScheduleId ? `${row.runByScheduleId}` : row.runByUserId ? `${userEmail}` : row.runByAPI ? 'API' : 'Unknown'; const logEndRef = useRef(null); const [workflowProgress, setWorkflowProgress] = useState<{ current: number; total: number; percentage: number; } | null>(null); // Subscribe to progress updates using module-level socket cache useEffect(() => { if (!row.browserId) return; // Get or create socket (from module cache) getOrCreateSocket(row.browserId); // Register callback if (!progressCallbacks.has(row.browserId)) { progressCallbacks.set(row.browserId, new Set()); } const callback = (data: any) => { setWorkflowProgress(data); }; progressCallbacks.get(row.browserId)!.add(callback); // Cleanup: remove callback and cleanup socket if no callbacks remain return () => { const callbacks = progressCallbacks.get(row.browserId); if (callbacks) { callbacks.delete(callback); // Cleanup socket if this was the last callback cleanupSocketIfUnused(row.browserId); } }; }, [row.browserId]); // Clear progress UI when run completes and trigger socket cleanup useEffect(() => { if (row.status !== 'running' && row.status !== 'queued') { setWorkflowProgress(null); // Attempt to cleanup socket when run completes // (will only cleanup if no other callbacks exist) if (row.browserId) { cleanupSocketIfUnused(row.browserId); } } }, [row.status, row.browserId]); const handleAbort = () => { abortRunHandler(row.runId, row.name, row.browserId); } const handleRowExpand = () => { const newOpen = !isOpen; onToggleExpanded(newOpen); }; useEffect(() => { const fetchUserEmail = async () => { if (row.runByUserId) { const userData = await getUserById(row.runByUserId); if (userData && userData.user) { setUserEmail(userData.user.email); } } }; fetchUserEmail(); }, [row.runByUserId]); const handleConfirmDelete = async () => { try { const res = await deleteRunFromStorage(`${row.runId}`); if (res) { handleDelete(); } } finally { setDeleteOpen(false); } }; return ( *': { borderBottom: 'unset' } }} hover role="checkbox" tabIndex={-1} key={row.id}> {isOpen ? : } {columns.map((column) => { // @ts-ignore const value: any = row[column.id]; if (value !== undefined) { return ( {value} ); } else { switch (column.id) { case 'runStatus': return ( {row.status === 'success' && } {row.status === 'running' && } {row.status === 'scheduled' && } {row.status === 'queued' && } {row.status === 'failed' && } {row.status === 'aborted' && } ) case 'delete': return ( setDeleteOpen(true)}> ); case 'settings': return ( setOpenSettingsModal(true)}> setOpenSettingsModal(false)} modalStyle={modalStyle} > <> {t('runs_table.run_settings_modal.title')} {t('runs_table.run_settings_modal.labels.run_type')}: ) default: return null; } } })} setDeleteOpen(false)} modalStyle={{ ...modalStyle, padding: 0, backgroundColor: 'transparent', width: 'auto', maxWidth: '520px' }}> {t('runs_table.delete_confirm.title', { name: row.name, defaultValue: 'Delete run "{{name}}"?' })} {t('runs_table.delete_confirm.message', { name: row.name, defaultValue: 'Are you sure you want to delete the run "{{name}}"?' })} ); } export const modalStyle = { top: '45%', left: '50%', transform: 'translate(-50%, -50%)', width: '30%', backgroundColor: 'background.paper', p: 4, height: 'fit-content', display: 'block', padding: '20px', };