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.
This commit is contained in:
Miguel 2025-08-15 16:56:02 +02:00
parent 405edd682e
commit e97cd5260b
9 changed files with 1010 additions and 61 deletions

View File

@ -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

75
MULTI_BROWSER_SUPPORT.md Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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"""

View File

@ -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' }) => {
<canvas
ref={canvasRef}
id={`chart-canvas-${session?.session_id || 'loading'}`}
style={{
width: '100%',
height: '100%',

View File

@ -54,6 +54,12 @@ export default function PlotRealtimeSession({
onConfigUpdate,
onReloadConfig // Nueva prop para recargar configuración desde backend
}) {
// Generate unique browser/tab identifier to avoid conflicts between multiple instances
const [browserTabId] = useState(() => {
// 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 (
<Card bg={cardBg} borderColor={borderColor} shadow="md">
@ -476,6 +522,12 @@ export default function PlotRealtimeSession({
<> | Trigger: {localConfig.trigger_variable}</>
)}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
Tab: {browserTabId}
{actualSessionId && actualSessionId !== plotDefinition.id && (
<> | Session: {actualSessionId.substring(0, 40)}...</>
)}
</Text>
</Box>
<Spacer />
<HStack>

46
main.py
View File

@ -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/<plot_id>", 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"""

View File

@ -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"
}