ui-nyla/src/hooks/playground/useDashboardLogTree.ts

238 lines
8.4 KiB
TypeScript

import { useState, useCallback, useRef } from 'react';
import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes';
interface OperationData {
logs: Map<string, WorkflowLog>;
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<string, OperationData>;
rootOperations: string[];
logExpandedStates: Map<string, boolean>;
currentRound: number | null;
}
export function useDashboardLogTree() {
const [tree, setTree] = useState<DashboardLogTree>({
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
currentRound: null
});
const treeRef = useRef<DashboardLogTree>(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<string>();
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
};
}