import { useState, useCallback, useRef } from 'react'; import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes'; interface OperationData { logs: Map; parentId: string | null; expanded: boolean; latestProgress: number | null; latestStatus: string | null; operationName: string | null; // Stable name from first log latestMessage: string | null; // Latest status message that updates } interface DashboardLogTree { operations: Map; rootOperations: string[]; logExpandedStates: Map; currentRound: number | null; } export function useDashboardLogTree() { const [tree, setTree] = useState({ operations: new Map(), rootOperations: [], logExpandedStates: new Map(), currentRound: null }); const treeRef = useRef(tree); treeRef.current = tree; const generateLogId = useCallback((log: WorkflowLog): string => { if (log.id) { return log.id; } return `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; }, []); const processDashboardLogs = useCallback((logs: WorkflowLog[]) => { setTree(prevTree => { const newTree: DashboardLogTree = { operations: new Map(prevTree.operations), rootOperations: [...prevTree.rootOperations], logExpandedStates: new Map(prevTree.logExpandedStates), currentRound: prevTree.currentRound }; // Process each log logs.forEach(log => { if (!log.operationId) { return; // Skip logs without operationId } const operationId = log.operationId; const logId = generateLogId(log); // Get or create operation const existingOperation = newTree.operations.get(operationId); // Create new logs Map (copy existing logs if updating) const logsMap = existingOperation ? new Map(existingOperation.logs) : new Map(); // Store log (Map ensures uniqueness by logId) logsMap.set(logId, log); // Determine stable operation name (only set once, never change) // Always use formatted operationId as the stable name - don't use log messages // Log messages are status updates and should go in latestMessage, not operationName let operationName = existingOperation?.operationName || null; if (operationName === null) { // Remove UUIDs and timestamps from operationId before formatting // UUID pattern: 8-4-4-4-12 hex digits (e.g., "1e6d7b14-4f30-40e2-b7a6-748b63b6a7f5") // Also remove standalone long hex strings that might be timestamps or IDs let cleanedId = operationId .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') // Remove UUIDs .replace(/\b[0-9a-f]{32,}\b/gi, '') // Remove long hex strings (timestamps/IDs) .replace(/\s+/g, ' ') // Normalize whitespace .trim(); // Format by splitting on dashes/underscores and capitalizing // This creates a stable, readable name like "Workflow Planning" from "workflow-planning" const formattedName = cleanedId .split(/[-_\s]+/) .filter(word => word.length > 0) // Remove empty strings .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); operationName = formattedName || operationId.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '').trim(); } // Update latest message (for status tag) - this updates with each poll const latestMessage = log.message || existingOperation?.latestMessage || null; // Update parentId if not set yet (from first log entry) const parentId = existingOperation?.parentId !== null && existingOperation?.parentId !== undefined ? existingOperation.parentId : (log.parentId !== undefined && log.parentId !== null ? log.parentId : null); // Update latest progress (use latest value) const latestProgress = log.progress !== undefined && log.progress !== null ? log.progress : existingOperation?.latestProgress ?? null; // Update latest status (use latest value) const latestStatus = log.status !== undefined && log.status !== null ? log.status : existingOperation?.latestStatus ?? null; // Create new operation object to ensure React detects the change const operation: OperationData = { logs: logsMap, parentId, expanded: existingOperation?.expanded ?? false, latestProgress, latestStatus, operationName, latestMessage }; newTree.operations.set(operationId, operation); }); // Rebuild root operations list (operations without parentId) // Use Set to ensure uniqueness, then convert back to array const rootOpsSet = new Set(); newTree.operations.forEach((op, opId) => { if (op.parentId === null) { rootOpsSet.add(opId); } }); // Sort by timestamp of earliest log entry (chronological order) newTree.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => { const opA = newTree.operations.get(opIdA); const opB = newTree.operations.get(opIdB); if (!opA || !opB) return 0; // Get earliest log timestamp for each operation const logsA = Array.from(opA.logs.values()); const logsB = Array.from(opB.logs.values()); if (logsA.length === 0 && logsB.length === 0) return 0; if (logsA.length === 0) return 1; // Put operations without logs at the end if (logsB.length === 0) return -1; const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); return earliestA - earliestB; // Ascending order (oldest first) }); return newTree; }); }, [generateLogId]); const clearDashboard = useCallback((resetRound: boolean = false) => { setTree({ operations: new Map(), rootOperations: [], logExpandedStates: new Map(), currentRound: resetRound ? null : treeRef.current.currentRound }); }, []); const toggleOperationExpanded = useCallback((operationId: string) => { setTree(prevTree => { const operation = prevTree.operations.get(operationId); if (!operation) { return prevTree; } const newTree: DashboardLogTree = { ...prevTree, operations: new Map(prevTree.operations) }; const updatedOperation = { ...operation, expanded: !operation.expanded }; newTree.operations.set(operationId, updatedOperation); return newTree; }); }, []); const updateCurrentRound = useCallback((round: number | null) => { setTree(prevTree => { // Clear dashboard if round changes if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) { return { operations: new Map(), rootOperations: [], logExpandedStates: new Map(), currentRound: round }; } return { ...prevTree, currentRound: round }; }); }, []); const getChildOperations = useCallback((parentId: string | null): string[] => { const currentTree = treeRef.current; const childOps = Array.from(currentTree.operations.entries()) .filter(([_, op]) => op.parentId === parentId) .map(([opId, op]) => ({ opId, op })); // Sort by timestamp of earliest log entry (chronological order) return childOps.sort((a, b) => { const logsA = Array.from(a.op.logs.values()); const logsB = Array.from(b.op.logs.values()); if (logsA.length === 0 && logsB.length === 0) return 0; if (logsA.length === 0) return 1; // Put operations without logs at the end if (logsB.length === 0) return -1; const earliestA = Math.min(...logsA.map(log => log.timestamp || 0)); const earliestB = Math.min(...logsB.map(log => log.timestamp || 0)); return earliestA - earliestB; // Ascending order (oldest first) }).map(({ opId }) => opId); }, []); return { tree, processDashboardLogs, clearDashboard, toggleOperationExpanded, updateCurrentRound, getChildOperations }; }