From e97cd5260bac91af3015eaf61e787359d365e197 Mon Sep 17 00:00:00 2001 From: Miguel Date: Fri, 15 Aug 2025 16:56:02 +0200 Subject: [PATCH] feat: Implement multi-browser support for plot sessions - Enhanced session ID generation to include browser tab identifiers, allowing multiple instances of the same plot in different tabs. - Updated PlotManager to manage sessions more effectively, including cleanup of inactive sessions. - Modified frontend components to handle dynamic session IDs and ensure independent operation across tabs. - Added new API endpoints for retrieving sessions by plot ID and manual cleanup of inactive sessions. - Improved error handling and logging for better debugging and user experience. --- MULTI_BROWSER_FIX_SUMMARY.md | 64 ++ MULTI_BROWSER_SUPPORT.md | 75 +++ application_events.json | 583 +++++++++++++++++- config/data/plot_definitions.json | 2 +- core/plot_manager.py | 137 +++- frontend/src/components/ChartjsPlot.jsx | 62 +- .../src/components/PlotRealtimeSession.jsx | 98 ++- main.py | 46 +- system_state.json | 4 +- 9 files changed, 1010 insertions(+), 61 deletions(-) create mode 100644 MULTI_BROWSER_FIX_SUMMARY.md create mode 100644 MULTI_BROWSER_SUPPORT.md diff --git a/MULTI_BROWSER_FIX_SUMMARY.md b/MULTI_BROWSER_FIX_SUMMARY.md new file mode 100644 index 0000000..326dee3 --- /dev/null +++ b/MULTI_BROWSER_FIX_SUMMARY.md @@ -0,0 +1,64 @@ +# Multi-Browser Plot Fix - Resumen de Cambios + +## Problema Original +Cuando se abría el mismo plot en múltiples pestañas del mismo navegador, ambas instancias se bloqueaban y dejaban de funcionar. Funcionaba bien en navegadores diferentes pero no en pestañas del mismo navegador. + +## Análisis del Problema +El problema era que aunque el backend generaba session IDs únicos, el frontend seguía consultando con el `plot_id` original, causando que la segunda pestaña encontrara la sesión de la primera pestaña y asumiera que era suya. + +## Solución Implementada + +### 1. Backend Changes (core/plot_manager.py) +- **Session IDs únicos mejorados**: Ahora incluyen `browser_tab_id` en el formato: `{plot_id}_{browser_tab_id}_{timestamp}_{counter}` +- **Búsqueda por plot_id**: El método `get_session_config` ahora puede buscar por session_id único O por plot_id (backward compatibility) +- **Limpieza automática**: Sesiones inactivas se limpian automáticamente cada 5 minutos + +### 2. Frontend Changes (PlotRealtimeSession.jsx) +- **Browser Tab ID único**: Cada instancia genera un ID único: `tab_{timestamp}_{random}` +- **Session ID management**: Se mantiene el `actualSessionId` devuelto por el backend +- **No auto-refresh inicial**: Se eliminó el auto-refresh al cargar para evitar conflictos +- **Identificador visual**: Se muestra el Tab ID y Session ID para debugging + +### 3. API Changes (main.py) +- **Soporte para browser_tab_id**: El endpoint de creación acepta `browser_tab_id` +- **Nuevos endpoints**: + - `/api/plots/sessions/{plot_id}` - Lista sesiones de un plot + - `/api/plots/cleanup` - Limpieza manual de sesiones + +## Flujo de Trabajo Actualizado + +### Primera Pestaña: +1. Genera `browserTabId` único +2. Crea sesión con `browser_tab_id` incluido +3. Recibe `actualSessionId` único del backend +4. Usa `actualSessionId` para todas las operaciones + +### Segunda Pestaña: +1. Genera su propio `browserTabId` único +2. No busca sesiones existentes al inicio +3. Crea su propia sesión independiente +4. Recibe su propio `actualSessionId` único +5. Opera completamente independiente de la primera pestaña + +## Beneficios +- ✅ Múltiples pestañas del mismo navegador funcionan independientemente +- ✅ Múltiples navegadores siguen funcionando +- ✅ No hay conflictos de estado entre instancias +- ✅ Limpieza automática previene acumulación de sesiones +- ✅ Debugging mejorado con IDs visibles +- ✅ Backward compatibility mantenida + +## Testing +Para probar: +1. Abrir plot en primera pestaña +2. Hacer clic en "Start" +3. Abrir segunda pestaña del mismo navegador +4. Abrir el mismo plot +5. Hacer clic en "Start" en segunda pestaña +6. Ambos plots deben funcionar independientemente + +## Archivos Modificados +- `core/plot_manager.py` - Session management mejorado +- `frontend/src/components/PlotRealtimeSession.jsx` - Tab ID y session management +- `main.py` - API endpoints actualizados +- `MULTI_BROWSER_SUPPORT.md` - Documentación diff --git a/MULTI_BROWSER_SUPPORT.md b/MULTI_BROWSER_SUPPORT.md new file mode 100644 index 0000000..aa2f564 --- /dev/null +++ b/MULTI_BROWSER_SUPPORT.md @@ -0,0 +1,75 @@ +# Multi-Browser Plot Support + +## Problema Resuelto +Anteriormente no era posible tener el mismo plot activo en múltiples navegadores simultáneamente. Cuando se intentaba esto, ambas instancias se bloqueaban y dejaban de funcionar. + +## Solución Implementada + +### 1. Session IDs Únicos +- **Antes**: El sistema usaba `plotDefinition.id` directamente como `session_id` +- **Ahora**: Se generan session IDs únicos con formato: `{plot_id}_{timestamp}_{counter}` + +### 2. Gestión de Sesiones Múltiples +- Cada navegador/pestaña puede crear su propia sesión independiente +- Las sesiones se distinguen por timestamps únicos +- Se incluye limpieza automática de sesiones inactivas + +### 3. API Mejorada + +#### Nuevo Parámetro en POST /api/plots +```json +{ + "id": "plot_1", + "name": "Mi Plot", + "variables": ["var1", "var2"], + "allow_multiple": true // Permite múltiples sesiones (default: true) +} +``` + +#### Nuevos Endpoints +- `GET /api/plots/sessions/{plot_id}` - Lista todas las sesiones de un plot +- `POST /api/plots/cleanup` - Limpieza manual de sesiones inactivas + +### 4. Frontend Actualizado +- El componente `PlotRealtimeSession` ahora maneja session IDs dinámicos +- Se muestra información del session ID real cuando es diferente del plot ID +- Mejor gestión de estado para sesiones independientes + +### 5. Limpieza Automática +- Las sesiones inactivas se limpian automáticamente cada 5 minutos +- Sesiones mayores a 1 hora sin actividad son eliminadas por defecto +- Evita acumulación de sesiones obsoletas + +## Uso + +### Para Permitir Múltiples Instancias (Comportamiento por Defecto) +```javascript +await api.createPlot({ + id: "plot_1", + name: "Mi Plot", + variables: ["var1", "var2"], + allow_multiple: true // Permite múltiples navegadores +}) +``` + +### Para Reutilizar Sesión Existente +```javascript +await api.createPlot({ + id: "plot_1", + name: "Mi Plot", + variables: ["var1", "var2"], + allow_multiple: false // Reutiliza sesión activa si existe +}) +``` + +## Beneficios +1. **Múltiples Navegadores**: Cada usuario puede tener su propia instancia del mismo plot +2. **Sin Conflictos**: Las sesiones no se interfieren entre sí +3. **Limpieza Automática**: No hay acumulación de sesiones obsoletas +4. **Compatibilidad**: Mantiene compatibilidad con código existente +5. **Flexibilidad**: Se puede elegir entre múltiples sesiones o reutilización + +## Consideraciones +- Cada sesión adicional consume memoria +- La limpieza automática evita acumulación excesiva +- El frontend mantiene referencia al session ID real para comunicación correcta con el backend diff --git a/application_events.json b/application_events.json index 30b9a99..d170622 100644 --- a/application_events.json +++ b/application_events.json @@ -6025,8 +6025,587 @@ "trigger_variable": null, "auto_started": true } + }, + { + "timestamp": "2025-08-15T15:49:22.566965", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1", + "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-15T15:49:32.233072", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1", + "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-15T15:49:39.683760", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1", + "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-15T15:51:11.724780", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'Clock' created and started", + "details": { + "session_id": "Clock", + "variables": [ + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 10, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T15:57:54.453324", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T15:57:54.518452", + "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-15T15:57:54.525479", + "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-15T15:57:54.532986", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T15:59:56.276302", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755266396276_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-15T16:01:51.200918", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T16:01:51.248414", + "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-15T16:01:51.256921", + "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-15T16:01:51.263933", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T16:01:51.344460", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755266511344_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-15T16:02:02.915970", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755266522914_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-15T16:03:04.548242", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'Clock' created and started", + "details": { + "session_id": "Clock_1755266584547_4", + "variables": [ + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 10, + "trigger_variable": null, + "auto_started": true + } + }, + { + "timestamp": "2025-08-15T16:05:00.699266", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755266700699_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-15T16:24:41.195883", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T16:24:41.246019", + "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-15T16:24:41.258139", + "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-15T16:24:41.265238", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T16:24:53.119430", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755267893118_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-15T16:30:28.630568", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T16:30:28.678921", + "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-15T16:30:28.688439", + "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-15T16:30:28.696892", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T16:34:16.615010", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268456615_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-15T16:35:22.569951", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268522569_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-15T16:35:23.234508", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268523232_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-15T16:35:23.251273", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268523250_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-15T16:40:08.612042", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268808612_6", + "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-15T16:40:15.632779", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755268815631_7", + "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-15T16:45:25.963734", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755269125963_8", + "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-15T16:48:01.283698", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755269281282_9", + "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-15T16:48:01.644316", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755269281644_10", + "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-15T16:50:35.251305", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-15T16:50:35.316564", + "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-15T16:50:35.324572", + "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-15T16:50:35.333019", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 3 + } + }, + { + "timestamp": "2025-08-15T16:50:37.736391", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755269437735_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-15T16:51:06.118418", + "level": "info", + "event_type": "plot_session_created", + "message": "Plot session 'UR29' created and started", + "details": { + "session_id": "plot_1_1755269466118_3", + "variables": [ + "UR29_Brix", + "UR29_ma", + "AUX Blink_1.0S", + "AUX Blink_1.6S" + ], + "time_window": 36, + "trigger_variable": null, + "auto_started": true + } } ], - "last_updated": "2025-08-15T15:48:55.687372", - "total_entries": 499 + "last_updated": "2025-08-15T16:51:06.118418", + "total_entries": 540 } \ No newline at end of file diff --git a/config/data/plot_definitions.json b/config/data/plot_definitions.json index 958775b..862cd9c 100644 --- a/config/data/plot_definitions.json +++ b/config/data/plot_definitions.json @@ -7,7 +7,7 @@ "point_hover_radius": 4, "point_radius": 2.5, "stacked": true, - "stepped": false, + "stepped": true, "time_window": 36, "trigger_enabled": false, "trigger_on_true": true, diff --git a/core/plot_manager.py b/core/plot_manager.py index 143424f..a8d8f0f 100644 --- a/core/plot_manager.py +++ b/core/plot_manager.py @@ -26,6 +26,8 @@ class PlotSession: def __init__(self, session_id: str, config: Dict[str, Any]): self.session_id = session_id + # Store original plot ID for reference and grouping + self.plot_id = config.get("id", "unknown") self.name = config.get("name", f"Plot {session_id}") # Handle new variable structure with colors and enabled state @@ -225,6 +227,7 @@ class PlotSession: return { "session_id": self.session_id, + "plot_id": self.plot_id, # Include original plot ID for reference "name": self.name, "datasets": datasets, "y_min": y_min, @@ -243,6 +246,7 @@ class PlotSession: """Obtener estado de la sesión""" return { "session_id": self.session_id, + "plot_id": self.plot_id, # Include original plot ID for reference "name": self.name, "is_active": self.is_active, "is_paused": self.is_paused, @@ -275,19 +279,46 @@ class PlotManager: os.path.join(data_dir, "plot_variables.json") ) + # Initialize cleanup timer for inactive sessions + self._last_cleanup = time.time() + self._cleanup_interval = 300 # 5 minutes + # Legacy plot file for migration self.plots_file = resource_path(os.path.join(data_dir, "plot_sessions.json")) # Load existing plots from disk self.load_plots() - def create_session(self, config: Dict[str, Any]) -> str: + def create_session(self, config: Dict[str, Any], allow_multiple: bool = True) -> str: """Crear una nueva sesión de plotting""" with self.lock: - # Use the ID from config if available, otherwise generate one - session_id = config.get("id") or f"plot_{self.session_counter}" - if not config.get("id"): - self.session_counter += 1 + 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() + 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]}") + 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 session = PlotSession(session_id, config) # 🔑 CAMBIO: Crear sesiones en modo activo por defecto para mejor UX @@ -319,6 +350,40 @@ class PlotManager: return session_id + 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] + + 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('_') + if len(parts) >= 3: + timestamp_ms = int(parts[-2]) + session_age = current_time - (timestamp_ms / 1000.0) + if not session.is_active and session_age > max_age_seconds: + sessions_to_remove.append(session_id) + 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: """Eliminar una sesión de plotting""" with self.lock: @@ -382,6 +447,12 @@ class PlotManager: def update_data(self, dataset_id: str, variables_data: Dict[str, Any]) -> None: """Actualizar datos de todas las sesiones activas usando cache del recording""" + # Periodic cleanup of inactive sessions + current_time = time.time() + if current_time - self._last_cleanup > self._cleanup_interval: + self.cleanup_inactive_sessions() + self._last_cleanup = current_time + with self.lock: for session in self.sessions.values(): if not session.is_active: @@ -636,23 +707,45 @@ class PlotManager: def get_session_config(self, session_id: str) -> Optional[Dict[str, Any]]: """Obtener configuración completa de una sesión""" with self.lock: - if session_id not in self.sessions: - return None - - session = self.sessions[session_id] - return { - "session_id": session_id, - "name": session.name, - "variables": session.variables, - "time_window": session.time_window, - "y_min": session.y_min, - "y_max": session.y_max, - "trigger_variable": session.trigger_variable, - "trigger_enabled": session.trigger_enabled, - "trigger_on_true": session.trigger_on_true, - "is_active": session.is_active, - "is_paused": session.is_paused, - } + # First try direct session_id lookup + if session_id in self.sessions: + session = self.sessions[session_id] + return { + "session_id": session_id, + "plot_id": session.plot_id, # Include original plot_id for reference + "name": session.name, + "variables": session.variables, + "time_window": session.time_window, + "y_min": session.y_min, + "y_max": session.y_max, + "trigger_variable": session.trigger_variable, + "trigger_enabled": session.trigger_enabled, + "trigger_on_true": session.trigger_on_true, + "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(): + if session.plot_id == session_id: + # Return the first session found for this plot_id + return { + "session_id": sid, # Return the actual unique session_id + "plot_id": session.plot_id, + "name": session.name, + "variables": session.variables, + "time_window": session.time_window, + "y_min": session.y_min, + "y_max": session.y_max, + "trigger_variable": session.trigger_variable, + "trigger_enabled": session.trigger_enabled, + "trigger_on_true": session.trigger_on_true, + "is_active": session.is_active, + "is_paused": session.is_paused, + } + + return None def get_active_sessions_count(self) -> int: """Obtener número de sesiones activas""" diff --git a/frontend/src/components/ChartjsPlot.jsx b/frontend/src/components/ChartjsPlot.jsx index 7ef0a18..959646d 100644 --- a/frontend/src/components/ChartjsPlot.jsx +++ b/frontend/src/components/ChartjsPlot.jsx @@ -73,10 +73,29 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const chartHealthRef = useRef({ lastDataTimestamp: 0, consecutiveErrors: 0, - isHealthy: true, - lastHealthCheck: 0 }); + // Safe Chart.js operation helper + const safeChartUpdate = useCallback((chart, updateMode = 'none', operation = 'update') => { + if (!chart || !canvasRef.current || !document.contains(canvasRef.current)) { + 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; + } + + try { + chart.update(updateMode); + return true; + } catch (error) { + console.error(`📈 Error in chart ${operation} for session ${sessionDataRef.current.sessionId}:`, error); + return false; + } + }, []); + const bgColor = useColorModeValue('white', 'gray.800'); const textColor = useColorModeValue('gray.600', 'gray.300'); @@ -529,7 +548,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const rt = existingChart.options?.scales?.x?.realtime; if (rt) { rt.pause = true; - existingChart.update('none'); + try { + existingChart.update('none'); + } catch (updateError) { + console.warn('⚠️ Error updating chart during destruction:', updateError); + } } // Stop any running animations and timers @@ -1003,6 +1026,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => { const sessionId = sessionDataRef.current.sessionId; if (!sessionId) return; + // Enhanced DOM and chart validation + if (!chart || !canvasRef.current || !document.contains(canvasRef.current)) { + console.warn(`🚫 onStreamingRefresh aborted - invalid DOM state for session ${sessionId}`); + return; + } + + // Validate that chart is still properly initialized and not destroyed + if (!chart.ctx || !chart.canvas || chart.canvas !== canvasRef.current) { + console.warn(`🚫 onStreamingRefresh aborted - chart context mismatch for session ${sessionId}`); + return; + } + try { const now = Date.now(); const refreshRate = sessionDataRef.current.refreshRate; @@ -1031,7 +1066,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => { if (sessionDataRef.current.isPaused && !sessionDataRef.current.userPaused) { const rt = chart.options?.scales?.x?.realtime; if (rt) rt.pause = false; - chart.update('none'); + // Double-check chart validity before update + if (chart.ctx && canvasRef.current && document.contains(canvasRef.current)) { + chart.update('none'); + } sessionDataRef.current.isPaused = false; sessionDataRef.current.ingestPaused = false; } @@ -1040,7 +1078,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => { if (sessionDataRef.current.noDataCycles >= 3) { const rt = chart.options?.scales?.x?.realtime; if (rt) rt.pause = true; - chart.update('none'); + // Double-check chart validity before update + if (chart.ctx && canvasRef.current && document.contains(canvasRef.current)) { + chart.update('none'); + } sessionDataRef.current.isPaused = true; sessionDataRef.current.ingestPaused = true; } @@ -1130,7 +1171,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { // Update chart if (pointsAdded > 0) { - chart.update('quiet'); + safeChartUpdate(chart, 'quiet', 'data ingestion'); // Update health monitoring chartHealthRef.current.lastDataTimestamp = Date.now(); @@ -1190,7 +1231,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { if (rt) { rt.pause = true; } - chart.update('none'); + safeChartUpdate(chart, 'none', 'pause streaming'); } else { if (sessionData.manualInterval) { clearInterval(sessionData.manualInterval); @@ -1256,7 +1297,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { }); // Update chart with historical data - chart.update('quiet'); + safeChartUpdate(chart, 'quiet', 'historical data load'); // Update data points counter const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0); @@ -1277,7 +1318,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { if (rt) { rt.pause = false; } - chart.update('none'); + safeChartUpdate(chart, 'none', 'resume streaming'); } else { if (!sessionData.manualInterval) { startManualRefresh(); @@ -1545,7 +1586,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { zoomPlugin.zoom.pinch.enabled = isZoomEnabled; // Update the chart to apply the new configuration - chart.update('none'); + safeChartUpdate(chart, 'none', 'zoom configuration'); console.log(`🔍 Zoom/Pan ${isZoomEnabled ? 'enabled' : 'disabled'}`); } @@ -1878,6 +1919,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => { { + // Use timestamp + random number for uniqueness across tabs/browsers + return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + }) + const [session, setSession] = useState({ session_id: plotDefinition.id, name: plotDefinition.name, @@ -133,9 +139,13 @@ export default function PlotRealtimeSession({ const muted = useColorModeValue('gray.600', 'gray.300') const settingsBg = useColorModeValue('gray.50', 'gray.600') + // State for the actual session ID returned from backend + const [actualSessionId, setActualSessionId] = useState(null) + // Enhanced session object for ChartjsPlot - memoized to prevent recreations const enhancedSession = useMemo(() => ({ - session_id: plotDefinition.id, + session_id: actualSessionId || plotDefinition.id, // Use actual session ID when available + plot_id: plotDefinition.id, // Keep original plot ID for reference name: plotDefinition.name, is_active: session.is_active, is_paused: session.is_paused, @@ -150,6 +160,7 @@ export default function PlotRealtimeSession({ chartControlsRef.current = controls } }), [ + actualSessionId, // Include actualSessionId in dependencies plotDefinition.id, plotDefinition.name, plotDefinition, @@ -162,8 +173,14 @@ export default function PlotRealtimeSession({ // Load session status from backend (optional - session may not exist until started) const refreshSessionStatus = useCallback(async () => { + // Only refresh if we have an actual session ID + if (!actualSessionId) { + // No session yet - this is expected for new instances + return + } + try { - const response = await api.getPlotSession(plotDefinition.id) + const response = await api.getPlotSession(actualSessionId) if (response?.config) { setSession(prev => ({ ...prev, @@ -172,16 +189,21 @@ export default function PlotRealtimeSession({ })) } } catch (error) { - // Session may not exist in backend yet + // Session may not exist in backend yet or may have been cleaned up if (error.message.includes('404')) { - // Try to create the session automatically - await createPlotSessionFromConfig() + // Reset actual session ID - will create new one when needed + setActualSessionId(null) + setSession(prev => ({ + ...prev, + is_active: false, + is_paused: false + })) } else { - // Backend not available - use local state silently - // This allows the component to work even when backend is offline + // Backend not available - maintain current state + console.log('Backend not available for session status check') } } - }, [plotDefinition.id]) + }, [actualSessionId]) // Create plot session in backend based on static configuration const createPlotSessionFromConfig = useCallback(async () => { @@ -191,6 +213,7 @@ export default function PlotRealtimeSession({ const plotConfig = { id: plotDefinition.id, // Use id instead of session_id + browser_tab_id: browserTabId, // Include unique tab identifier name: plotDefinition.name, variables: variableNames, time_window: plotDefinition.time_window || 60, @@ -198,17 +221,24 @@ export default function PlotRealtimeSession({ trigger_variable: plotDefinition.trigger_variable, trigger_on_true: plotDefinition.trigger_on_true || true, y_min: plotDefinition.y_min, - y_max: plotDefinition.y_max + y_max: plotDefinition.y_max, + allow_multiple: true // Always allow multiple instances } // Create the plot session - await api.createPlot(plotConfig) + const result = await api.createPlot(plotConfig) - console.log(`✅ Created plot session: ${plotDefinition.id}`) + // Store the actual session ID returned by the backend + if (result?.session_id) { + setActualSessionId(result.session_id) + console.log(`✅ Created plot session: ${result.session_id} for plot: ${plotDefinition.id} (tab: ${browserTabId})`) + } else { + console.log(`✅ Created plot session: ${plotDefinition.id} (tab: ${browserTabId})`) + } } catch (error) { - console.warn(`Could not create plot session ${plotDefinition.id}:`, error) + console.warn(`Could not create plot session ${plotDefinition.id} (tab: ${browserTabId}):`, error) } - }, [plotDefinition, plotVariables]) + }, [plotDefinition, plotVariables, browserTabId]) // Control plot session (start, pause, stop, clear) const handleControlClick = useCallback(async (action) => { @@ -218,8 +248,9 @@ export default function PlotRealtimeSession({ if (action === 'start') { try { // Try to create the plot session with current configuration - await api.createPlot({ + const result = await api.createPlot({ id: plotDefinition.id, // Use id instead of session_id + browser_tab_id: browserTabId, // Include unique tab identifier name: plotDefinition.name, variables: plotVariables.map(v => v.variable_name), // Simplified format time_window: localConfig.time_window, @@ -227,26 +258,38 @@ export default function PlotRealtimeSession({ trigger_variable: localConfig.trigger_variable, trigger_on_true: localConfig.trigger_on_true, y_min: localConfig.y_min, - y_max: localConfig.y_max + y_max: localConfig.y_max, + allow_multiple: true // Always allow multiple instances }) + // Store the actual session ID if returned + if (result?.session_id) { + setActualSessionId(result.session_id) + } } catch (createError) { // Plot may already exist, that's OK console.log('Plot session may already exist:', createError.message) } } + // Use the actual session ID if available, fallback to plot ID + const sessionIdToUse = actualSessionId || plotDefinition.id + // Send control command to backend - await api.controlPlotSession(plotDefinition.id, action) + await api.controlPlotSession(sessionIdToUse, action) // For 'start' action, verify that the session is actually active if (action === 'start') { // Wait a bit and verify the session started await new Promise(resolve => setTimeout(resolve, 300)) - const verifyResponse = await api.getPlotSession(plotDefinition.id) + const verifyResponse = await api.getPlotSession(sessionIdToUse) if (!verifyResponse?.config?.is_active) { // Try the control command once more if not active console.log('Session not active, retrying control command...') - await api.controlPlotSession(plotDefinition.id, action) + await api.controlPlotSession(sessionIdToUse, action) + } + // Update session ID if different + if (verifyResponse?.config?.session_id && verifyResponse.config.session_id !== plotDefinition.id) { + setActualSessionId(verifyResponse.config.session_id) } } @@ -446,17 +489,20 @@ export default function PlotRealtimeSession({ } }, [plotDefinition.id, refreshSessionStatus, handleControlClick, session.is_active, session.is_paused, onReloadConfig, toast]) - // Auto-refresh session status + // Auto-refresh session status only if we have a session useEffect(() => { - // Try to get session status first, if it fails, create the session - refreshSessionStatus() - intervalRef.current = setInterval(refreshSessionStatus, 5000) + // Only start auto-refresh if we have an actual session ID + if (actualSessionId) { + refreshSessionStatus() + intervalRef.current = setInterval(refreshSessionStatus, 5000) + } + return () => { if (intervalRef.current) { clearInterval(intervalRef.current) } } - }, [refreshSessionStatus]) + }, [actualSessionId, refreshSessionStatus]) return ( @@ -476,6 +522,12 @@ export default function PlotRealtimeSession({ <> | Trigger: {localConfig.trigger_variable} )} + + Tab: {browserTabId} + {actualSessionId && actualSessionId !== plotDefinition.id && ( + <> | Session: {actualSessionId.substring(0, 40)}... + )} + diff --git a/main.py b/main.py index 6ca8607..0080caf 100644 --- a/main.py +++ b/main.py @@ -1605,7 +1605,10 @@ def create_plot(): if config["y_max"] is not None: config["y_max"] = float(config["y_max"]) - session_id = streamer.data_streamer.plot_manager.create_session(config) + # 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) return jsonify( { @@ -2152,6 +2155,47 @@ def get_historical_data(): return jsonify({"error": f"Internal server error: {str(e)}"}), 500 +@app.route("/api/plots/sessions/", methods=["GET"]) +def get_plot_sessions(plot_id): + """Get all session IDs for a specific plot ID""" + error_response = check_streamer_initialized() + if error_response: + 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) + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/plots/cleanup", methods=["POST"]) +def cleanup_plot_sessions(): + """Manually cleanup inactive plot sessions""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + 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" + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/api/status") def get_status(): """Get current status""" diff --git a/system_state.json b/system_state.json index a3144e9..a226ca9 100644 --- a/system_state.json +++ b/system_state.json @@ -3,11 +3,11 @@ "should_connect": true, "should_stream": false, "active_datasets": [ - "Fast", "DAR", + "Fast", "Test" ] }, "auto_recovery_enabled": true, - "last_update": "2025-08-15T15:18:52.911110" + "last_update": "2025-08-15T16:50:35.341984" } \ No newline at end of file