updated log rendering
This commit is contained in:
parent
d3c950d735
commit
641930b1d0
11 changed files with 2097 additions and 273 deletions
575
docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md
Normal file
575
docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
# Dashboard Log Polling and Rendering Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This documentation explains the complete flow of how dashboard messages (logs with `operationId`) are polled, processed, sorted, and rendered in the workflow dashboard. The system uses a hierarchical tree structure to display operations and their progress, with real-time updates through polling.
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
The system follows this flow:
|
||||
|
||||
1. **Polling Controller** (`workflowPollingController.js`) - Manages polling intervals and scheduling
|
||||
2. **Data Layer** (`workflowData.js`) - Fetches data from API and routes logs to appropriate handlers
|
||||
3. **Dashboard Processor** (`workflowUiRendererDashboard.js`) - Processes logs with `operationId` and builds hierarchical tree
|
||||
4. **Dashboard Renderer** (`workflowUiRendererDashboard.js`) - Renders the hierarchical tree structure
|
||||
|
||||
## Key Files
|
||||
|
||||
- `workflowPollingController.js` - Centralized polling controller
|
||||
- `workflowData.js` - API communication and data routing
|
||||
- `workflowUiRendererDashboard.js` - Dashboard log processing and rendering
|
||||
- `workflowCoordination.js` - State management coordination
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Polling Mechanism
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowPollingController.js`
|
||||
|
||||
The polling controller uses a recursive `setTimeout` approach to create an infinite polling chain. This ensures continuous updates while preventing race conditions and rate limiting issues.
|
||||
|
||||
#### Configuration
|
||||
|
||||
- **Base interval**: 5 seconds (`baseInterval = 5000`)
|
||||
- **Maximum interval**: 10 seconds (`maxInterval = 10000`)
|
||||
- **Exponential backoff multiplier**: 1.5
|
||||
- **Concurrency prevention**: Uses `isPollInProgress` flag to prevent multiple simultaneous polls
|
||||
|
||||
#### Key Methods
|
||||
|
||||
**`startPolling(workflowId)`**
|
||||
- Starts polling for a specific workflow
|
||||
- Stops any existing polling before starting new one
|
||||
- Sets `activeWorkflowId` and `isPolling` flag
|
||||
- Executes immediate first poll (no delay)
|
||||
- Validates workflow ID before starting
|
||||
|
||||
**`doPolling()`**
|
||||
- Executes one poll cycle asynchronously
|
||||
- Prevents concurrent execution using `isPollInProgress` flag
|
||||
- Calls `pollWorkflowData()` from `workflowData.js`
|
||||
- Handles errors and implements exponential backoff on failures
|
||||
- Self-schedules next poll using recursive `setTimeout`
|
||||
- Validates workflow is still valid before scheduling next poll
|
||||
|
||||
**`stopPolling()`**
|
||||
- Stops all polling operations immediately
|
||||
- Clears all scheduled timeouts
|
||||
- Resets all state flags (`isPolling`, `isPollInProgress`, `activeWorkflowId`)
|
||||
- Resets failure count
|
||||
|
||||
**`pausePolling()` / `resumePolling()`**
|
||||
- Temporarily pauses polling (e.g., during user interactions)
|
||||
- Resumes polling after pause
|
||||
|
||||
#### Polling Flow
|
||||
|
||||
```javascript
|
||||
startPolling(workflowId)
|
||||
↓
|
||||
doPolling() [immediate first poll]
|
||||
↓
|
||||
pollWorkflowData(workflowId) [async API call]
|
||||
↓
|
||||
setTimeout(() => doPolling(), interval) [schedule next poll]
|
||||
↓
|
||||
[recursive loop continues until stopped]
|
||||
```
|
||||
|
||||
#### Error Handling
|
||||
|
||||
- **Rate limiting (429 errors)**: Increases backoff more aggressively, stops polling after 5 consecutive rate limit errors
|
||||
- **Network errors**: Logged but don't immediately stop polling (allows retry)
|
||||
- **Workflow validation**: Checks if workflow is still valid before each poll cycle
|
||||
- **Poll failures**: Exponential backoff increases interval up to `maxInterval`
|
||||
|
||||
### 2. Data Fetching
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowData.js`
|
||||
|
||||
The `pollWorkflowData()` function orchestrates the data fetching process.
|
||||
|
||||
#### API Calls
|
||||
|
||||
The function makes two parallel API calls:
|
||||
|
||||
1. **`api.getWorkflow(workflowId)`** - Fetches workflow status and metadata
|
||||
2. **`api.getWorkflowChatData(workflowId, afterTimestamp)`** - Fetches unified chat data (messages, logs, stats)
|
||||
|
||||
#### Incremental Polling
|
||||
|
||||
- **First poll**: `afterTimestamp = null` → Fetches ALL historical data
|
||||
- **Subsequent polls**: `afterTimestamp = workflowState.lastRenderedTimestamp` → Fetches only new items since last render
|
||||
- **Timestamp tracking**: Uses `createdAt` timestamp from each item to track what's been rendered
|
||||
|
||||
#### Data Processing
|
||||
|
||||
The `processUnifiedChatData()` function processes items in chronological order:
|
||||
|
||||
1. Routes each item based on `type` field:
|
||||
- `'message'` → `processUnifiedMessage()`
|
||||
- `'log'` → `processUnifiedLog()`
|
||||
- `'stat'` → `processUnifiedStat()`
|
||||
|
||||
2. Updates `lastRenderedTimestamp` after processing each item (ensures accurate incremental polling)
|
||||
|
||||
3. Processes items sequentially to maintain chronological order
|
||||
|
||||
#### Workflow Status Updates
|
||||
|
||||
- Monitors workflow status changes
|
||||
- Updates UI buttons and controls when status changes
|
||||
- Handles special case: Ignores 'completed' status if workflow is in Round 2+ (prevents premature stopping)
|
||||
|
||||
#### Polling Continuation Logic
|
||||
|
||||
Polling continues based on workflow status:
|
||||
- **'running'**: Continues polling
|
||||
- **'completed'**: Continues polling temporarily to get final messages, then stops
|
||||
- **'failed' / 'stopped'**: Stops polling immediately
|
||||
- **Other statuses**: Stops polling
|
||||
|
||||
### 3. Log Routing
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowData.js` - `processUnifiedLog()`
|
||||
|
||||
Logs are routed to different rendering areas based on the presence of `operationId`:
|
||||
|
||||
#### Routing Logic
|
||||
|
||||
```javascript
|
||||
if (log.operationId) {
|
||||
// Logs WITH operationId → Dashboard
|
||||
processDashboardLogs([frontendLog]);
|
||||
} else {
|
||||
// Logs WITHOUT operationId → Unified Content Area
|
||||
WorkflowCoordination.addLogEntry(frontendLog.message, frontendLog.type, frontendLog);
|
||||
}
|
||||
```
|
||||
|
||||
#### Log Format Conversion
|
||||
|
||||
Backend `ChatLog` format is converted to frontend format:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: log.id,
|
||||
message: log.message,
|
||||
type: log.type || 'info',
|
||||
timestamp: log.timestamp,
|
||||
status: log.status || 'running',
|
||||
progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined,
|
||||
performance: log.performance,
|
||||
operationId: log.operationId || null,
|
||||
parentId: log.parentId || null
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Points
|
||||
|
||||
- **All logs are processed**: No duplicates are skipped (logs may contain progress updates)
|
||||
- **Progress tracking**: Logs with `operationId` typically contain progress information
|
||||
- **Hierarchical structure**: `parentId` field enables parent-child relationships between operations
|
||||
|
||||
### 4. Dashboard Log Processing
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `processDashboardLogs()`
|
||||
|
||||
This function processes logs with `operationId` and builds the hierarchical tree structure.
|
||||
|
||||
#### Processing Steps
|
||||
|
||||
1. **Group by operationId**
|
||||
- Creates or updates operation groups in `dashboardLogTree.operations` Map
|
||||
- Each operation stores logs in a Map keyed by `logId` (ensures uniqueness)
|
||||
|
||||
2. **Update operation metadata**
|
||||
- Updates `parentId` if not set yet (from first log entry)
|
||||
- Updates `latestProgress` when log contains progress value
|
||||
- Updates `latestStatus` when log contains status value
|
||||
|
||||
3. **Generate unique log IDs**
|
||||
- Uses provided `log.id` if available
|
||||
- Otherwise generates: `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
||||
- Ensures all progress updates are stored, even with same progress value
|
||||
|
||||
4. **Build root operations list**
|
||||
- Filters operations without `parentId`
|
||||
- Stores in `dashboardLogTree.rootOperations` array
|
||||
|
||||
5. **Trigger rendering**
|
||||
- Calls `renderDashboard()` after processing all logs
|
||||
|
||||
#### Data Structure
|
||||
|
||||
```javascript
|
||||
dashboardLogTree = {
|
||||
operations: Map<operationId, {
|
||||
logs: Map<logId, log>, // All logs for this operation
|
||||
parentId: string | null, // Parent operation ID (if nested)
|
||||
expanded: boolean, // UI expanded/collapsed state
|
||||
latestProgress: number | null, // Most recent progress value
|
||||
latestStatus: string | null // Most recent status value
|
||||
}>,
|
||||
rootOperations: string[], // Operation IDs without parent
|
||||
logExpandedStates: Map<logId, boolean>, // Individual log expanded states
|
||||
currentRound: number | null // Current workflow round
|
||||
}
|
||||
```
|
||||
|
||||
#### Important Behaviors
|
||||
|
||||
- **All logs stored**: Every log with same `operationId` is stored (represents progress updates)
|
||||
- **Latest values tracked**: `latestProgress` and `latestStatus` always reflect most recent state
|
||||
- **Parent-child relationships**: Operations can nest via `parentId` field
|
||||
|
||||
### 5. Sorting
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js`
|
||||
|
||||
Multiple sorting mechanisms ensure consistent display order:
|
||||
|
||||
#### Operation-Level Log Sorting
|
||||
|
||||
**Location**: `renderOperationNode()` function, lines 169-173
|
||||
|
||||
Logs within an operation are sorted by timestamp in ascending order:
|
||||
|
||||
```javascript
|
||||
const logsArray = Array.from(operation.logs.values()).sort((a, b) => {
|
||||
const tsA = a.timestamp || 0;
|
||||
const tsB = b.timestamp || 0;
|
||||
return tsA - tsB; // Ascending order (oldest first)
|
||||
});
|
||||
```
|
||||
|
||||
**Purpose**: Ensures logs are displayed in chronological order within each operation.
|
||||
|
||||
#### Child Operations Sorting
|
||||
|
||||
**Location**: `getChildOperations()` function, line 453
|
||||
|
||||
Child operations are sorted alphabetically by `operationId`:
|
||||
|
||||
```javascript
|
||||
return Array.from(dashboardLogTree.operations.entries())
|
||||
.filter(([opId, op]) => op.parentId === parentId)
|
||||
.map(([opId]) => opId)
|
||||
.sort(); // Alphabetical sort for consistent ordering
|
||||
```
|
||||
|
||||
**Purpose**: Provides consistent, predictable ordering of sibling operations.
|
||||
|
||||
#### Timeline Sorting (Unified Content)
|
||||
|
||||
**Location**: `workflowUiRenderer.js` - `renderUnifiedContent()` function
|
||||
|
||||
Logs without `operationId` are combined with messages and sorted by timestamp:
|
||||
|
||||
```javascript
|
||||
timeline.sort((a, b) => a.timestamp - b.timestamp);
|
||||
```
|
||||
|
||||
**Purpose**: Creates a unified chronological timeline of all non-dashboard content.
|
||||
|
||||
#### Sorting Summary
|
||||
|
||||
| Context | Sort Key | Order | Purpose |
|
||||
|---------|----------|-------|---------|
|
||||
| Logs within operation | `timestamp` | Ascending | Chronological display |
|
||||
| Child operations | `operationId` | Alphabetical | Consistent ordering |
|
||||
| Unified timeline | `timestamp` | Ascending | Chronological timeline |
|
||||
|
||||
### 6. Rendering
|
||||
|
||||
**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `renderDashboard()`
|
||||
|
||||
The rendering system creates a hierarchical tree structure with collapsible nodes and progress indicators.
|
||||
|
||||
#### Hierarchical Structure
|
||||
|
||||
- **Root operations**: Operations without `parentId` are rendered first
|
||||
- **Child operations**: Operations with `parentId` matching a parent's `operationId` are nested
|
||||
- **Single line per operation**: Each operation shows ONE line that updates with latest status/progress
|
||||
- **All logs represented**: All logs with same `operationId` are represented by this single updating line
|
||||
|
||||
#### Rendering Process
|
||||
|
||||
**Step 1: `renderDashboard()`**
|
||||
- Builds HTML from `dashboardLogTree` structure
|
||||
- Handles empty state (no operations)
|
||||
- Sets up event handlers for collapse/expand functionality
|
||||
|
||||
**Step 2: `renderOperationNode(operationId, depth)`** (Recursive)
|
||||
- Renders a single operation node
|
||||
- Calculates indentation based on depth (8px per level)
|
||||
- Determines if operation has child operations
|
||||
- Gets latest log entry for operation name and type
|
||||
- Calculates progress percentage (forces 100% when status is 'completed')
|
||||
- Builds HTML for:
|
||||
- Expand/collapse button (if has children)
|
||||
- Operation icon (based on log type)
|
||||
- Operation name (from latest log message)
|
||||
- Status and progress percentage
|
||||
- Progress bar (if progress available)
|
||||
- Recursively renders child operations if expanded
|
||||
|
||||
#### Visual Elements
|
||||
|
||||
**Operation Header**
|
||||
- Expand/collapse button (chevron icon)
|
||||
- Operation icon (info/success/error/warning)
|
||||
- Operation name (from latest log message)
|
||||
- Status badge (running/completed/failed/etc.)
|
||||
- Progress percentage (if available)
|
||||
|
||||
**Progress Bar**
|
||||
- Visual progress indicator
|
||||
- Width based on progress percentage (0-100%)
|
||||
- "completed" class when progress >= 100%
|
||||
- Hidden if no progress value
|
||||
|
||||
**Indentation**
|
||||
- Root level (depth 0): No indentation
|
||||
- Child levels: Indented via parent container padding (8px per level)
|
||||
- Creates visual hierarchy
|
||||
|
||||
#### State Management
|
||||
|
||||
**Expanded/Collapsed State**
|
||||
- Stored in `operation.expanded` boolean
|
||||
- Toggled via `toggleOperationExpanded(operationId)`
|
||||
- Persists during re-renders
|
||||
- Controls visibility of child operations container
|
||||
|
||||
**Event Handlers**
|
||||
- `setupCollapseExpandHandlers()`: Sets up click handlers for expand buttons
|
||||
- `setupLogCollapseExpandHandlers()`: Sets up handlers for log entry expansion
|
||||
- Click handlers toggle expanded state and re-render dashboard
|
||||
|
||||
#### Rendering Flow
|
||||
|
||||
```
|
||||
renderDashboard()
|
||||
↓
|
||||
[For each root operation]
|
||||
renderOperationNode(operationId, 0)
|
||||
↓
|
||||
[Build operation header HTML]
|
||||
↓
|
||||
[If has children and expanded]
|
||||
[For each child operation]
|
||||
renderOperationNode(childOperationId, depth)
|
||||
↓
|
||||
[Recursive rendering continues...]
|
||||
↓
|
||||
[Set innerHTML of dashboard container]
|
||||
↓
|
||||
[Setup event handlers]
|
||||
```
|
||||
|
||||
#### Key Rendering Features
|
||||
|
||||
1. **Progress Updates**: Operation line updates in-place as new logs arrive
|
||||
2. **Status Changes**: Status badge updates when operation status changes
|
||||
3. **Collapsible Tree**: Users can expand/collapse operation groups
|
||||
4. **Visual Hierarchy**: Indentation shows parent-child relationships
|
||||
5. **Latest State**: Always shows most recent log message, progress, and status
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Dashboard Log Tree
|
||||
|
||||
```javascript
|
||||
{
|
||||
operations: Map<operationId, {
|
||||
logs: Map<logId, log>, // All logs for this operation
|
||||
parentId: string | null, // Parent operation ID
|
||||
expanded: boolean, // UI expanded state
|
||||
latestProgress: number | null, // Most recent progress (0-1)
|
||||
latestStatus: string | null // Most recent status
|
||||
}>,
|
||||
rootOperations: string[], // Operation IDs without parent
|
||||
logExpandedStates: Map<logId, boolean>, // Individual log expanded states
|
||||
currentRound: number | null // Current workflow round
|
||||
}
|
||||
```
|
||||
|
||||
### Log Entry Format
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: string, // Unique log ID
|
||||
message: string, // Log message text
|
||||
type: 'info' | 'success' | 'error' | 'warning',
|
||||
timestamp: number, // Unix timestamp (seconds)
|
||||
status: string, // Operation status
|
||||
progress: number | null, // Progress value (0-1) or null
|
||||
operationId: string | null, // Operation ID (null = unified content)
|
||||
parentId: string | null // Parent operation ID (for nesting)
|
||||
}
|
||||
```
|
||||
|
||||
### Unified Chat Data Item
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'message' | 'log' | 'stat', // Item type
|
||||
item: { /* message/log/stat data */ },
|
||||
createdAt: number // Timestamp for sorting
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Incremental Polling
|
||||
|
||||
- Uses `lastRenderedTimestamp` to fetch only new items
|
||||
- First poll loads all historical data (`afterTimestamp = null`)
|
||||
- Subsequent polls fetch incrementally (`afterTimestamp = lastRenderedTimestamp`)
|
||||
- Reduces API load and improves performance
|
||||
|
||||
### 2. Hierarchical Display
|
||||
|
||||
- Operations can have parent-child relationships via `parentId`
|
||||
- Visual indentation shows hierarchy
|
||||
- Collapsible tree structure for better UX
|
||||
- Supports unlimited nesting depth
|
||||
|
||||
### 3. Progress Tracking
|
||||
|
||||
- Shows progress bars for operations with progress values
|
||||
- Updates in real-time as new logs arrive
|
||||
- Forces 100% progress when status is 'completed'
|
||||
- Displays status badges (running/completed/failed/etc.)
|
||||
|
||||
### 4. Collapsible Tree
|
||||
|
||||
- Users can expand/collapse operation groups
|
||||
- Expand/collapse state persists during re-renders
|
||||
- Click handlers on operation headers and expand buttons
|
||||
- Smooth visual transitions
|
||||
|
||||
### 5. Round Detection
|
||||
|
||||
- Tracks current workflow round in `dashboardLogTree.currentRound`
|
||||
- Clears dashboard when round changes (via `updateProgressFromMessage()`)
|
||||
- Prevents mixing data from different workflow rounds
|
||||
|
||||
### 6. Duplicate Prevention
|
||||
|
||||
- Uses Map with `logId` keys to prevent duplicate entries
|
||||
- Same log ID updates in place rather than creating duplicates
|
||||
- Ensures unique log entries even with same progress value
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Rate Limiting (429 Errors)
|
||||
|
||||
- Detected in `pollWorkflowData()` and `doPolling()`
|
||||
- Triggers exponential backoff with increased multiplier
|
||||
- Stops polling after 5 consecutive rate limit errors
|
||||
- Prevents API abuse
|
||||
|
||||
### Network Errors
|
||||
|
||||
- Logged but don't immediately stop polling
|
||||
- Allows retry on transient network issues
|
||||
- Controller handles backoff automatically
|
||||
- Polling continues for recoverable errors
|
||||
|
||||
### Rendering Errors
|
||||
|
||||
- Don't stop polling (UI issue, not data issue)
|
||||
- Logged for debugging
|
||||
- Polling continues to get workflow status updates
|
||||
- UI can recover on next successful render
|
||||
|
||||
### Workflow Validation
|
||||
|
||||
- `isWorkflowValid()` checks before each poll cycle
|
||||
- Validates workflow state exists and matches active workflow
|
||||
- Checks if polling is still enabled (`pollActive` flag)
|
||||
- Stops polling if workflow is invalid
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Polling Intervals
|
||||
|
||||
- Base interval: 5 seconds (balanced between responsiveness and server load)
|
||||
- Maximum interval: 10 seconds (prevents excessive backoff)
|
||||
- Exponential backoff: Prevents overwhelming server during errors
|
||||
|
||||
### Data Processing
|
||||
|
||||
- Processes items sequentially to maintain chronological order
|
||||
- Uses Maps for O(1) lookups when grouping operations
|
||||
- Incremental polling reduces data transfer
|
||||
- Timestamp-based filtering at API level
|
||||
|
||||
### Rendering Optimization
|
||||
|
||||
- Full re-render on each update (simplifies state management)
|
||||
- Event handlers re-attached after each render
|
||||
- HTML generation is efficient (string concatenation)
|
||||
- Minimal DOM manipulation (innerHTML replacement)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Starting Polling
|
||||
|
||||
```javascript
|
||||
import pollingController from './workflowPollingController.js';
|
||||
|
||||
// Start polling for a workflow
|
||||
pollingController.startPolling('workflow-123');
|
||||
```
|
||||
|
||||
### Stopping Polling
|
||||
|
||||
```javascript
|
||||
// Stop polling
|
||||
pollingController.stopPolling();
|
||||
```
|
||||
|
||||
### Processing Dashboard Logs
|
||||
|
||||
```javascript
|
||||
import { processDashboardLogs } from './workflowUiRendererDashboard.js';
|
||||
|
||||
// Process logs with operationId
|
||||
const logs = [
|
||||
{
|
||||
id: 'log-1',
|
||||
message: 'Processing file...',
|
||||
type: 'info',
|
||||
timestamp: 1234567890,
|
||||
status: 'running',
|
||||
progress: 0.5,
|
||||
operationId: 'op-123',
|
||||
parentId: null
|
||||
}
|
||||
];
|
||||
|
||||
processDashboardLogs(logs);
|
||||
```
|
||||
|
||||
### Clearing Dashboard
|
||||
|
||||
```javascript
|
||||
import { clearDashboard } from './workflowUiRendererDashboard.js';
|
||||
|
||||
// Clear dashboard (e.g., on workflow reset)
|
||||
clearDashboard(true); // true = reset round tracking
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `FRONTEND_ARCHITECTURE.md` - Overall frontend architecture
|
||||
- `workflowCoordination.js` - State management coordination
|
||||
- `workflowUiRenderer.js` - Unified content rendering
|
||||
|
||||
## Conclusion
|
||||
|
||||
The dashboard log polling and rendering system provides a robust, hierarchical display of workflow operations with real-time updates. The system efficiently handles incremental polling, sorts data chronologically, and renders a collapsible tree structure that scales to complex workflows with multiple nested operations.
|
||||
|
||||
|
|
@ -248,11 +248,62 @@ export async function fetchChatData(
|
|||
|
||||
console.log('📤 fetchChatData request:', requestConfig);
|
||||
|
||||
const data = await request<ChatDataResponse>(requestConfig);
|
||||
const data = await request<any>(requestConfig);
|
||||
|
||||
console.log('📥 fetchChatData response:', data);
|
||||
|
||||
// Ensure all arrays exist
|
||||
// Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] }
|
||||
if (data.items && Array.isArray(data.items)) {
|
||||
const messages: WorkflowMessage[] = [];
|
||||
const logs: WorkflowLog[] = [];
|
||||
const stats: WorkflowStats[] = [];
|
||||
const documents: WorkflowDocument[] = [];
|
||||
|
||||
data.items.forEach((item: any) => {
|
||||
if (item.type === 'message') {
|
||||
// Handle both formats: item.item or direct item data
|
||||
const messageData = item.item || item;
|
||||
if (messageData && (messageData.id || messageData.message)) {
|
||||
messages.push(messageData);
|
||||
} else {
|
||||
console.warn('⚠️ Invalid message item:', item);
|
||||
}
|
||||
} else if (item.type === 'log') {
|
||||
const logData = item.item || item;
|
||||
if (logData) {
|
||||
logs.push(logData);
|
||||
}
|
||||
} else if (item.type === 'stat') {
|
||||
const statData = item.item || item;
|
||||
if (statData) {
|
||||
stats.push(statData);
|
||||
}
|
||||
}
|
||||
// Documents might be in items or separate
|
||||
if (item.type === 'document') {
|
||||
const docData = item.item || item;
|
||||
if (docData) {
|
||||
documents.push(docData);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📦 Extracted from items:', {
|
||||
messages: messages.length,
|
||||
logs: logs.length,
|
||||
stats: stats.length,
|
||||
documents: documents.length
|
||||
});
|
||||
|
||||
return {
|
||||
messages,
|
||||
logs,
|
||||
stats,
|
||||
documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : [])
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to direct format: { messages: [], logs: [], stats: [] }
|
||||
return {
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
logs: Array.isArray(data.logs) ? data.logs : [],
|
||||
|
|
|
|||
|
|
@ -26,47 +26,112 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Round Group */
|
||||
.roundGroup {
|
||||
|
||||
/* Dashboard Tree Styles */
|
||||
.dashboardSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.roundHeader {
|
||||
|
||||
.dashboardContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardContainer > .operationNode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.operationNode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.operationNodeIndented {
|
||||
border-left: 2px solid var(--color-border, #e0e0e0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.operationNodeIndented::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--color-border, #e0e0e0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.operationRow {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start;
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.operationContent {
|
||||
flex: 1;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.operationHeader {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--object-radius-small);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.roundHeader.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.roundHeader.clickable:hover {
|
||||
background-color: var(--color-highlight-gray);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.roundHeaderLabel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
min-height: 32px;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-gray);
|
||||
transition: color 0.2s ease;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expandButton:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
.expandButtonSpacer {
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray);
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
|
@ -74,29 +139,233 @@
|
|||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.roundLogs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-left: 16px;
|
||||
.operationIcon {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary, #007bff);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-theme="dark"] .roundHeader {
|
||||
.operationIcon[data-type="success"] {
|
||||
background-color: var(--color-success, #28a745);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.operationIcon[data-type="error"] {
|
||||
background-color: var(--color-error, #dc3545);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.operationIcon[data-type="warning"] {
|
||||
background-color: var(--color-warning, #ffc107);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.operationIcon[data-type="info"] {
|
||||
background-color: var(--color-primary, #007bff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.operationName {
|
||||
flex: 1;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusMessageTag {
|
||||
font-size: 11px;
|
||||
color: var(--color-gray);
|
||||
font-weight: 400;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--color-highlight-gray);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.operationTimestamp {
|
||||
font-size: 11px;
|
||||
color: var(--color-gray);
|
||||
font-weight: 400;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background-color: var(--color-highlight-gray);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusBadge.statusCompleted {
|
||||
background-color: var(--color-success, #28a745);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.statusBadge.statusFailed {
|
||||
background-color: var(--color-error, #dc3545);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.statusBadge.statusRunning {
|
||||
background-color: var(--color-primary, #007bff);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progressPercentage {
|
||||
font-size: 11px;
|
||||
color: var(--color-gray);
|
||||
font-weight: 600;
|
||||
min-width: 45px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
height: 4px;
|
||||
background-color: var(--color-highlight-gray);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
background-color: var(--color-primary, #007bff);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progressBar.progressCompleted {
|
||||
background-color: var(--color-success, #28a745);
|
||||
}
|
||||
|
||||
.operationChildren {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Log messages container */
|
||||
.operationLogsContainer {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.operationLogsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-left: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--object-radius-small);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.logEntryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logTimestamp {
|
||||
color: var(--color-gray);
|
||||
font-weight: 500;
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logEntryMessage {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logProgress {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark theme support for log entries */
|
||||
[data-theme="dark"] .logEntry {
|
||||
background-color: var(--color-surface-dark);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .roundHeader.clickable:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .roundHeaderLabel {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .collapseIcon {
|
||||
[data-theme="dark"] .logTimestamp {
|
||||
color: var(--color-gray-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .logEntryMessage {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .operationLogsList {
|
||||
border-left-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
/* Dark theme support for dashboard */
|
||||
[data-theme="dark"] .operationHeader {
|
||||
background-color: var(--color-surface-dark);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .operationName {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .statusBadge {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .progressPercentage {
|
||||
color: var(--color-gray-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .progressBarContainer {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .operationNode[data-depth] {
|
||||
border-left-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,115 +1,248 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { LogProps, RoundGroup } from './LogTypes';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import React from 'react';
|
||||
import { LogProps, WorkflowLog } from './LogTypes';
|
||||
import { AutoScroll } from '../AutoScroll';
|
||||
import { LogMessage } from './LogMessage/LogMessage';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from './Log.module.css';
|
||||
|
||||
// Helper function to group logs by round
|
||||
const groupLogsByRound = (logs: any[]): RoundGroup[] => {
|
||||
const roundMap = new Map<number, RoundGroup>();
|
||||
let currentRound = 1; // Track current round
|
||||
|
||||
// Sort logs chronologically first
|
||||
const sortedLogs = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
sortedLogs.forEach((log) => {
|
||||
const message = (log.message || '').toLowerCase();
|
||||
|
||||
// Check if this is a workflow status message that indicates a round change
|
||||
if (message.includes('workflow started') || message.includes('workflow resumed')) {
|
||||
const roundMatch = message.match(/\(?round\s+(\d+)\)?/i);
|
||||
if (roundMatch) {
|
||||
currentRound = parseInt(roundMatch[1], 10);
|
||||
} else if (message.includes('workflow started')) {
|
||||
// If started without round number, assume round 1
|
||||
currentRound = 1;
|
||||
// Helper to get status badge class
|
||||
const getStatusBadgeClass = (status?: string | null): string => {
|
||||
if (!status) return styles.statusBadge;
|
||||
switch (status.toLowerCase()) {
|
||||
case 'completed':
|
||||
return `${styles.statusBadge} ${styles.statusCompleted}`;
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return `${styles.statusBadge} ${styles.statusFailed}`;
|
||||
case 'running':
|
||||
return `${styles.statusBadge} ${styles.statusRunning}`;
|
||||
default:
|
||||
return styles.statusBadge;
|
||||
}
|
||||
// If resumed without round number, keep current round
|
||||
}
|
||||
|
||||
// Assign log to current round
|
||||
const roundNumber = currentRound;
|
||||
|
||||
if (!roundMap.has(roundNumber)) {
|
||||
roundMap.set(roundNumber, {
|
||||
round: roundNumber,
|
||||
logs: [],
|
||||
latestProgress: undefined,
|
||||
latestTimestamp: 0
|
||||
});
|
||||
}
|
||||
|
||||
const roundGroup = roundMap.get(roundNumber)!;
|
||||
roundGroup.logs.push(log);
|
||||
|
||||
// Update latest progress and timestamp
|
||||
if (log.progress !== undefined && log.progress !== null) {
|
||||
if (roundGroup.latestProgress === undefined || log.progress > roundGroup.latestProgress) {
|
||||
roundGroup.latestProgress = log.progress;
|
||||
}
|
||||
}
|
||||
|
||||
if ((log.timestamp || 0) > roundGroup.latestTimestamp) {
|
||||
roundGroup.latestTimestamp = log.timestamp || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort rounds and logs within each round
|
||||
return Array.from(roundMap.values())
|
||||
.sort((a, b) => a.round - b.round)
|
||||
.map(roundGroup => ({
|
||||
...roundGroup,
|
||||
logs: roundGroup.logs.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0))
|
||||
}));
|
||||
};
|
||||
|
||||
const Log: React.FC<LogProps> = ({
|
||||
className = '',
|
||||
emptyMessage = 'No log information available',
|
||||
logs = []
|
||||
dashboardTree,
|
||||
onToggleOperationExpanded,
|
||||
getChildOperations
|
||||
}) => {
|
||||
// Group logs by round
|
||||
const roundGroups = useMemo(() => groupLogsByRound(logs), [logs]);
|
||||
|
||||
// Get the latest round number
|
||||
const latestRound = roundGroups.length > 0 ? roundGroups[roundGroups.length - 1].round : null;
|
||||
|
||||
// State to track collapsed rounds (round number -> isCollapsed)
|
||||
const [collapsedRounds, setCollapsedRounds] = useState<Set<number>>(new Set());
|
||||
|
||||
// Initialize collapsed state: collapse all rounds except the latest one
|
||||
useEffect(() => {
|
||||
if (roundGroups.length > 0 && latestRound !== null) {
|
||||
setCollapsedRounds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
// Ensure latest round is not collapsed
|
||||
newSet.delete(latestRound);
|
||||
// Collapse all other rounds that aren't already in the set
|
||||
roundGroups.forEach(rg => {
|
||||
if (rg.round !== latestRound && !newSet.has(rg.round)) {
|
||||
newSet.add(rg.round);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
const formatLogTimestamp = (timestamp: number): string => {
|
||||
try {
|
||||
const formatted = formatUnixTimestamp(timestamp, undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
return formatted.time;
|
||||
} catch {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
}
|
||||
};
|
||||
// Render operation node recursively
|
||||
const renderOperationNode = (operationId: string, depth: number = 0): React.ReactNode => {
|
||||
if (!dashboardTree || !getChildOperations) {
|
||||
return null;
|
||||
}
|
||||
}, [roundGroups.length, latestRound]); // Only update when rounds change, not on every log update
|
||||
|
||||
// Toggle collapse state for a round
|
||||
const toggleRoundCollapse = (round: number) => {
|
||||
setCollapsedRounds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(round)) {
|
||||
newSet.delete(round);
|
||||
} else {
|
||||
newSet.add(round);
|
||||
const operation = dashboardTree.operations.get(operationId);
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
return newSet;
|
||||
|
||||
// Get logs for this operation, sorted by timestamp
|
||||
const logsArray = Array.from(operation.logs.values()).sort((a, b) => {
|
||||
const tsA = a.timestamp || 0;
|
||||
const tsB = b.timestamp || 0;
|
||||
return tsA - tsB; // Ascending order (oldest first)
|
||||
});
|
||||
|
||||
// Get latest log for timestamp
|
||||
const latestLog = logsArray.length > 0 ? logsArray[logsArray.length - 1] : null;
|
||||
|
||||
// Skip rendering if no logs yet
|
||||
if (logsArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get child operations
|
||||
const childOperations = getChildOperations(operationId);
|
||||
const hasChildren = childOperations.length > 0;
|
||||
const hasLogs = logsArray.length > 0;
|
||||
const hasContentToExpand = hasChildren || hasLogs;
|
||||
|
||||
// Calculate progress percentage
|
||||
let progressPercentage = 0;
|
||||
if (operation.latestProgress !== null && operation.latestProgress !== undefined) {
|
||||
progressPercentage = Math.min(Math.max(operation.latestProgress * 100, 0), 100);
|
||||
}
|
||||
|
||||
// Force 100% progress when status is 'completed'
|
||||
if (operation.latestStatus === 'completed') {
|
||||
progressPercentage = 100;
|
||||
}
|
||||
|
||||
// Use stable operation name (from first log) or fallback to operationId
|
||||
const operationName = operation.operationName || `Operation ${operationId}`;
|
||||
// Use latest message as status tag (updates with each poll)
|
||||
const latestMessage = operation.latestMessage || '';
|
||||
const operationStatus = operation.latestStatus || 'running';
|
||||
const operationTimestamp = latestLog?.timestamp;
|
||||
|
||||
// Calculate consistent indentation per level (24px per level)
|
||||
const indentPx = depth * 24;
|
||||
const hasIndent = depth > 0;
|
||||
|
||||
// Calculate log entry indentation to align with operation name
|
||||
// Operation name starts at: header padding-left (12px) + button/spacer (20px) + gap (8px) = 40px from operationContent
|
||||
// operationLogsList has margin-left: 12px (for border), so log entries are at: container marginLeft + 12px
|
||||
// We want log entries at 40px from operationContent, so: container marginLeft + 12px = 40px
|
||||
// Therefore: container marginLeft = 28px from operationContent
|
||||
// But operationNode has paddingLeft: 12px for indented nodes, 0 for root
|
||||
// So from operationNode: container marginLeft = 28px - operationNode.paddingLeft
|
||||
// Root: 28px - 0 = 28px
|
||||
// Indented: 28px - 12px = 16px
|
||||
const logIndentPx = hasIndent ? 16 : 28;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={operationId}
|
||||
className={`${styles.operationNode} ${hasIndent ? styles.operationNodeIndented : ''}`}
|
||||
style={{
|
||||
marginLeft: hasIndent ? `${indentPx}px` : '0',
|
||||
paddingLeft: hasIndent ? '12px' : '0',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div className={styles.operationRow}>
|
||||
{/* Operation content */}
|
||||
<div className={styles.operationContent}>
|
||||
<div className={styles.operationHeader}>
|
||||
{hasContentToExpand && (
|
||||
<button
|
||||
className={styles.expandButton}
|
||||
onClick={() => onToggleOperationExpanded?.(operationId)}
|
||||
aria-label={operation.expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<span className={`${styles.collapseIcon} ${operation.expanded ? '' : styles.collapsed}`}>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!hasContentToExpand && <span className={styles.expandButtonSpacer} />}
|
||||
|
||||
<span className={styles.operationName}>{operationName}</span>
|
||||
|
||||
{/* Latest status message tag (updates with each poll) */}
|
||||
{latestMessage && (
|
||||
<span className={styles.statusMessageTag}>
|
||||
{latestMessage}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{operationTimestamp && (
|
||||
<span className={styles.operationTimestamp}>
|
||||
{formatLogTimestamp(operationTimestamp)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={getStatusBadgeClass(operationStatus)}>
|
||||
{operationStatus}
|
||||
</span>
|
||||
|
||||
{progressPercentage > 0 && (
|
||||
<span className={styles.progressPercentage}>
|
||||
{Math.round(progressPercentage)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progressPercentage > 0 && (
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div
|
||||
className={`${styles.progressBar} ${progressPercentage >= 100 ? styles.progressCompleted : ''}`}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show logs and children when expanded */}
|
||||
{operation.expanded && (
|
||||
<>
|
||||
{/* Log messages for this operation - show only latest log */}
|
||||
{latestLog && (
|
||||
<div
|
||||
className={styles.operationLogsContainer}
|
||||
style={{
|
||||
marginLeft: `${logIndentPx}px` // Align with operation name consistently at all levels
|
||||
}}
|
||||
>
|
||||
<div className={styles.operationLogsList}>
|
||||
<div key={`log-${operationId}-latest`} className={styles.logEntry}>
|
||||
<div className={styles.logEntryHeader}>
|
||||
<span className={styles.logTimestamp}>
|
||||
{formatLogTimestamp(latestLog.timestamp)}
|
||||
</span>
|
||||
<span className={styles.logEntryMessage}>
|
||||
{latestLog.message}
|
||||
</span>
|
||||
{latestLog.status && (
|
||||
<span className={getStatusBadgeClass(latestLog.status)}>
|
||||
{latestLog.status}
|
||||
</span>
|
||||
)}
|
||||
{latestLog.progress !== undefined && latestLog.progress !== null && (
|
||||
<span className={styles.logProgress}>
|
||||
{Math.round(latestLog.progress * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Child operations */}
|
||||
{hasChildren && (
|
||||
<div className={styles.operationChildren}>
|
||||
{childOperations.map((childOpId) => renderOperationNode(childOpId, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (logs.length === 0) {
|
||||
// Render dashboard tree
|
||||
const renderDashboard = (): React.ReactNode => {
|
||||
if (!dashboardTree || !getChildOperations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dashboardTree.rootOperations.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyState}>{emptyMessage}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
{dashboardTree.rootOperations.map((rootOpId) => renderOperationNode(rootOpId, 0))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Check if we have dashboard logs to display
|
||||
const hasDashboardLogs = dashboardTree && dashboardTree.rootOperations.length > 0;
|
||||
|
||||
if (!hasDashboardLogs) {
|
||||
return (
|
||||
<div className={`${styles.logContainer} ${className}`}>
|
||||
<div className={styles.emptyState}>{emptyMessage}</div>
|
||||
|
|
@ -119,70 +252,15 @@ const Log: React.FC<LogProps> = ({
|
|||
|
||||
return (
|
||||
<div className={`${styles.logContainer} ${className}`}>
|
||||
{/* Scrollable Content Section - All Rounds in Chronological Order */}
|
||||
<AutoScroll
|
||||
scrollDependency={logs.length}
|
||||
>
|
||||
<AutoScroll scrollDependency={dashboardTree.rootOperations.length}>
|
||||
<div className={styles.scrollableContent}>
|
||||
{/* All Round Groups - In Chronological Order (Oldest First, Latest Last) */}
|
||||
{roundGroups.map((roundGroup) => {
|
||||
const isCollapsed = collapsedRounds.has(roundGroup.round);
|
||||
|
||||
return (
|
||||
<div key={`round-${roundGroup.round}`} className={styles.roundGroup}>
|
||||
{/* Round Header - Clickable */}
|
||||
{roundGroup.logs.length > 0 && (
|
||||
<div
|
||||
className={`${styles.roundHeader} ${styles.clickable}`}
|
||||
onClick={() => toggleRoundCollapse(roundGroup.round)}
|
||||
>
|
||||
<div className={styles.roundHeaderLabel}>
|
||||
<span>Round {roundGroup.round} Logs</span>
|
||||
<span className={`${styles.collapseIcon} ${isCollapsed ? styles.collapsed : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
<div className={styles.dashboardSection}>
|
||||
{renderDashboard()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Messages for this Round - Collapsible */}
|
||||
{!isCollapsed && (
|
||||
<div className={styles.roundLogs}>
|
||||
{roundGroup.logs.map((log, index) => {
|
||||
// Convert log to Message format for LogMessage component
|
||||
const message = {
|
||||
id: log.id || `log-${index}`,
|
||||
workflowId: log.workflowId || '',
|
||||
message: log.message || '',
|
||||
status: log.status,
|
||||
timestamp: log.timestamp,
|
||||
publishedAt: log.timestamp,
|
||||
sequenceNr: index,
|
||||
role: 'system',
|
||||
documents: undefined,
|
||||
summary: undefined
|
||||
};
|
||||
|
||||
return (
|
||||
<LogMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
showDocuments={false}
|
||||
showMetadata={true}
|
||||
showProgress={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AutoScroll>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Log;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,21 @@ export interface WorkflowLog {
|
|||
}
|
||||
|
||||
/**
|
||||
* Round group containing logs and progress
|
||||
* Dashboard log tree structure
|
||||
*/
|
||||
export interface RoundGroup {
|
||||
round: number;
|
||||
logs: WorkflowLog[];
|
||||
latestProgress: number | undefined;
|
||||
latestTimestamp: number;
|
||||
export interface DashboardLogTree {
|
||||
operations: Map<string, {
|
||||
logs: Map<string, WorkflowLog>;
|
||||
parentId: string | null;
|
||||
expanded: boolean;
|
||||
latestProgress: number | null;
|
||||
latestStatus: string | null;
|
||||
operationName: string | null;
|
||||
latestMessage: string | null;
|
||||
}>;
|
||||
rootOperations: string[];
|
||||
logExpandedStates: Map<string, boolean>;
|
||||
currentRound: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,8 +50,18 @@ export interface LogProps {
|
|||
emptyMessage?: string;
|
||||
|
||||
/**
|
||||
* Array of log entries to display
|
||||
* Dashboard log tree (logs with operationId)
|
||||
*/
|
||||
logs?: WorkflowLog[];
|
||||
dashboardTree?: DashboardLogTree;
|
||||
|
||||
/**
|
||||
* Callback to toggle operation expanded state
|
||||
*/
|
||||
onToggleOperationExpanded?: (operationId: string) => void;
|
||||
|
||||
/**
|
||||
* Function to get child operations for a parent
|
||||
*/
|
||||
getChildOperations?: (parentId: string | null) => string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1393,12 +1393,16 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
|
||||
case 'log': {
|
||||
const logConfig = content.logConfig || {};
|
||||
const logEntries = Array.isArray(hookData?.logs) ? hookData.logs : [];
|
||||
const dashboardTree = hookData?.dashboardTree;
|
||||
const onToggleOperationExpanded = hookData?.onToggleOperationExpanded;
|
||||
const getChildOperations = hookData?.getChildOperations;
|
||||
return (
|
||||
<div key={content.id} className={styles.logSection}>
|
||||
<Log
|
||||
emptyMessage={logConfig.emptyMessage ? resolveLanguageText(logConfig.emptyMessage, t) : undefined}
|
||||
logs={logEntries}
|
||||
dashboardTree={dashboardTree}
|
||||
onToggleOperationExpanded={onToggleOperationExpanded}
|
||||
getChildOperations={getChildOperations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { deleteFileFromMessageApi } from '../../api/workflowApi';
|
|||
import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
|
||||
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
|
||||
import { useWorkflows } from './useWorkflows';
|
||||
import { useDashboardLogTree } from './useDashboardLogTree';
|
||||
import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
||||
|
||||
export interface WorkflowFile {
|
||||
|
|
@ -45,6 +46,8 @@ export function useDashboardInputForm() {
|
|||
startingWorkflow,
|
||||
messages,
|
||||
logs,
|
||||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
startWorkflow,
|
||||
stopWorkflow,
|
||||
resetWorkflow,
|
||||
|
|
@ -52,6 +55,16 @@ export function useDashboardInputForm() {
|
|||
setWorkflowStatusOptimistic
|
||||
} = useWorkflowLifecycle();
|
||||
|
||||
// Dashboard log tree hook
|
||||
const {
|
||||
tree: dashboardTree,
|
||||
processDashboardLogs,
|
||||
clearDashboard,
|
||||
toggleOperationExpanded,
|
||||
updateCurrentRound,
|
||||
getChildOperations
|
||||
} = useDashboardLogTree();
|
||||
|
||||
// Ref to prevent infinite sync loops
|
||||
const isSyncingRef = useRef(false);
|
||||
|
||||
|
|
@ -135,6 +148,60 @@ export function useDashboardInputForm() {
|
|||
|
||||
const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows();
|
||||
|
||||
// Track processed log IDs to avoid reprocessing
|
||||
const processedLogIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastWorkflowIdRef = useRef<string | null>(null);
|
||||
const lastDashboardLogsLengthRef = useRef<number>(0);
|
||||
|
||||
// Clear processed logs when workflow changes
|
||||
useEffect(() => {
|
||||
if (workflowId !== lastWorkflowIdRef.current) {
|
||||
processedLogIdsRef.current.clear();
|
||||
lastWorkflowIdRef.current = workflowId || null;
|
||||
lastDashboardLogsLengthRef.current = 0;
|
||||
if (!workflowId) {
|
||||
clearDashboard(true);
|
||||
}
|
||||
}
|
||||
}, [workflowId, clearDashboard]);
|
||||
|
||||
// Process dashboard logs when they change (only new logs)
|
||||
useEffect(() => {
|
||||
if (!dashboardLogs || dashboardLogs.length === 0) {
|
||||
lastDashboardLogsLengthRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if the array length changed (indicating new logs)
|
||||
if (dashboardLogs.length === lastDashboardLogsLengthRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to only new logs that haven't been processed
|
||||
const newLogs = dashboardLogs.filter(log => {
|
||||
const logId = log.id || `${log.operationId}-${log.timestamp}`;
|
||||
if (processedLogIdsRef.current.has(logId)) {
|
||||
return false;
|
||||
}
|
||||
processedLogIdsRef.current.add(logId);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Only process if there are new logs
|
||||
if (newLogs.length > 0) {
|
||||
processDashboardLogs(newLogs);
|
||||
}
|
||||
|
||||
lastDashboardLogsLengthRef.current = dashboardLogs.length;
|
||||
}, [dashboardLogs, processDashboardLogs]);
|
||||
|
||||
// Update current round in dashboard tree when it changes
|
||||
useEffect(() => {
|
||||
if (currentRound !== undefined) {
|
||||
updateCurrentRound(currentRound);
|
||||
}
|
||||
}, [currentRound, updateCurrentRound]);
|
||||
|
||||
const workflowFiles = useMemo(() => {
|
||||
const fileMap = new Map<string, WorkflowFile>();
|
||||
const pendingFileIds = new Set(pendingFiles.map(f => f.fileId));
|
||||
|
|
@ -657,7 +724,10 @@ export function useDashboardInputForm() {
|
|||
currentRound,
|
||||
isRunning,
|
||||
messages: displayMessages || [],
|
||||
logs: logs || [],
|
||||
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
|
||||
dashboardTree, // Dashboard log tree (logs with operationId)
|
||||
onToggleOperationExpanded: toggleOperationExpanded,
|
||||
getChildOperations,
|
||||
workflowItems,
|
||||
selectedWorkflowId: workflowId || selectedWorkflowId || null,
|
||||
onWorkflowSelect: handleWorkflowSelect,
|
||||
|
|
|
|||
197
src/hooks/playground/useDashboardLogTree.ts
Normal file
197
src/hooks/playground/useDashboardLogTree.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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);
|
||||
const isNewOperation = !existingOperation;
|
||||
|
||||
// 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) {
|
||||
// Format operationId by splitting on dashes/underscores and capitalizing
|
||||
// This creates a stable, readable name like "Workflow Planning" from "workflow-planning"
|
||||
const formattedName = operationId
|
||||
.split(/[-_]/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
operationName = formattedName || operationId;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
newTree.rootOperations = Array.from(rootOpsSet).sort(); // Alphabetical sort
|
||||
|
||||
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;
|
||||
return Array.from(currentTree.operations.entries())
|
||||
.filter(([_, op]) => op.parentId === parentId)
|
||||
.map(([opId]) => opId)
|
||||
.sort(); // Alphabetical sort
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tree,
|
||||
processDashboardLogs,
|
||||
clearDashboard,
|
||||
toggleOperationExpanded,
|
||||
updateCurrentRound,
|
||||
getChildOperations
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,14 +1,22 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useApiRequest } from '../useApi';
|
||||
import {
|
||||
type Workflow,
|
||||
type WorkflowMessage,
|
||||
type WorkflowLog,
|
||||
type StartWorkflowRequest,
|
||||
fetchWorkflow as fetchWorkflowApi
|
||||
fetchWorkflow as fetchWorkflowApi,
|
||||
fetchChatData
|
||||
} from '../../api/workflowApi';
|
||||
import { useWorkflowOperations } from './useWorkflowOperations';
|
||||
import { sortMessages, sortLogs } from './playgroundUtils';
|
||||
import { useWorkflowPolling } from './useWorkflowPolling';
|
||||
|
||||
interface UnifiedChatDataItem {
|
||||
type: 'message' | 'log' | 'stat';
|
||||
item: WorkflowMessage | WorkflowLog | any;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function useWorkflowLifecycle() {
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
|
|
@ -16,12 +24,20 @@ export function useWorkflowLifecycle() {
|
|||
const [currentRound, setCurrentRound] = useState<number | undefined>(undefined);
|
||||
const [messages, setMessages] = useState<WorkflowMessage[]>([]);
|
||||
const [logs, setLogs] = useState<WorkflowLog[]>([]);
|
||||
const [dashboardLogs, setDashboardLogs] = useState<WorkflowLog[]>([]);
|
||||
const [unifiedContentLogs, setUnifiedContentLogs] = useState<WorkflowLog[]>([]);
|
||||
const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState<number | null>(null);
|
||||
const prevStatusRef = useRef<string>('idle');
|
||||
const statusRef = useRef<string>('idle');
|
||||
const statusChangedFromRunningAtRef = useRef<number | null>(null);
|
||||
const lastRenderedTimestampRef = useRef<number | null>(null);
|
||||
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
|
||||
const { request } = useApiRequest();
|
||||
const pollingController = useWorkflowPolling();
|
||||
|
||||
// Store polling controller methods in refs to avoid dependency issues
|
||||
const pollingControllerRef = useRef(pollingController);
|
||||
pollingControllerRef.current = pollingController;
|
||||
|
||||
// Helper to update status and track transitions
|
||||
const updateWorkflowStatus = useCallback((newStatus: string) => {
|
||||
|
|
@ -46,6 +62,281 @@ export function useWorkflowLifecycle() {
|
|||
updateWorkflowStatus(status);
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
// Convert backend log format to frontend format
|
||||
const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => {
|
||||
return {
|
||||
id: log.id,
|
||||
workflowId: log.workflowId || workflowId || '',
|
||||
message: log.message || '',
|
||||
type: log.type || 'info',
|
||||
timestamp: log.timestamp || log.createdAt || Date.now(),
|
||||
status: log.status || 'running',
|
||||
progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined,
|
||||
performance: log.performance,
|
||||
operationId: log.operationId || null,
|
||||
parentId: log.parentId || null
|
||||
};
|
||||
}, [workflowId]);
|
||||
|
||||
// Process unified chat data chronologically
|
||||
const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => {
|
||||
console.log('🔄 Processing unified chat data:', {
|
||||
messages: chatData.messages?.length || 0,
|
||||
logs: chatData.logs?.length || 0,
|
||||
stats: chatData.stats?.length || 0
|
||||
});
|
||||
|
||||
// Build unified timeline of all items
|
||||
const timeline: UnifiedChatDataItem[] = [];
|
||||
|
||||
// Add messages
|
||||
(chatData.messages || []).forEach((message: WorkflowMessage) => {
|
||||
timeline.push({
|
||||
type: 'message',
|
||||
item: message,
|
||||
createdAt: message.publishedAt || message.timestamp || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Add logs
|
||||
(chatData.logs || []).forEach((log: any) => {
|
||||
timeline.push({
|
||||
type: 'log',
|
||||
item: log,
|
||||
createdAt: log.timestamp || log.createdAt || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Add stats (if needed)
|
||||
(chatData.stats || []).forEach((stat: any) => {
|
||||
timeline.push({
|
||||
type: 'stat',
|
||||
item: stat,
|
||||
createdAt: stat.timestamp || stat.createdAt || Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📋 Timeline created with', timeline.length, 'items');
|
||||
|
||||
// Sort chronologically
|
||||
timeline.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
// Process items sequentially to maintain chronological order
|
||||
// Update lastRenderedTimestamp after processing all items (use latest timestamp)
|
||||
if (timeline.length > 0) {
|
||||
const latestTimestamp = timeline[timeline.length - 1].createdAt;
|
||||
lastRenderedTimestampRef.current = latestTimestamp;
|
||||
}
|
||||
|
||||
// Use functional updates to avoid dependency on current state
|
||||
setMessages(prevMessages => {
|
||||
const newMessages: WorkflowMessage[] = [...prevMessages];
|
||||
let hasChanges = false;
|
||||
let messagesAdded = 0;
|
||||
let messagesUpdated = 0;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'message') {
|
||||
const message = item.item as WorkflowMessage;
|
||||
|
||||
if (!message || !message.id) {
|
||||
console.warn('⚠️ Invalid message in timeline:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message already exists
|
||||
const existingIndex = newMessages.findIndex(m => m.id === message.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Always update existing message (don't compare, just update)
|
||||
newMessages[existingIndex] = message;
|
||||
hasChanges = true;
|
||||
messagesUpdated++;
|
||||
} else {
|
||||
newMessages.push(message);
|
||||
hasChanges = true;
|
||||
messagesAdded++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`);
|
||||
if (messagesAdded > 0 || messagesUpdated > 0) {
|
||||
console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) })));
|
||||
}
|
||||
|
||||
// Always return sorted array if we processed any messages
|
||||
if (hasChanges || timeline.some(item => item.type === 'message')) {
|
||||
const sorted = [...newMessages].sort(sortMessages);
|
||||
console.log(`✅ Returning ${sorted.length} sorted messages`);
|
||||
return sorted;
|
||||
}
|
||||
|
||||
console.log('⚠️ No changes detected, returning previous messages');
|
||||
return prevMessages;
|
||||
});
|
||||
|
||||
setDashboardLogs(prevDashboardLogs => {
|
||||
const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
|
||||
// Route logs based on operationId
|
||||
if (frontendLog.operationId) {
|
||||
// Logs WITH operationId → Dashboard
|
||||
const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Check if log actually changed
|
||||
const existingLog = newDashboardLogs[existingIndex];
|
||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
||||
newDashboardLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newDashboardLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only return new array if there are changes
|
||||
if (!hasChanges) {
|
||||
return prevDashboardLogs;
|
||||
}
|
||||
|
||||
return [...newDashboardLogs].sort(sortLogs);
|
||||
});
|
||||
|
||||
setUnifiedContentLogs(prevUnifiedContentLogs => {
|
||||
const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs];
|
||||
let hasChanges = false;
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
|
||||
// Route logs based on operationId
|
||||
if (!frontendLog.operationId) {
|
||||
// Logs WITHOUT operationId → Unified Content Area
|
||||
const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Check if log actually changed
|
||||
const existingLog = newUnifiedContentLogs[existingIndex];
|
||||
if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) {
|
||||
newUnifiedContentLogs[existingIndex] = frontendLog;
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
newUnifiedContentLogs.push(frontendLog);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only return new array if there are changes
|
||||
if (!hasChanges) {
|
||||
return prevUnifiedContentLogs;
|
||||
}
|
||||
|
||||
return [...newUnifiedContentLogs].sort(sortLogs);
|
||||
});
|
||||
|
||||
// Update combined logs for backward compatibility (using functional update)
|
||||
setLogs(prevLogs => {
|
||||
const allLogs: WorkflowLog[] = [...prevLogs];
|
||||
|
||||
timeline.forEach((item) => {
|
||||
if (item.type === 'log') {
|
||||
const backendLog = item.item as any;
|
||||
const frontendLog = convertLogToFrontendFormat(backendLog);
|
||||
const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id);
|
||||
if (existingIndex >= 0) {
|
||||
allLogs[existingIndex] = frontendLog;
|
||||
} else {
|
||||
allLogs.push(frontendLog);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [...allLogs].sort(sortLogs);
|
||||
});
|
||||
}, [convertLogToFrontendFormat]);
|
||||
|
||||
// Poll workflow data using unified chat data endpoint
|
||||
const pollWorkflowData = useCallback(async (id: string) => {
|
||||
try {
|
||||
// Determine afterTimestamp for incremental polling
|
||||
const afterTimestamp = lastRenderedTimestampRef.current || undefined;
|
||||
|
||||
// Fetch workflow status
|
||||
const workflowData = await fetchWorkflowApi(request, id).catch(() => null);
|
||||
|
||||
if (workflowData) {
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
}
|
||||
|
||||
// Fetch unified chat data
|
||||
const chatData = await fetchChatData(request, id, afterTimestamp);
|
||||
|
||||
console.log('📊 Processed chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0,
|
||||
statsCount: chatData.stats?.length || 0,
|
||||
afterTimestamp: afterTimestamp
|
||||
});
|
||||
|
||||
// If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly
|
||||
// Reset timestamp to null so next poll fetches all items (but only if we have existing data)
|
||||
const hasNoNewData = (chatData.messages?.length || 0) === 0 &&
|
||||
(chatData.logs?.length || 0) === 0 &&
|
||||
(chatData.stats?.length || 0) === 0;
|
||||
|
||||
// Only reset if we're using afterTimestamp and got empty results
|
||||
// This handles cases where backend filtering might miss items due to timestamp precision issues
|
||||
if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) {
|
||||
console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll');
|
||||
// Don't reset immediately - let this poll complete, next poll will fetch all
|
||||
lastRenderedTimestampRef.current = null;
|
||||
}
|
||||
|
||||
// Process unified chat data
|
||||
processUnifiedChatData(chatData);
|
||||
|
||||
// Determine if polling should continue
|
||||
const currentStatus = statusRef.current;
|
||||
const changedAt = statusChangedFromRunningAtRef.current;
|
||||
|
||||
// Stop polling immediately for failed or stopped workflows
|
||||
// For completed workflows, allow grace period (handled by useEffect)
|
||||
if (currentStatus === 'failed' || currentStatus === 'stopped') {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling for 'running' status
|
||||
// For 'completed' status, continue if within grace period (handled by useEffect)
|
||||
// Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped
|
||||
} catch (error: any) {
|
||||
// Handle rate limiting (429 errors)
|
||||
if (error?.status === 429 || error?.response?.status === 429) {
|
||||
throw error; // Let polling controller handle rate limit backoff
|
||||
}
|
||||
console.error('Error polling workflow data:', error);
|
||||
// Don't throw for other errors - allow polling to continue with backoff
|
||||
}
|
||||
}, [request, updateWorkflowStatus, processUnifiedChatData]);
|
||||
|
||||
// Load initial workflow data (non-polling)
|
||||
const loadWorkflowData = useCallback(async (id: string) => {
|
||||
try {
|
||||
const workflowData = await fetchWorkflowApi(request, id).catch(() => null);
|
||||
|
|
@ -53,73 +344,107 @@ export function useWorkflowLifecycle() {
|
|||
if (!workflowData) {
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : [];
|
||||
const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : [];
|
||||
const status = workflowData.status || 'idle';
|
||||
const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined;
|
||||
|
||||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
|
||||
// Always fetch unified chat data to get all messages and logs
|
||||
// Reset lastRenderedTimestamp to fetch all historical data
|
||||
lastRenderedTimestampRef.current = null;
|
||||
try {
|
||||
const chatData = await fetchChatData(request, id, undefined);
|
||||
console.log('📥 loadWorkflowData: Fetched unified chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0
|
||||
});
|
||||
processUnifiedChatData(chatData);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
|
||||
// Fallback to workflowData if unified chat data fails
|
||||
if (messagesData.length > 0) {
|
||||
setMessages([...messagesData].sort(sortMessages));
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
if (logsData.length > 0) {
|
||||
setLogs([...logsData].sort(sortLogs));
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
// Process logs and separate by operationId
|
||||
const dashboardLogsList: WorkflowLog[] = [];
|
||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
||||
|
||||
// Update status and track transitions
|
||||
updateWorkflowStatus(status);
|
||||
logsData.forEach((log: any) => {
|
||||
const frontendLog = convertLogToFrontendFormat(log);
|
||||
if (frontendLog.operationId) {
|
||||
dashboardLogsList.push(frontendLog);
|
||||
} else {
|
||||
unifiedContentLogsList.push(frontendLog);
|
||||
}
|
||||
});
|
||||
|
||||
setDashboardLogs(dashboardLogsList.sort(sortLogs));
|
||||
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
|
||||
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow data:', error);
|
||||
}
|
||||
}, [request, updateWorkflowStatus]);
|
||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
||||
|
||||
// Set up polling when workflow is running
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setCurrentRound(undefined);
|
||||
// Only clear state if not already cleared to avoid unnecessary updates
|
||||
setMessages(prev => prev.length > 0 ? [] : prev);
|
||||
setLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
|
||||
setCurrentRound(prev => prev !== undefined ? undefined : prev);
|
||||
if (statusChangedFromRunningAt !== null) {
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}
|
||||
lastRenderedTimestampRef.current = null;
|
||||
pollingControllerRef.current.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling if:
|
||||
// 1. Workflow is currently running, OR
|
||||
// 2. Workflow just stopped running (within last 5 seconds) - grace period to catch final messages
|
||||
// 2. Workflow just completed (within last 10 seconds) - grace period to catch final messages
|
||||
// Stop polling for failed or stopped workflows immediately
|
||||
// Use ref for statusChangedFromRunningAt to get latest value (state updates are async)
|
||||
const changedAtRef = statusChangedFromRunningAtRef.current;
|
||||
const shouldPoll = workflowStatus === 'running' ||
|
||||
(statusChangedFromRunningAt !== null && Date.now() - statusChangedFromRunningAt < 5000);
|
||||
(workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000);
|
||||
|
||||
if (shouldPoll) {
|
||||
// Load immediately when status becomes running or when in grace period
|
||||
loadWorkflowData(workflowId);
|
||||
|
||||
// Poll more frequently for smoother updates (every 1 second instead of 2)
|
||||
const intervalId = setInterval(() => {
|
||||
// Check grace period on each poll using refs to get latest values
|
||||
const currentStatus = statusRef.current;
|
||||
const changedAt = statusChangedFromRunningAtRef.current;
|
||||
const stillInGracePeriod = currentStatus === 'running' ||
|
||||
(changedAt !== null && Date.now() - changedAt < 5000);
|
||||
|
||||
if (stillInGracePeriod) {
|
||||
loadWorkflowData(workflowId);
|
||||
// Reset lastRenderedTimestamp for first poll (fetch all historical data)
|
||||
if (lastRenderedTimestampRef.current === null) {
|
||||
lastRenderedTimestampRef.current = null; // null means fetch all
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
// Start polling
|
||||
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
|
||||
} else {
|
||||
// Clear the status change timestamp when we stop polling
|
||||
// Stop polling for failed, stopped, or completed (after grace period) workflows
|
||||
pollingControllerRef.current.stopPolling();
|
||||
// Clear the status change timestamp when we stop polling (only if not already null)
|
||||
if (statusChangedFromRunningAt !== null) {
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
}
|
||||
}, [workflowStatus, workflowId, loadWorkflowData, statusChangedFromRunningAt]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
pollingControllerRef.current.stopPolling();
|
||||
};
|
||||
}, [workflowStatus, workflowId, pollWorkflowData]);
|
||||
|
||||
const handleStartWorkflow = useCallback(async (
|
||||
workflowData: StartWorkflowRequest,
|
||||
|
|
@ -132,6 +457,8 @@ export function useWorkflowLifecycle() {
|
|||
const workflow = result.data as Workflow;
|
||||
setWorkflowId(workflow.id);
|
||||
updateWorkflowStatus(workflow.status || 'running');
|
||||
// Reset lastRenderedTimestamp for new workflow
|
||||
lastRenderedTimestampRef.current = null;
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Failed to start workflow' };
|
||||
|
|
@ -168,17 +495,23 @@ export function useWorkflowLifecycle() {
|
|||
setCurrentRound(undefined);
|
||||
setStatusChangedFromRunningAt(null);
|
||||
statusChangedFromRunningAtRef.current = null;
|
||||
lastRenderedTimestampRef.current = null;
|
||||
pollingControllerRef.current.stopPolling();
|
||||
}, [updateWorkflowStatus]);
|
||||
|
||||
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
|
||||
try {
|
||||
setWorkflowId(workflowIdToSelect);
|
||||
// Reset lastRenderedTimestamp for new workflow selection
|
||||
lastRenderedTimestampRef.current = null;
|
||||
|
||||
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
|
||||
|
||||
if (!workflowData) {
|
||||
setMessages([]);
|
||||
setLogs([]);
|
||||
setDashboardLogs([]);
|
||||
setUnifiedContentLogs([]);
|
||||
updateWorkflowStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
|
@ -191,23 +524,46 @@ export function useWorkflowLifecycle() {
|
|||
updateWorkflowStatus(status);
|
||||
setCurrentRound(round);
|
||||
|
||||
// Always fetch unified chat data to get all messages and logs (regardless of status)
|
||||
// This ensures completed workflows also show their logs
|
||||
try {
|
||||
const chatData = await fetchChatData(request, workflowIdToSelect, undefined);
|
||||
console.log('📥 selectWorkflow: Fetched unified chat data:', {
|
||||
messagesCount: chatData.messages?.length || 0,
|
||||
logsCount: chatData.logs?.length || 0,
|
||||
status
|
||||
});
|
||||
processUnifiedChatData(chatData);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error);
|
||||
// Fallback to workflowData if unified chat data fails
|
||||
if (messagesData.length > 0) {
|
||||
setMessages([...messagesData].sort(sortMessages));
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
if (logsData.length > 0) {
|
||||
setLogs([...logsData].sort(sortLogs));
|
||||
// Process logs and separate by operationId
|
||||
const dashboardLogsList: WorkflowLog[] = [];
|
||||
const unifiedContentLogsList: WorkflowLog[] = [];
|
||||
|
||||
logsData.forEach((log: any) => {
|
||||
const frontendLog = convertLogToFrontendFormat(log);
|
||||
if (frontendLog.operationId) {
|
||||
dashboardLogsList.push(frontendLog);
|
||||
} else {
|
||||
setLogs([]);
|
||||
unifiedContentLogsList.push(frontendLog);
|
||||
}
|
||||
});
|
||||
|
||||
setDashboardLogs(dashboardLogsList.sort(sortLogs));
|
||||
setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs));
|
||||
setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs));
|
||||
}
|
||||
|
||||
// If workflow is running, start polling immediately
|
||||
// The useEffect will handle the polling setup
|
||||
// If workflow is running, polling will start automatically via useEffect
|
||||
} catch (error) {
|
||||
console.error('Error selecting workflow:', error);
|
||||
}
|
||||
}, [request, updateWorkflowStatus]);
|
||||
}, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]);
|
||||
|
||||
const isRunning = workflowStatus === 'running';
|
||||
const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false;
|
||||
|
|
@ -221,6 +577,8 @@ export function useWorkflowLifecycle() {
|
|||
startingWorkflow,
|
||||
messages,
|
||||
logs,
|
||||
dashboardLogs,
|
||||
unifiedContentLogs,
|
||||
startWorkflow: handleStartWorkflow,
|
||||
stopWorkflow: handleStopWorkflow,
|
||||
resetWorkflow,
|
||||
|
|
@ -228,4 +586,3 @@ export function useWorkflowLifecycle() {
|
|||
setWorkflowStatusOptimistic
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
205
src/hooks/playground/useWorkflowPolling.ts
Normal file
205
src/hooks/playground/useWorkflowPolling.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
|
||||
interface PollingState {
|
||||
activeWorkflowId: string | null;
|
||||
isPolling: boolean;
|
||||
isPollInProgress: boolean;
|
||||
isPaused: boolean;
|
||||
currentInterval: number;
|
||||
failureCount: number;
|
||||
rateLimitFailureCount: number;
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
const BASE_INTERVAL = 5000; // 5 seconds
|
||||
const MAX_INTERVAL = 10000; // 10 seconds
|
||||
const BACKOFF_MULTIPLIER = 1.5;
|
||||
const RATE_LIMIT_BACKOFF_MULTIPLIER = 2.0;
|
||||
const MAX_RATE_LIMIT_FAILURES = 5;
|
||||
|
||||
export type PollCallback = (workflowId: string) => Promise<void>;
|
||||
|
||||
export function useWorkflowPolling() {
|
||||
const stateRef = useRef<PollingState>({
|
||||
activeWorkflowId: null,
|
||||
isPolling: false,
|
||||
isPollInProgress: false,
|
||||
isPaused: false,
|
||||
currentInterval: BASE_INTERVAL,
|
||||
failureCount: 0,
|
||||
rateLimitFailureCount: 0,
|
||||
timeoutId: null
|
||||
});
|
||||
|
||||
const pollCallbackRef = useRef<PollCallback | null>(null);
|
||||
|
||||
const calculateInterval = useCallback((isRateLimit: boolean = false): number => {
|
||||
const state = stateRef.current;
|
||||
const multiplier = isRateLimit ? RATE_LIMIT_BACKOFF_MULTIPLIER : BACKOFF_MULTIPLIER;
|
||||
const newInterval = Math.min(
|
||||
BASE_INTERVAL * Math.pow(multiplier, state.failureCount),
|
||||
MAX_INTERVAL
|
||||
);
|
||||
return Math.floor(newInterval);
|
||||
}, []);
|
||||
|
||||
const scheduleNextPoll = useCallback((interval: number) => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.timeoutId) {
|
||||
clearTimeout(state.timeoutId);
|
||||
state.timeoutId = null;
|
||||
}
|
||||
|
||||
// Don't schedule if not polling or paused
|
||||
if (!state.isPolling || state.isPaused || !state.activeWorkflowId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule next poll
|
||||
state.timeoutId = setTimeout(() => {
|
||||
state.timeoutId = null;
|
||||
doPolling();
|
||||
}, interval);
|
||||
}, []);
|
||||
|
||||
const doPolling = useCallback(async () => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Prevent concurrent polls
|
||||
if (state.isPollInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate workflow is still active
|
||||
if (!state.activeWorkflowId || !state.isPolling || state.isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowId = state.activeWorkflowId;
|
||||
state.isPollInProgress = true;
|
||||
|
||||
try {
|
||||
if (pollCallbackRef.current) {
|
||||
await pollCallbackRef.current(workflowId);
|
||||
}
|
||||
|
||||
// Success - reset failure counts and interval
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
|
||||
// Schedule next poll
|
||||
scheduleNextPoll(state.currentInterval);
|
||||
} catch (error: any) {
|
||||
// Handle errors
|
||||
const isRateLimit = error?.status === 429 || error?.response?.status === 429;
|
||||
|
||||
if (isRateLimit) {
|
||||
state.rateLimitFailureCount++;
|
||||
|
||||
// Stop polling after too many rate limit errors
|
||||
if (state.rateLimitFailureCount >= MAX_RATE_LIMIT_FAILURES) {
|
||||
console.error('Too many rate limit errors, stopping polling');
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
state.rateLimitFailureCount = 0; // Reset rate limit count on non-rate-limit errors
|
||||
}
|
||||
|
||||
state.failureCount++;
|
||||
const nextInterval = calculateInterval(isRateLimit);
|
||||
state.currentInterval = nextInterval;
|
||||
|
||||
console.warn(`Polling error (attempt ${state.failureCount}):`, error);
|
||||
|
||||
// Schedule next poll with backoff
|
||||
scheduleNextPoll(nextInterval);
|
||||
} finally {
|
||||
state.isPollInProgress = false;
|
||||
}
|
||||
}, [scheduleNextPoll, calculateInterval]);
|
||||
|
||||
const startPolling = useCallback((workflowId: string, callback: PollCallback) => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Stop any existing polling
|
||||
if (state.isPolling) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
// Validate workflow ID
|
||||
if (!workflowId || typeof workflowId !== 'string') {
|
||||
console.error('Invalid workflow ID for polling:', workflowId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up polling state
|
||||
state.activeWorkflowId = workflowId;
|
||||
state.isPolling = true;
|
||||
state.isPaused = false;
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
pollCallbackRef.current = callback;
|
||||
|
||||
// Execute immediate first poll (no delay)
|
||||
doPolling();
|
||||
}, [doPolling]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Clear timeout
|
||||
if (state.timeoutId) {
|
||||
clearTimeout(state.timeoutId);
|
||||
state.timeoutId = null;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
state.isPolling = false;
|
||||
state.isPollInProgress = false;
|
||||
state.activeWorkflowId = null;
|
||||
state.failureCount = 0;
|
||||
state.rateLimitFailureCount = 0;
|
||||
state.currentInterval = BASE_INTERVAL;
|
||||
state.isPaused = false;
|
||||
pollCallbackRef.current = null;
|
||||
}, []);
|
||||
|
||||
const pausePolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
state.isPaused = true;
|
||||
}, []);
|
||||
|
||||
const resumePolling = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
if (state.isPolling && state.isPaused) {
|
||||
state.isPaused = false;
|
||||
// Resume polling immediately
|
||||
if (!state.isPollInProgress) {
|
||||
scheduleNextPoll(0);
|
||||
}
|
||||
}
|
||||
}, [scheduleNextPoll]);
|
||||
|
||||
const isPolling = useCallback((): boolean => {
|
||||
return stateRef.current.isPolling && !stateRef.current.isPaused;
|
||||
}, []);
|
||||
|
||||
const getActiveWorkflowId = useCallback((): string | null => {
|
||||
return stateRef.current.activeWorkflowId;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startPolling,
|
||||
stopPolling,
|
||||
pausePolling,
|
||||
resumePolling,
|
||||
isPolling,
|
||||
getActiveWorkflowId
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
Loading…
Reference in a new issue