From 696b79ba0d0474a6fb507723892358b564a9305e Mon Sep 17 00:00:00 2001 From: Miguel Date: Fri, 15 Aug 2025 18:55:58 +0200 Subject: [PATCH] feat: Implement coordinated connection and polling for real-time data updates across tabs - Introduced `useCoordinatedConnection` and `useCoordinatedPolling` hooks to manage data fetching and state synchronization between tabs. - Refactored `DatasetCompleteManager`, `PlotRealtimeViewer`, and `VariableSelectorWidget` components to utilize SSE and polling for live data updates. - Added `TabCoordinationDemo` component to visualize tab coordination status and leadership. - Updated `Dashboard` to leverage coordinated polling for status updates, improving performance and reducing redundant connections. - Enhanced `TabCoordinator` utility to manage leadership and data broadcasting between tabs effectively. --- application_events.json | 490 +++++++++++++++++- config/data/plot_definitions.json | 2 +- core/plot_manager.py | 41 +- frontend/src/components/ChartjsPlot.jsx | 248 ++++----- .../src/components/DatasetCompleteManager.jsx | 64 +-- .../src/components/PlotRealtimeViewer.jsx | 79 ++- .../src/components/TabCoordinationDemo.jsx | 62 +++ .../rjsf/VariableSelectorWidget.jsx | 72 +-- .../src/hooks/useCoordinatedConnection.js | 171 ++++++ frontend/src/pages/Dashboard.jsx | 98 ++-- frontend/src/utils/TabCoordinator.js | 298 +++++++++++ main.py | 44 +- system_state.json | 6 +- 13 files changed, 1329 insertions(+), 346 deletions(-) create mode 100644 frontend/src/components/TabCoordinationDemo.jsx create mode 100644 frontend/src/hooks/useCoordinatedConnection.js create mode 100644 frontend/src/utils/TabCoordinator.js diff --git a/application_events.json b/application_events.json index d170622..e9f355d 100644 --- a/application_events.json +++ b/application_events.json @@ -6604,8 +6604,494 @@ "trigger_variable": null, "auto_started": true } + }, + { + "timestamp": "2025-08-15T17:42:00.705539", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T17:42:00.772135", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 2, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-15T17:42:00.779204", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 1, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-15T17:42:00.789530", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T17:42:31.920722", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755272551920_2", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:44:26.728938", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T17:44:26.777455", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 2, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-15T17:44:26.786455", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 1, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-15T17:44:26.794684", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T17:53:24.856979", + "level": "info", + "event_type": "csv_recording_stopped", + "message": "CSV recording stopped (dataset threads continue for UDP streaming)", + "details": {} + }, + { + "timestamp": "2025-08-15T17:53:24.872499", + "level": "info", + "event_type": "udp_streaming_stopped", + "message": "UDP streaming to PlotJuggler stopped (CSV recording continues)", + "details": {} + }, + { + "timestamp": "2025-08-15T17:53:24.966995", + "level": "info", + "event_type": "dataset_deactivated", + "message": "Dataset deactivated: Fast", + "details": { + "dataset_id": "Fast" + } + }, + { + "timestamp": "2025-08-15T17:53:24.977921", + "level": "info", + "event_type": "dataset_deactivated", + "message": "Dataset deactivated: test", + "details": { + "dataset_id": "Test" + } + }, + { + "timestamp": "2025-08-15T17:53:25.341401", + "level": "info", + "event_type": "dataset_deactivated", + "message": "Dataset deactivated: DAR", + "details": { + "dataset_id": "DAR" + } + }, + { + "timestamp": "2025-08-15T17:53:25.355401", + "level": "info", + "event_type": "plc_disconnection", + "message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)", + "details": {} + }, + { + "timestamp": "2025-08-15T17:54:30.335124", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T17:55:02.184677", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273302184_2", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:55:18.605848", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273318604_3", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:55:32.567790", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 2, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-15T17:55:32.670492", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 1, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-15T17:55:32.766807", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T17:55:32.865225", + "level": "info", + "event_type": "plc_connection", + "message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 3 datasets", + "details": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2, + "symbols_path": "C:/Users/migue/Downloads/symSAE452.asc", + "auto_started_recording": true, + "recording_datasets": 3, + "dataset_names": [ + "Fast", + "DAR", + "test" + ] + } + }, + { + "timestamp": "2025-08-15T17:55:41.765105", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273341764_4", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:56:09.469159", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273369469_5", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:58:12.298168", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T17:58:12.350158", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 2, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-15T17:58:12.357167", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 1, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-15T17:58:12.367234", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T17:58:33.564814", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273513563_2", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T17:58:40.629205", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755273520629_3", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T18:51:36.078105", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T18:51:36.128341", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 2, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-15T18:51:36.140887", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 1, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-15T18:51:36.148889", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T18:53:20.148638", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755276800148_2", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T18:53:34.377431", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755276814377_3", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T18:54:02.935377", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755276842934_4", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T18:54:06.986356", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755276846986_5", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T18:54:24.318797", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'Clock' created and started", + "details": { + "session_id": "Clock_1755276864317_6", + "variables": [ + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 10, + "trigger_variable": null, + "auto_started": true + } } ], - "last_updated": "2025-08-15T16:51:06.118418", - "total_entries": 540 + "last_updated": "2025-08-15T18:54:24.318797", + "total_entries": 579 } \ No newline at end of file diff --git a/config/data/plot_definitions.json b/config/data/plot_definitions.json index 862cd9c..d95b8e6 100644 --- a/config/data/plot_definitions.json +++ b/config/data/plot_definitions.json @@ -6,7 +6,7 @@ "name": "UR29", "point_hover_radius": 4, "point_radius": 2.5, - "stacked": true, + "stacked": false, "stepped": true, "time_window": 36, "trigger_enabled": false, diff --git a/core/plot_manager.py b/core/plot_manager.py index a8d8f0f..06e8ebc 100644 --- a/core/plot_manager.py +++ b/core/plot_manager.py @@ -289,33 +289,38 @@ class PlotManager: # Load existing plots from disk self.load_plots() - def create_session(self, config: Dict[str, Any], allow_multiple: bool = True) -> str: + def create_session( + self, config: Dict[str, Any], allow_multiple: bool = True + ) -> str: """Crear una nueva sesión de plotting""" with self.lock: base_id = config.get("id") or f"plot_{self.session_counter}" browser_tab_id = config.get("browser_tab_id", "") - + if not allow_multiple: # Check if there's already an active session for this plot existing_sessions = [ - sid for sid, session in self.sessions.items() + sid + for sid, session in self.sessions.items() if session.plot_id == base_id and session.is_active ] - + if existing_sessions: # Return the existing active session ID if self.logger: - self.logger.info(f"Returning existing active session for plot {base_id}: {existing_sessions[0]}") + self.logger.info( + f"Returning existing active session for plot {base_id}: {existing_sessions[0]}" + ) return existing_sessions[0] - + # Generate unique session ID to allow multiple browser instances # Format: {plot_id}_{browser_tab_id}_{timestamp}_{counter} to ensure uniqueness timestamp = int(time.time() * 1000) # millisecond precision session_id_parts = [base_id] - + if browser_tab_id: session_id_parts.append(browser_tab_id) - + session_id_parts.extend([str(timestamp), str(self.session_counter)]) session_id = "_".join(session_id_parts) self.session_counter += 1 @@ -353,19 +358,23 @@ class PlotManager: def get_sessions_by_plot_id(self, plot_id: str) -> List[str]: """Get all session IDs for a specific plot ID""" with self.lock: - return [sid for sid, session in self.sessions.items() if session.plot_id == plot_id] + return [ + sid + for sid, session in self.sessions.items() + if session.plot_id == plot_id + ] def cleanup_inactive_sessions(self, max_age_seconds: int = 3600) -> int: """Remove inactive sessions older than max_age_seconds""" current_time = time.time() removed_count = 0 - + with self.lock: sessions_to_remove = [] for session_id, session in self.sessions.items(): # Calculate session age from timestamp in session_id try: - parts = session_id.split('_') + parts = session_id.split("_") if len(parts) >= 3: timestamp_ms = int(parts[-2]) session_age = current_time - (timestamp_ms / 1000.0) @@ -374,14 +383,14 @@ class PlotManager: except (ValueError, IndexError): # Skip sessions with invalid timestamp format continue - + for session_id in sessions_to_remove: del self.sessions[session_id] removed_count += 1 - + if removed_count > 0 and self.logger: self.logger.info(f"Cleaned up {removed_count} inactive plot sessions") - + return removed_count def remove_session(self, session_id: str) -> bool: @@ -724,7 +733,7 @@ class PlotManager: "is_active": session.is_active, "is_paused": session.is_paused, } - + # If not found, try to find a session by plot_id (backward compatibility) # This handles cases where frontend queries with plot_id instead of unique session_id for sid, session in self.sessions.items(): @@ -744,7 +753,7 @@ class PlotManager: "is_active": session.is_active, "is_paused": session.is_paused, } - + return None def get_active_sessions_count(self) -> int: diff --git a/frontend/src/components/ChartjsPlot.jsx b/frontend/src/components/ChartjsPlot.jsx index 959646d..7435eec 100644 --- a/frontend/src/components/ChartjsPlot.jsx +++ b/frontend/src/components/ChartjsPlot.jsx @@ -68,7 +68,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const [isLoadingHistorical, setIsLoadingHistorical] = useState(false); const [isZoomEnabled, setIsZoomEnabled] = useState(false); const resolvedConfigRef = useRef(null); - + // Chart health monitoring const chartHealthRef = useRef({ lastDataTimestamp: 0, @@ -81,7 +81,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { console.warn(`🚫 Safe chart ${operation} aborted - invalid DOM state for session ${sessionDataRef.current.sessionId}`); return false; } - + if (!chart.ctx || !chart.canvas || chart.canvas !== canvasRef.current) { console.warn(`🚫 Safe chart ${operation} aborted - chart context mismatch for session ${sessionDataRef.current.sessionId}`); return false; @@ -177,23 +177,23 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Chart health monitoring and auto-recovery const checkChartHealth = useCallback(() => { if (!chartRef.current) return false; - + const health = chartHealthRef.current; const now = Date.now(); - + // Check if chart is receiving data const timeSinceLastData = now - health.lastDataTimestamp; const hasRecentData = timeSinceLastData < 30000; // 30 seconds - + // Check if streaming is properly configured const realtimeOptions = chartRef.current.options?.scales?.x?.realtime; const isStreamingConfigured = !!realtimeOptions; - + // Check if datasets exist and are properly configured const hasDatasets = chartRef.current.data?.datasets?.length > 0; - + const isHealthy = hasRecentData && isStreamingConfigured && hasDatasets; - + if (!isHealthy && health.isHealthy) { console.warn('⚠️ Chart health check failed:', { hasRecentData, @@ -203,16 +203,16 @@ const ChartjsPlot = ({ session, height = '400px' }) => { consecutiveErrors: health.consecutiveErrors }); } - + health.isHealthy = isHealthy; health.lastHealthCheck = now; - + return isHealthy; }, []); - + const attemptAutoRecovery = useCallback(async () => { console.log('🔄 Attempting chart auto-recovery...'); - + try { // Clear all data directly if (chartRef.current) { @@ -224,10 +224,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => { chartRef.current.update('quiet'); setDataPointsCount(0); } - + // Wait a moment await new Promise(resolve => setTimeout(resolve, 100)); - + // Recreate chart if session is active if (session?.is_active && !session?.is_paused) { // Trigger a recreation by clearing refs and letting useEffect handle it @@ -236,18 +236,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // The useEffect will recreate the chart console.log('✅ Chart auto-recovery completed'); }, 100); - + // Reset health counters chartHealthRef.current.consecutiveErrors = 0; chartHealthRef.current.isHealthy = true; - + return true; } } catch (error) { console.error('❌ Chart auto-recovery failed:', error); chartHealthRef.current.consecutiveErrors++; } - + return false; }, [session]); @@ -263,20 +263,20 @@ const ChartjsPlot = ({ session, height = '400px' }) => { datasets: chartRef.current?.data?.datasets?.length || 0, realtimeConfig: !!chartRef.current?.options?.scales?.x?.realtime }); - + // Check for common issues const issues = []; if (!chartRef.current) issues.push('Chart not initialized'); if (!session?.is_active) issues.push('Session not active'); if (session?.is_paused) issues.push('Session is paused'); if (chartHealthRef.current.consecutiveErrors > 5) issues.push(`${chartHealthRef.current.consecutiveErrors} consecutive errors`); - + if (issues.length > 0) { console.warn('⚠️ Issues detected:', issues); } else { console.log('✅ No issues detected'); } - + return issues; }, [session]); @@ -342,7 +342,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { if (timestamp instanceof Date) { return timestamp.getTime(); } - + let raw = timestamp; if (typeof raw === 'string') { const asNum = Number(raw); @@ -358,12 +358,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => { } } } - + if (typeof raw !== 'number' || !Number.isFinite(raw)) { console.warn('⚠️ Invalid timestamp value:', timestamp); return Date.now(); // fallback to current time } - + // Normalize seconds to milliseconds const normalized = raw < 1e12 ? raw * 1000 : raw; return normalized; @@ -373,10 +373,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Ensure we have the most up-to-date config const sessionConfig = session?.config; const resolvedConfig = resolvedConfigRef.current; - + // Prefer session config if it's newer or if resolved config is missing const cfg = sessionConfig || resolvedConfig; - + if (!canvasRef.current || !cfg) return; // Update resolvedConfigRef to ensure consistency @@ -642,7 +642,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { tension: lineTension, stepped: stepped, // Assign Y axis based on configuration and mode - yAxisID: useStackedAxes + yAxisID: useStackedAxes ? `y-axis-${index}` // Stacked mode: each variable gets its own axis : (variableInfo.y_axis === 'right' ? 'y-right' : 'y-left') // Non-stacked: use left/right configuration }; @@ -688,7 +688,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Sort points by timestamp to ensure proper order historicalPoints.sort((a, b) => a.x - b.x); datasets[index].data = historicalPoints; - + // Update lastPushedX tracking for streaming continuity const lastPoint = historicalPoints[historicalPoints.length - 1]; if (lastPoint && typeof lastPoint.x === 'number') { @@ -714,7 +714,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const yMinInitial = (typeof config.y_min === 'number' && isFinite(config.y_min)) ? config.y_min : undefined; const yMaxInitial = (typeof config.y_max === 'number' && isFinite(config.y_max)) ? config.y_max : undefined; - + const chartConfig = { type: 'line', data: { datasets }, @@ -758,77 +758,77 @@ const ChartjsPlot = ({ session, height = '400px' }) => { }, ...(useStackedAxes && enabledVariables.length > 0 ? // When stacked is enabled, create a separate Y axis for each dataset - enabledVariables.reduce((scales, variable, index) => { - // Safe access to variable properties - const variableName = variable.name || variable.label || `Variable ${index}`; - const color = variable.color || getColor(variableName, index); - - // Respect the y_axis configuration from variable settings - // If y_axis is specified, use it; otherwise use intelligent distribution - let position; - if (variable.y_axis === 'left' || variable.y_axis === 'right') { - position = variable.y_axis; - } else { - // Fallback to intelligent distribution for variables without y_axis config - const totalAxes = enabledVariables.length; - if (totalAxes <= 2) { - position = index === 0 ? 'left' : 'right'; - } else if (totalAxes <= 4) { - position = index % 2 === 0 ? 'left' : 'right'; - } else { - const halfPoint = Math.ceil(totalAxes / 2); - position = index < halfPoint ? 'left' : 'right'; - } - } - - scales[`y-axis-${index}`] = { - type: 'linear', - position: position, - stack: 'stack-group', - stackWeight: 1, - border: { - color: color, - width: 2 - }, - grid: { - drawOnChartArea: index === 0, // Only show grid for the first axis - color: color + '20' // Semi-transparent grid lines - }, - ticks: { - color: color, - font: { - weight: 'bold' - } - }, - title: { - display: true, - text: variable.label || variable.name || `Variable ${index}`, - color: color, - font: { - weight: 'bold', - size: 12 - } - }, - min: yMinInitial, - max: yMaxInitial - }; - return scales; - }, {}) - : useStackedAxes && enabledVariables.length === 0 - ? // Stacked is enabled but no variables, add a placeholder axis - { - 'y-axis-default': { - type: 'linear', - position: 'left', - title: { - display: true, - text: 'No Variables' - }, - min: yMinInitial, - max: yMaxInitial - } + enabledVariables.reduce((scales, variable, index) => { + // Safe access to variable properties + const variableName = variable.name || variable.label || `Variable ${index}`; + const color = variable.color || getColor(variableName, index); + + // Respect the y_axis configuration from variable settings + // If y_axis is specified, use it; otherwise use intelligent distribution + let position; + if (variable.y_axis === 'left' || variable.y_axis === 'right') { + position = variable.y_axis; + } else { + // Fallback to intelligent distribution for variables without y_axis config + const totalAxes = enabledVariables.length; + if (totalAxes <= 2) { + position = index === 0 ? 'left' : 'right'; + } else if (totalAxes <= 4) { + position = index % 2 === 0 ? 'left' : 'right'; + } else { + const halfPoint = Math.ceil(totalAxes / 2); + position = index < halfPoint ? 'left' : 'right'; + } } - : // Non-stacked mode with left/right Y axes + + scales[`y-axis-${index}`] = { + type: 'linear', + position: position, + stack: 'stack-group', + stackWeight: 1, + border: { + color: color, + width: 2 + }, + grid: { + drawOnChartArea: index === 0, // Only show grid for the first axis + color: color + '20' // Semi-transparent grid lines + }, + ticks: { + color: color, + font: { + weight: 'bold' + } + }, + title: { + display: true, + text: variable.label || variable.name || `Variable ${index}`, + color: color, + font: { + weight: 'bold', + size: 12 + } + }, + min: yMinInitial, + max: yMaxInitial + }; + return scales; + }, {}) + : useStackedAxes && enabledVariables.length === 0 + ? // Stacked is enabled but no variables, add a placeholder axis + { + 'y-axis-default': { + type: 'linear', + position: 'left', + title: { + display: true, + text: 'No Variables' + }, + min: yMinInitial, + max: yMaxInitial + } + } + : // Non-stacked mode with left/right Y axes { 'y-left': { type: 'linear', @@ -875,7 +875,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { enabled: isZoomEnabled, mode: 'x', modifierKey: 'shift', - onPanComplete: function(context) { + onPanComplete: function (context) { // Preserve streaming state after pan const chart = context.chart; if (chart.options?.scales?.x?.realtime) { @@ -888,21 +888,21 @@ const ChartjsPlot = ({ session, height = '400px' }) => { } }, zoom: { - drag: { + drag: { enabled: isZoomEnabled, backgroundColor: 'rgba(128,128,128,0.3)' }, - wheel: { + wheel: { enabled: isZoomEnabled, speed: 0.1 }, pinch: { enabled: isZoomEnabled }, mode: 'x', - onZoomComplete: function(context) { + onZoomComplete: function (context) { // Preserve streaming state and data after zoom const chart = context.chart; console.log('🔍 Zoom completed, preserving streaming state...'); - + if (chart.options?.scales?.x?.realtime) { const realtime = chart.options.scales.x.realtime; // Don't auto-pause streaming after zoom @@ -1172,18 +1172,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Update chart if (pointsAdded > 0) { safeChartUpdate(chart, 'quiet', 'data ingestion'); - + // Update health monitoring chartHealthRef.current.lastDataTimestamp = Date.now(); chartHealthRef.current.consecutiveErrors = 0; } else { // No new data received - increment error counter chartHealthRef.current.consecutiveErrors++; - + // Check if we need auto-recovery after multiple failed attempts if (chartHealthRef.current.consecutiveErrors > 10) { console.warn(`⚠️ No data received for ${chartHealthRef.current.consecutiveErrors} consecutive attempts`); - + // Attempt auto-recovery if too many consecutive errors if (chartHealthRef.current.consecutiveErrors > 20) { console.warn('🚑 Triggering auto-recovery due to consecutive data failures'); @@ -1287,7 +1287,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Sort points by timestamp to ensure proper order historicalPoints.sort((a, b) => a.x - b.x); chart.data.datasets[index].data = historicalPoints; - + // Update lastPushedX tracking for streaming continuity const lastPoint = historicalPoints[historicalPoints.length - 1]; if (lastPoint && typeof lastPoint.x === 'number') { @@ -1348,7 +1348,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { try { console.log('🔄 Resetting zoom with full data reload...'); - + // Get current configuration for variable information const cfg = resolvedConfigRef.current || session?.config; if (!cfg || !cfg.variables) { @@ -1382,14 +1382,14 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const enabledVariables = getEnabledVariables(cfg.variables); if (enabledVariables.length > 0) { console.log(`📊 Reloading data for ${enabledVariables.length} variables...`); - + // Get current time window (use same as initial load) const timeWindow = cfg.time_window || 3600; // Default 1 hour const variableNames = enabledVariables.map(v => v.name); - + // Load fresh historical data const historicalData = await loadHistoricalData(variableNames, timeWindow); - + if (historicalData.length > 0) { console.log(`📊 Loaded ${historicalData.length} historical data points for reset`); @@ -1413,7 +1413,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Sort points by timestamp to ensure proper order historicalPoints.sort((a, b) => a.x - b.x); chartRef.current.data.datasets[index].data = historicalPoints; - + // Update lastPushedX tracking for streaming continuity const lastPoint = historicalPoints[historicalPoints.length - 1]; if (lastPoint && typeof lastPoint.x === 'number') { @@ -1429,7 +1429,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0); setDataPointsCount(totalHistoricalPoints); } - + console.log('✅ Historical data reloaded'); } @@ -1454,11 +1454,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const oldConfig = resolvedConfigRef.current; console.log(`📋 Old config:`, oldConfig); - + // Merge new config with current configuration const mergedConfig = { ...oldConfig, ...newConfig }; console.log(`📋 Merged config:`, mergedConfig); - + resolvedConfigRef.current = mergedConfig; // Always recreate chart to ensure all changes are applied correctly @@ -1574,20 +1574,20 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Update zoom configuration when zoom enabled state changes useEffect(() => { if (!chartRef.current) return; - + const chart = chartRef.current; const zoomPlugin = chart.options?.plugins?.zoom; - + if (zoomPlugin) { // Update zoom configuration dynamically zoomPlugin.pan.enabled = isZoomEnabled; zoomPlugin.zoom.drag.enabled = isZoomEnabled; zoomPlugin.zoom.wheel.enabled = isZoomEnabled; zoomPlugin.zoom.pinch.enabled = isZoomEnabled; - + // Update the chart to apply the new configuration safeChartUpdate(chart, 'none', 'zoom configuration'); - + console.log(`🔍 Zoom/Pan ${isZoomEnabled ? 'enabled' : 'disabled'}`); } }, [isZoomEnabled]); @@ -1595,10 +1595,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Periodic health monitoring useEffect(() => { if (!session?.session_id || !chartRef.current) return; - + const healthCheckInterval = setInterval(() => { const isHealthy = checkChartHealth(); - + // Log health status periodically for debugging if (!isHealthy) { console.warn('📊 Chart health check:', { @@ -1610,7 +1610,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { }); } }, 15000); // Check every 15 seconds - + return () => clearInterval(healthCheckInterval); }, [session?.session_id, checkChartHealth]); @@ -1871,7 +1871,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { > 🔄 Reset Zoom - + {/* Auto-recovery button when chart health is poor */} {!chartHealthRef.current.isHealthy && ( <> @@ -1893,7 +1893,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { > 🚑 Fix Chart - + + {isLeader && ( + 👑 Leader Tab + )} + {isConnected && ( + 🔗 Connected + )} - {statusError && ( - - - Failed to load status: {statusError} - - )} + {/* Tab Coordination Demo */} + - + diff --git a/frontend/src/utils/TabCoordinator.js b/frontend/src/utils/TabCoordinator.js new file mode 100644 index 0000000..5cd945e --- /dev/null +++ b/frontend/src/utils/TabCoordinator.js @@ -0,0 +1,298 @@ +/** + * TabCoordinator - Coordina múltiples pestañas para evitar sobrecarga de conexiones + * + * Problema: Los navegadores limitan a ~6 conexiones HTTP concurrentes por dominio. + * Cada pestaña crea múltiples conexiones (SSE + polling), causando bloqueos. + * + * Solución: Solo la pestaña "líder" hace las conexiones reales, las demás reciben + * los datos via BroadcastChannel. + */ + +class TabCoordinator { + constructor() { + this.tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + this.isLeader = false + this.channel = new BroadcastChannel('plc_streamer_coordination') + this.subscribers = new Map() + this.leaderHeartbeatInterval = null + this.leaderCheckInterval = null + + // Configuración + this.HEARTBEAT_INTERVAL = 3000 // 3 segundos + this.LEADER_TIMEOUT = 6000 // 6 segundos sin heartbeat = cambio de líder + + console.log(`🔗 TabCoordinator initialized for tab ${this.tabId}`) + + this.setupEventListeners() + this.electLeader() + } + + setupEventListeners() { + // Escuchar mensajes de otras pestañas + this.channel.addEventListener('message', (event) => { + const { type, data, fromTab } = event.data + + if (fromTab === this.tabId) return // Ignorar nuestros propios mensajes + + switch (type) { + case 'leader_heartbeat': + this.handleLeaderHeartbeat(data, fromTab) + break + case 'leader_election': + this.handleLeaderElection(fromTab) + break + case 'data_broadcast': + this.handleDataBroadcast(data) + break + case 'leader_resignation': + this.handleLeaderResignation(fromTab) + break + } + }) + + // Cleanup al cerrar pestaña + window.addEventListener('beforeunload', () => { + this.resignLeadership() + }) + + // Cleanup al ocultar pestaña (cambio de tab) + document.addEventListener('visibilitychange', () => { + if (document.hidden && this.isLeader) { + // Tab oculta, considerar resignar liderazgo tras un delay + setTimeout(() => { + if (document.hidden && this.isLeader) { + this.resignLeadership() + } + }, 5000) + } + }) + } + + electLeader() { + // Verificar si ya hay un líder activo + const lastHeartbeat = localStorage.getItem('plc_leader_heartbeat') + const lastTab = localStorage.getItem('plc_leader_tab') + + if (lastHeartbeat && lastTab) { + const timeSinceHeartbeat = Date.now() - parseInt(lastHeartbeat) + if (timeSinceHeartbeat < this.LEADER_TIMEOUT) { + // Hay un líder activo, somos seguidores + this.becomeFollower() + return + } + } + + // No hay líder o expiró, intentar ser líder + this.becomeLeader() + } + + becomeLeader() { + if (this.isLeader) return + + console.log(`👑 Tab ${this.tabId} becoming leader`) + this.isLeader = true + + // Anunciar liderazgo + this.broadcast('leader_election', { timestamp: Date.now() }) + + // Iniciar heartbeat + this.startHeartbeat() + + // Notificar a subscribers que somos el líder + this.notifySubscribers('leadership_change', { isLeader: true }) + } + + becomeFollower() { + if (!this.isLeader) return + + console.log(`👥 Tab ${this.tabId} becoming follower`) + this.isLeader = false + + // Detener heartbeat + this.stopHeartbeat() + + // Notificar a subscribers que ya no somos líder + this.notifySubscribers('leadership_change', { isLeader: false }) + } + + resignLeadership() { + if (!this.isLeader) return + + console.log(`📤 Tab ${this.tabId} resigning leadership`) + + // Anunciar resignación + this.broadcast('leader_resignation', { timestamp: Date.now() }) + + // Limpiar localStorage + localStorage.removeItem('plc_leader_heartbeat') + localStorage.removeItem('plc_leader_tab') + + this.becomeFollower() + } + + startHeartbeat() { + this.stopHeartbeat() // Cleanup previo + + this.leaderHeartbeatInterval = setInterval(() => { + const timestamp = Date.now() + + // Guardar en localStorage para verificación de otras pestañas + localStorage.setItem('plc_leader_heartbeat', timestamp.toString()) + localStorage.setItem('plc_leader_tab', this.tabId) + + // Broadcast a otras pestañas + this.broadcast('leader_heartbeat', { timestamp }) + }, this.HEARTBEAT_INTERVAL) + + // Inmediatamente hacer el primer heartbeat + const timestamp = Date.now() + localStorage.setItem('plc_leader_heartbeat', timestamp.toString()) + localStorage.setItem('plc_leader_tab', this.tabId) + } + + stopHeartbeat() { + if (this.leaderHeartbeatInterval) { + clearInterval(this.leaderHeartbeatInterval) + this.leaderHeartbeatInterval = null + } + } + + handleLeaderHeartbeat(data, fromTab) { + if (this.isLeader && fromTab !== this.tabId) { + // Otro tab dice ser líder, verificar timestamps + const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0') + const theirTimestamp = data.timestamp + + if (theirTimestamp > ourTimestamp + 1000) { // 1 segundo de tolerancia + // Su heartbeat es más reciente, renunciar + console.log(`👑 Tab ${this.tabId} yielding leadership to ${fromTab}`) + this.resignLeadership() + } + } + } + + handleLeaderElection(fromTab) { + if (this.isLeader) { + // Conflicto de liderazgo, resolver por timestamp + const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0') + const currentTime = Date.now() + + // Si nuestro heartbeat es muy viejo, renunciar + if (currentTime - ourTimestamp > this.HEARTBEAT_INTERVAL * 2) { + this.resignLeadership() + } + } + } + + handleLeaderResignation(fromTab) { + // Si el líder renuncia, intentar tomar el liderazgo tras un delay + setTimeout(() => { + this.electLeader() + }, 1000 + Math.random() * 1000) // 1-2 segundos aleatorio para evitar conflictos + } + + handleDataBroadcast(data) { + // Retransmitir datos a subscribers locales + const { source, payload } = data + this.notifySubscribers('data_received', { source, data: payload }) + } + + broadcast(type, data) { + this.channel.postMessage({ + type, + data, + fromTab: this.tabId, + timestamp: Date.now() + }) + } + + // API para que los componentes se subscriban a datos + subscribe(source, callback) { + if (!this.subscribers.has(source)) { + this.subscribers.set(source, new Set()) + } + this.subscribers.get(source).add(callback) + + // Retornar función de cleanup + return () => { + const subs = this.subscribers.get(source) + if (subs) { + subs.delete(callback) + if (subs.size === 0) { + this.subscribers.delete(source) + } + } + } + } + + notifySubscribers(type, data) { + // Notificar a todos los subscribers + for (const [source, callbacks] of this.subscribers.entries()) { + for (const callback of callbacks) { + try { + callback({ type, source, data }) + } catch (error) { + console.error(`Error in subscriber callback for ${source}:`, error) + } + } + } + } + + // API para que el líder envíe datos a otras pestañas + broadcastData(source, data) { + if (!this.isLeader) return + + this.broadcast('data_broadcast', { + source, + payload: data + }) + } + + // API para verificar si somos el líder + getIsLeader() { + return this.isLeader + } + + // API para hacer una conexión "coordinada" - solo el líder la hace realmente + createCoordinatedConnection(source, connectionFactory) { + if (this.isLeader) { + // Somos líder, crear conexión real + console.log(`🔗 Tab ${this.tabId} (leader) creating real connection for ${source}`) + return connectionFactory((data) => { + // Cuando recibimos datos, enviarlos a otras pestañas + this.broadcastData(source, data) + // Y también procesarlos localmente + this.notifySubscribers('data_received', { source, data }) + }) + } else { + // Somos seguidores, retornar conexión "fake" que escucha broadcasts + console.log(`👥 Tab ${this.tabId} (follower) creating coordinated connection for ${source}`) + return { + close: () => {}, // No-op + // Las pestañas seguidoras reciben datos via broadcasts + } + } + } + + destroy() { + this.resignLeadership() + this.stopHeartbeat() + this.channel.close() + + if (this.leaderCheckInterval) { + clearInterval(this.leaderCheckInterval) + } + } +} + +// Singleton global +let globalCoordinator = null + +export function getTabCoordinator() { + if (!globalCoordinator) { + globalCoordinator = new TabCoordinator() + } + return globalCoordinator +} + +export default TabCoordinator diff --git a/main.py b/main.py index 0080caf..1cab1d2 100644 --- a/main.py +++ b/main.py @@ -1607,8 +1607,10 @@ def create_plot(): # Check if multiple sessions should be allowed (default: True for backward compatibility) allow_multiple = data.get("allow_multiple", True) - - session_id = streamer.data_streamer.plot_manager.create_session(config, allow_multiple) + + session_id = streamer.data_streamer.plot_manager.create_session( + config, allow_multiple + ) return jsonify( { @@ -2163,13 +2165,17 @@ def get_plot_sessions(plot_id): return error_response try: - session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id(plot_id) - return jsonify({ - "success": True, - "plot_id": plot_id, - "session_ids": session_ids, - "session_count": len(session_ids) - }) + session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id( + plot_id + ) + return jsonify( + { + "success": True, + "plot_id": plot_id, + "session_ids": session_ids, + "session_count": len(session_ids), + } + ) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -2184,14 +2190,18 @@ def cleanup_plot_sessions(): try: data = request.get_json() or {} max_age_seconds = data.get("max_age_seconds", 3600) # Default 1 hour - - removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions(max_age_seconds) - - return jsonify({ - "success": True, - "removed_sessions": removed_count, - "message": f"Cleaned up {removed_count} inactive plot sessions" - }) + + removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions( + max_age_seconds + ) + + return jsonify( + { + "success": True, + "removed_sessions": removed_count, + "message": f"Cleaned up {removed_count} inactive plot sessions", + } + ) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/system_state.json b/system_state.json index a226ca9..030bc8b 100644 --- a/system_state.json +++ b/system_state.json @@ -4,10 +4,10 @@ "should_stream": false, "active_datasets": [ "DAR", - "Fast", - "Test" + "Test", + "Fast" ] }, "auto_recovery_enabled": true, - "last_update": "2025-08-15T16:50:35.341984" + "last_update": "2025-08-15T18:51:36.158179" } \ No newline at end of file