Update system state configuration, add cursor ignore file, and implement PlotRealtimeViewer component for real-time plot management

This commit is contained in:
Miguel 2025-08-13 15:15:25 +02:00
parent 771cf6cba6
commit 972a965335
17 changed files with 1503 additions and 54239 deletions

3
.cursorignore Normal file
View File

@ -0,0 +1,3 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
*.csv

View File

@ -1,3 +1,28 @@
2025-08-13
- Solicitud (resumen): Integrar visualización de plots en tiempo real directamente en `Dashboard.jsx`, debajo de la gestión de plots, para tener en un solo lugar la configuración, creación/modificación de variables, creación/modificación de plots y visualización.
- Decisiones y cambios:
- Se creó el componente `frontend/src/components/PlotRealtimeViewer.jsx` que lista todas las sesiones de plots del backend (`/api/plots`) y renderiza un `ChartjsPlot` por sesión, con controles locales y de backend (start/pause/stop/clear y refresh).
- Se integró en `frontend/src/pages/Dashboard.jsx` debajo de `PlotCompleteManager`, con el título “Real-time plots”, cumpliendo la visualización embebida dentro del dashboard.
- `PlotRealtimeViewer` refresca estados periódicamente y usa `/api/plots/{session_id}/config` para sincronizar estado/pausa. `ChartjsPlot` ya gestiona la carga de config y streaming desde `/api/plots/{session_id}/data`.
- Notas técnicas relevantes:
- No se tocó el backend; se reutilizan los endpoints existentes (`/api/plots`, `/api/plots/{id}/config`, `/api/plots/{id}/control`, `/api/plots/{id}/data`).
- La gestión de definiciones/variables de plots sigue en `PlotCompleteManager`; al guardar, el backend recarga las sesiones. `PlotRealtimeViewer` refresca para reflejar los cambios.
- Fix: error Chart.js "Cannot read properties of undefined (reading 'handleEvent')".
- `frontend/src/components/ChartjsPlot.jsx` ahora:
- Solo aplica opciones de zoom si el plugin está registrado; intenta registrar con varias claves globales si existe.
- Pausa el scale realtime y limpia intervalos antes de destruir el chart, para evitar ciclos RAF del plugin streaming tras unmount.
- En modo fallback (scale `time`), desactiva explícitamente el plugin realtime en `options.plugins.realtime = false`.
- Fix control de tiempo real en Dashboard (gráficos siempre activos / botones sin efecto):
- Problema: los charts iniciaban activos y los botones Start/Pause no surtían efecto (solo Clear funcionaba).
- Causas: desincronización entre `pause` del scale realtime y la ingesta local; el efecto que escucha `session.is_active/is_paused` revertía de inmediato las acciones locales.
- Cambios: en `frontend/src/components/ChartjsPlot.jsx` se sincronizan `ingestPaused` e `isPaused` con el estado inicial del chart (`pause: !session.is_active || session.is_paused`). Se añade ventana de override temporal (`userOverrideUntil` 3s) tras Start/Pause para evitar que el efecto de sincronización con backend revierta inmediatamente la acción del usuario.
- Impacto: los gráficos ahora respetan el estado inicial detenido cuando corresponde; los botones Start/Pause/Stop funcionan sin reanudaciones/pausas espurias.
# Memoria de Evolución del Proyecto
## Sistema de Tablas Editables para Datasets y Plots (10/08/2025)
@ -651,3 +676,20 @@ ChartjsPlot render → Chart.js integration → streaming setup
- 🐛 **Error isolation**: Separación clara entre errores de red, backend y frontend
- 📋 **User feedback**: Notificaciones claras de problemas y estado del sistema
- ⚡ **Graceful handling**: Sistema continúa funcionando aunque algunos servicios fallen
## 2025-08-13 (tarde)
- Solicitud (resumen): "Los datasets que leen @dataset_definitions.json no están mostrando el valor real de Dataset Enabled en @Dashboard.jsx".
- Decisiones y cambios:
- Se añadió una insignia de estado en `frontend/src/components/FormTable.jsx` que muestra "Enabled/Disabled" en cada tarjeta de item cuando la data tiene `enabled: boolean`. Esto asegura que, incluso en modo readonly, el estado actual del dataset se vea claramente conforme al JSON `config/data/dataset_definitions.json`.
- Conocimientos clave:
- La UI de datasets usa `FormTable` con `schema.additionalProperties` y `uiSchema` específicos; la propiedad `enabled` se edita con widget checkbox según `dataset-definitions.uischema.json`.
- El backend calcula `active_datasets` automáticamente a partir de `datasets[].enabled` y persiste en `dataset_definitions.json` sin campos estáticos, por lo que `enabled` es la fuente de verdad.
- Mostrar la insignia evita confusión cuando el formulario está en modo solo lectura o cuando hay latencia entre guardado y recarga.
- Fix adicional: valores que se reseteaban al cambiar de campo en edición (RJSF)
- Causa: El `Form` usaba `formData={data[key]}` y no controlábamos `onChange` en modo edición, por lo que cualquier re-render restauraba el valor original.
- Solución: `FormTable.jsx` ahora usa un estado local `editingFormData` cuando `editingKey === key`. Se inicializa al pulsar Edit, `onChange` actualiza `editingFormData`, y `formData` del `Form` se alimenta de ese estado hasta guardar o cancelar.
- Impacto: Al editar un item, los cambios entre campos se mantienen correctamente hasta pulsar Save.

View File

@ -9556,8 +9556,871 @@
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T10:04:01.510701",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T10:04:01.523388",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T10:04:01.534388",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T10:04:01.547346",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 2 datasets",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": true,
"recording_datasets": 2,
"dataset_names": [
"DAR",
"Fast"
]
}
},
{
"timestamp": "2025-08-13T10:04:50.156461",
"level": "info",
"event_type": "udp_streaming_started",
"message": "UDP streaming to PlotJuggler started",
"details": {
"udp_host": "127.0.0.1",
"udp_port": 9870,
"datasets_available": 2
}
},
{
"timestamp": "2025-08-13T11:30:17.872774",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T11:30:17.937806",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T11:30:17.951867",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T11:30:17.963808",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T11:30:17.977806",
"level": "info",
"event_type": "udp_streaming_started",
"message": "UDP streaming to PlotJuggler started",
"details": {
"udp_host": "127.0.0.1",
"udp_port": 9870,
"datasets_available": 2
}
},
{
"timestamp": "2025-08-13T11:36:04.553484",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T11:36:04.566484",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-08-13T11:36:05.479892",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "DAR"
}
},
{
"timestamp": "2025-08-13T11:36:05.585651",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: Fast",
"details": {
"dataset_id": "Fast"
}
},
{
"timestamp": "2025-08-13T11:36:05.601680",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T11:36:07.028566",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T11:36:07.043893",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T11:36:07.060471",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T11:36:07.073719",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 2 datasets",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": true,
"recording_datasets": 2,
"dataset_names": [
"DAR",
"Fast"
]
}
},
{
"timestamp": "2025-08-13T11:38:47.160563",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T11:38:47.227567",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T11:38:47.241129",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T11:38:47.251583",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T11:43:03.461288",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T11:43:03.511481",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T11:43:03.527332",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T11:43:03.539480",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:24:56.257638",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:24:56.400551",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:24:56.415147",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:24:56.428513",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:47:21.309062",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:47:21.393140",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:47:21.409313",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:47:21.420314",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:48:30.992091",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:48:31.058240",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:48:31.069240",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:48:31.081242",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:49:20.228991",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:49:20.293590",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:49:20.306683",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:49:20.318681",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:50:57.575176",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:50:57.642620",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:50:57.661324",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:50:57.674356",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:55:34.864405",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:55:34.944688",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T13:55:34.961689",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T13:55:34.974937",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T13:55:38.731880",
"level": "error",
"event_type": "dataset_loop_error",
"message": "Multiple consecutive read failures for dataset 'Fast' (5). Stopping dataset.",
"details": {
"dataset_id": "Fast",
"consecutive_errors": 5
}
},
{
"timestamp": "2025-08-13T13:55:42.951679",
"level": "error",
"event_type": "dataset_loop_error",
"message": "Multiple consecutive read failures for dataset 'DAR' (5). Stopping dataset.",
"details": {
"dataset_id": "DAR",
"consecutive_errors": 5
}
},
{
"timestamp": "2025-08-13T13:56:18.746882",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T13:56:18.811395",
"level": "error",
"event_type": "csv_recording_error",
"message": "Cannot start CSV recording: No datasets with variables configured",
"details": {}
},
{
"timestamp": "2025-08-13T13:56:39.664861",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T13:56:39.677858",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-08-13T13:56:39.690990",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: Fast",
"details": {
"dataset_id": "Fast"
}
},
{
"timestamp": "2025-08-13T13:56:39.704302",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "DAR"
}
},
{
"timestamp": "2025-08-13T13:56:39.718587",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T13:57:37.934920",
"level": "error",
"event_type": "csv_recording_error",
"message": "Cannot start CSV recording: No datasets with variables configured",
"details": {}
},
{
"timestamp": "2025-08-13T13:57:37.961461",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 (no datasets for recording)",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": false,
"recording_datasets": 0
}
},
{
"timestamp": "2025-08-13T14:02:07.164010",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T14:02:16.144763",
"level": "error",
"event_type": "csv_recording_error",
"message": "Cannot start CSV recording: No datasets with variables configured",
"details": {}
},
{
"timestamp": "2025-08-13T14:02:16.157356",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 (no datasets for recording)",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": false,
"recording_datasets": 0
}
},
{
"timestamp": "2025-08-13T14:07:23.248088",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T14:07:34.119374",
"level": "error",
"event_type": "csv_recording_error",
"message": "Cannot start CSV recording: No datasets with variables configured",
"details": {}
},
{
"timestamp": "2025-08-13T14:07:34.131666",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 (no datasets for recording)",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": false,
"recording_datasets": 0
}
},
{
"timestamp": "2025-08-13T14:10:03.473372",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T14:10:10.286283",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T14:10:10.332149",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-08-13T14:10:10.360865",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "DAR"
}
},
{
"timestamp": "2025-08-13T14:10:10.381239",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: Fast",
"details": {
"dataset_id": "Fast"
}
},
{
"timestamp": "2025-08-13T14:10:10.405727",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T14:10:14.866208",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T14:10:14.881834",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T14:10:14.895847",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T14:10:14.915075",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 2 datasets",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"auto_started_recording": true,
"recording_datasets": 2,
"dataset_names": [
"DAR",
"Fast"
]
}
},
{
"timestamp": "2025-08-13T14:14:02.563422",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T14:14:02.648401",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-13T14:14:02.666461",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-13T14:14:02.681756",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T14:54:09.121582",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T14:54:09.135579",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-08-13T14:54:09.381016",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "DAR"
}
},
{
"timestamp": "2025-08-13T14:54:09.738004",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: Fast",
"details": {
"dataset_id": "Fast"
}
},
{
"timestamp": "2025-08-13T14:54:09.753196",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-08-13T15:03:37.721996",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
}
],
"last_updated": "2025-08-13T00:19:23.495379",
"total_entries": 899
"last_updated": "2025-08-13T15:03:37.721996",
"total_entries": 987
}

View File

@ -2,17 +2,17 @@
"datasets": {
"DAR": {
"created": "2025-08-08T15:47:18.566053",
"enabled": true,
"enabled": false,
"name": "DAR",
"prefix": "gateway_phoenix",
"sampling_interval": 1
},
"Fast": {
"created": "2025-08-09T02:06:26.840011",
"enabled": true,
"enabled": false,
"name": "Fast",
"prefix": "fast",
"sampling_interval": 0.1
"sampling_interval": 0.6
}
}
}

View File

@ -1,51 +1,11 @@
{
"dataset_variables": {
"DAR": {
"variables": {
"UR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"streaming": true,
"type": "real"
},
"UR29_ma": {
"area": "db",
"db": 1011,
"offset": 1296,
"streaming": true,
"type": "real"
},
"fUR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"streaming": false,
"type": "real"
}
},
"streaming_variables": [
"UR29_Brix",
"UR29_ma"
]
"variables": {},
"streaming_variables": []
},
"Fast": {
"variables": {
"fUR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"streaming": false,
"type": "real"
},
"fUR29_ma": {
"area": "db",
"db": 1011,
"offset": 1296,
"streaming": false,
"type": "real"
}
},
"variables": {},
"streaming_variables": []
}
}

View File

@ -1,23 +1,31 @@
{
"csv_config": {
"cleanup_interval_hours": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:column": 3
},
"last_cleanup": {
"ui:column": 3
},
"last_cleanup": {},
"max_days": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:column": 2
},
"max_hours": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:column": 2
},
"max_size_mb": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:column": 2
},
"records_directory": {
"ui:placeholder": "records"
"ui:placeholder": "records",
"ui:column": 10
},
"rotation_enabled": {
"ui:widget": "checkbox"
"ui:widget": "checkbox",
"ui:column": 2
},
"ui:layout": [
[
@ -25,10 +33,6 @@
"name": "cleanup_interval_hours",
"width": 3
},
{
"name": "last_cleanup",
"width": 3
},
{
"name": "max_days",
"width": 2
@ -40,16 +44,20 @@
{
"name": "max_size_mb",
"width": 2
},
{
"name": "last_cleanup",
"width": 3
}
],
[
{
"name": "records_directory",
"width": 10
},
{
"name": "rotation_enabled",
"width": 2
},
{
"name": "records_directory",
"width": 10
}
]
],
@ -61,20 +69,21 @@
"max_size_mb",
"records_directory",
"rotation_enabled"
]
],
"ui:column": 12
},
"plc_config": {
"ip": {
"ui:placeholder": "192.168.1.100",
"ui:column": 6
"ui:column": 6,
"ui:placeholder": "192.168.1.100"
},
"rack": {
"ui:widget": "updown",
"ui:column": 3
"ui:column": 3,
"ui:widget": "updown"
},
"slot": {
"ui:widget": "updown",
"ui:column": 3
"ui:column": 3,
"ui:widget": "updown"
},
"ui:layout": [
[
@ -96,10 +105,12 @@
"ip",
"rack",
"slot"
]
],
"ui:column": 6
},
"sampling_interval": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:column": 2
},
"udp_config": {
"host": {
@ -123,8 +134,15 @@
"ui:order": [
"host",
"port"
]
],
"ui:column": 4
},
"ui:order": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
],
"ui:layout": [
[
{
@ -133,24 +151,18 @@
},
{
"name": "udp_config",
"width": 6
}
],
[
{
"name": "csv_config",
"width": 10
"width": 4
},
{
"name": "sampling_interval",
"width": 2
}
],
[
{
"name": "csv_config",
"width": 12
}
]
],
"ui:order": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
]
}

View File

@ -13,7 +13,9 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
insertNaNOnNextIngest: false,
isPaused: false,
isRealTimeMode: true,
refreshRate: 1000
refreshRate: 1000,
userOverrideUntil: 0,
userPaused: false
});
const [isLoading, setIsLoading] = useState(true);
@ -93,15 +95,32 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const Chart = window.Chart;
// Register zoom plugin if available
// Ensure zoom plugin is registered only if available to avoid plugin errors
let zoomAvailable = false;
try {
if (window.ChartZoom && !Chart.registry.plugins.get('zoom')) {
Chart.register(window.ChartZoom);
if (Chart.registry?.plugins?.get?.('zoom')) {
zoomAvailable = true;
} else {
const maybeZoom = (window.ChartZoom || window.chartjsPluginZoom || window.chartjs_plugin_zoom || window.chartjsPluginZoomDefault || (window['chartjs-plugin-zoom'] && window['chartjs-plugin-zoom'].default));
if (maybeZoom) {
Chart.register(maybeZoom);
zoomAvailable = true;
}
}
} catch (e) {
console.warn('⚠️ Zoom plugin registration failed:', e);
}
// Ensure realtime scale is available (no fallback)
try {
const hasRealtimeScale = !!(Chart.registry?.scales?.get && Chart.registry.scales.get('realtime'));
if (!hasRealtimeScale) {
throw new Error('chartjs-plugin-streaming (realtime scale) not loaded');
}
} catch (e) {
throw new Error('Realtime scale not available. Ensure chartjs-plugin-streaming v2.x is loaded after Chart.js.');
}
const ctx = canvasRef.current.getContext('2d');
// Destroy existing chart
@ -135,6 +154,9 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionDataRef.current.datasetIndex.set(variableInfo.name, index);
});
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 },
@ -181,8 +203,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
display: true,
text: 'Valor'
},
min: config.y_min,
max: config.y_max
min: yMinInitial,
max: yMaxInitial
}
},
plugins: {
@ -194,17 +216,13 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
mode: 'index',
intersect: false
},
zoom: {
pan: {
enabled: true,
mode: 'x'
},
...(zoomAvailable ? {
zoom: {
pinch: { enabled: true },
wheel: { enabled: true },
mode: 'x'
// Evita listeners wheel/touch no-passive del plugin; usa drag + pan con modificador
pan: { enabled: true, mode: 'x', modifierKey: 'shift' },
zoom: { drag: { enabled: true }, wheel: { enabled: false }, pinch: { enabled: false }, mode: 'x' }
}
}
} : {})
},
interaction: {
mode: 'nearest',
@ -224,40 +242,14 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
}
};
// Try to create chart with realtime scale
try {
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = true;
console.log(`✅ Plot ${session.session_id}: Real-time Streaming enabled`);
} catch (e) {
console.warn(`⚠️ Plot ${session.session_id}: Real-time scale not available. Falling back to time scale.`, e);
// Fallback configuration without realtime
chartConfig.options.scales.x = {
type: 'time',
time: {
unit: 'second',
displayFormats: {
second: 'HH:mm:ss'
}
},
ticks: {
display: true,
autoSkip: true,
maxRotation: 0
},
title: {
display: true,
text: 'Tiempo'
}
};
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = false;
// Start manual refresh for fallback mode
startManualRefresh();
}
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = true;
sessionDataRef.current.noDataCycles = 0;
// Sync ingest pause state with initial chart pause
const initialPaused = !session.is_active || session.is_paused;
sessionDataRef.current.ingestPaused = initialPaused;
sessionDataRef.current.isPaused = initialPaused;
console.log(`✅ Plot ${session.session_id}: Real-time Streaming enabled`);
setIsLoading(false);
setError(null);
@ -290,22 +282,65 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const plotData = await response.json();
// Add new data to chart
addNewDataToStreaming(plotData, now);
const pointsAdded = addNewDataToStreaming(plotData, now);
updatePointsCounter(plotData);
// Auto-pause when no data arrives for several cycles; resume when data appears
if (pointsAdded > 0) {
sessionDataRef.current.noDataCycles = 0;
// Do not auto-resume if user explicitly paused
if (sessionDataRef.current.isPaused && !sessionDataRef.current.userPaused) {
const rt = chart.options?.scales?.x?.realtime;
if (rt) rt.pause = false;
chart.update('none');
sessionDataRef.current.isPaused = false;
sessionDataRef.current.ingestPaused = false;
}
} else {
sessionDataRef.current.noDataCycles = (sessionDataRef.current.noDataCycles || 0) + 1;
if (sessionDataRef.current.noDataCycles >= 3) {
const rt = chart.options?.scales?.x?.realtime;
if (rt) rt.pause = true;
chart.update('none');
sessionDataRef.current.isPaused = true;
sessionDataRef.current.ingestPaused = true;
}
}
} catch (error) {
console.error(`📈 Error in streaming refresh for ${session.session_id}:`, error);
}
}, [session?.session_id]);
const addNewDataToStreaming = useCallback((plotData, timestamp) => {
if (!chartRef.current || !plotData) return;
if (!chartRef.current || !plotData) return 0;
const chart = chartRef.current;
const sessionData = sessionDataRef.current;
// Check if paused
if (sessionData.ingestPaused) return;
if (sessionData.ingestPaused) return 0;
// Helpers to parse flexible payloads
const getYValue = (point) => {
const candidate = (point?.y !== undefined) ? point.y : (point?.value !== undefined ? point.value : point?.v);
const yNum = typeof candidate === 'number' ? candidate : Number(candidate);
return Number.isFinite(yNum) ? yNum : null;
};
const getXValueMs = (point) => {
let raw = (point?.x !== undefined) ? point.x : (point?.ts !== undefined ? point.ts : (point?.timestamp !== undefined ? point.timestamp : point?.t));
if (typeof raw === 'string') {
const asNum = Number(raw);
if (Number.isFinite(asNum)) raw = asNum; else {
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) raw = parsed; else return null;
}
}
if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
// Normalize seconds to milliseconds
const xMs = raw < 1e12 ? raw * 1000 : raw;
return xMs;
};
let pointsAdded = 0;
chart.data.datasets.forEach((chartDataset, datasetIndex) => {
@ -320,12 +355,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const newPoints = [];
for (let i = 0; i < backendDataset.data.length; i++) {
const p = backendDataset.data[i];
const yNum = typeof p.y === 'number' ? p.y : Number(p.y);
if (!isFinite(yNum)) continue;
const yNum = getYValue(p);
if (yNum === null) continue;
let xNum = typeof p.x === 'number' ? p.x : Number(p.x);
if (!isFinite(xNum)) continue;
if (xNum < 1e12) xNum = xNum * 1000; // seconds -> ms
const xNum = getXValueMs(p);
if (xNum === null) continue;
if (xNum > lastPushedX) newPoints.push({ x: xNum, y: yNum });
}
@ -356,10 +390,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
});
// Update chart
if (!sessionData.isRealTimeMode) {
cleanupOldDataFallback();
chart.update('quiet');
} else if (pointsAdded > 0) {
if (pointsAdded > 0) {
chart.update('quiet');
}
@ -367,22 +398,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (pointsAdded > 0 && sessionData.insertNaNOnNextIngest) {
sessionData.insertNaNOnNextIngest = false;
}
return pointsAdded;
}, []);
const cleanupOldDataFallback = useCallback(() => {
if (!chartRef.current || !session?.config) return;
const chart = chartRef.current;
const timeWindow = (session.config.time_window || 60) * 1000;
const now = Date.now();
const cutoffTime = now - timeWindow;
chart.data.datasets.forEach((dataset) => {
if (dataset.data && dataset.data.length > 0) {
dataset.data = dataset.data.filter(point => point.x > cutoffTime);
}
});
}, [session?.config]);
// Fallback cleanup removed per requirement: always realtime; we pause instead when no data
const updatePointsCounter = useCallback((plotData) => {
const totalPoints = plotData.data_points_count || 0;
@ -410,11 +429,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = true;
const rt = chart.options?.scales?.x?.realtime;
if (rt) {
rt.pause = true;
}
chart.update('quiet');
chart.update('none');
} else {
if (sessionData.manualInterval) {
clearInterval(sessionData.manualInterval);
@ -424,6 +443,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionData.ingestPaused = true;
sessionData.isPaused = true;
sessionData.userOverrideUntil = Date.now() + 3000;
}, []);
const resumeStreaming = useCallback(() => {
@ -432,11 +452,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = false;
const rt = chart.options?.scales?.x?.realtime;
if (rt) {
rt.pause = false;
}
chart.update('quiet');
chart.update('none');
} else {
if (!sessionData.manualInterval) {
startManualRefresh();
@ -446,6 +466,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionData.insertNaNOnNextIngest = true;
sessionData.ingestPaused = false;
sessionData.isPaused = false;
sessionData.userOverrideUntil = Date.now() + 3000;
}, [startManualRefresh]);
const clearChart = useCallback(() => {
@ -476,11 +497,15 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Update chart when session status changes
useEffect(() => {
if (!chartRef.current || !session) return;
// Respect short user override window to avoid flapping when backend state is stale
if (Date.now() < (sessionDataRef.current.userOverrideUntil || 0)) return;
const shouldPause = !session.is_active || session.is_paused;
if (shouldPause) {
sessionDataRef.current.userPaused = true;
pauseStreaming();
} else {
sessionDataRef.current.userPaused = false;
resumeStreaming();
}
}, [session?.is_active, session?.is_paused, pauseStreaming, resumeStreaming]);
@ -533,10 +558,17 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
createStreamingChart();
}
return () => {
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
try {
if (chartRef.current) {
const rt = chartRef.current.options?.scales?.x?.realtime;
if (rt) {
rt.pause = true;
chartRef.current.update('none');
}
chartRef.current.destroy();
chartRef.current = null;
}
} catch { /* ignore */ }
if (sessionDataRef.current.manualInterval) {
clearInterval(sessionDataRef.current.manualInterval);
}
@ -597,7 +629,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
style={{
width: '100%',
height: '100%',
borderRadius: '6px'
borderRadius: '6px',
touchAction: 'none',
WebkitUserSelect: 'none',
userSelect: 'none'
}}
/>
<Box

View File

@ -17,13 +17,13 @@ import {
} from '@chakra-ui/react'
// No necesitamos Form completo, solo FormTable
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
import { getSchema, readConfig, writeConfig, activateDataset, deactivateDataset } from '../services/api.js'
/**
* DatasetCompleteManager - Gestiona datasets y variables de forma simplificada
* Incluye: tabla de datasets individuales + variables (sin campos estáticos de configuración)
*/
export default function DatasetCompleteManager() {
export default function DatasetCompleteManager({ status }) {
const [fullData, setFullData] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
@ -38,11 +38,55 @@ export default function DatasetCompleteManager() {
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
const [liveValues, setLiveValues] = useState({})
const sseRef = React.useRef(null)
useEffect(() => {
loadData()
}, [])
// Subscribe to SSE for live variable values of the selected dataset
useEffect(() => {
// Close previous stream
if (sseRef.current) {
try { sseRef.current.close() } catch { /* ignore */ }
sseRef.current = null
}
// Only stream when online and dataset selected
const plcConnected = !!status?.plc_connected
if (!plcConnected || !selectedDatasetId) {
setLiveValues({})
return
}
try {
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0`)
sseRef.current = es
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.type === 'values' && payload.values) {
setLiveValues(payload.values || {})
} else if (payload?.type === 'no_cache' || payload?.type === 'dataset_inactive' || payload?.type === 'plc_disconnected') {
setLiveValues({})
}
} catch { /* ignore */ }
}
es.onerror = () => {
try { es.close() } catch { /* ignore */ }
sseRef.current = null
}
} catch { /* ignore */ }
return () => {
if (sseRef.current) {
try { sseRef.current.close() } catch { /* ignore */ }
sseRef.current = null
}
}
}, [status?.plc_connected, selectedDatasetId])
const loadData = async () => {
setLoading(true)
try {
@ -66,7 +110,8 @@ export default function DatasetCompleteManager() {
const variableSchemaPath = variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables?.additionalProperties
const variableUiSchemaPath = variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables?.additionalProperties
setVariableSchema(variableSchemaPath)
// FormTable requiere un schema con additionalProperties en la raíz
setVariableSchema(variableSchemaPath ? { additionalProperties: variableSchemaPath } : null)
setVariableUiSchema(variableUiSchemaPath || {})
setFullData(datasetDataResp.data || {})
@ -106,11 +151,30 @@ export default function DatasetCompleteManager() {
const saveDatasets = async (newDatasets) => {
try {
// Solo enviar datasets, el backend calcula active_datasets automáticamente
const newFullData = {
datasets: newDatasets
// Detectar cambios en "enabled" para llamar a endpoints de activación, evitando estados inconsistentes
const prev = fullData.datasets || {}
const changedIds = []
for (const [id, cfg] of Object.entries(newDatasets)) {
const before = prev[id]?.enabled === true
const after = cfg?.enabled === true
if (before !== after) changedIds.push({ id, after })
}
// Persistir datasets primero
const newFullData = { datasets: newDatasets }
await saveFullData(newFullData)
// Aplicar activación/desactivación en backend para arrancar/parar hilos
await Promise.allSettled(changedIds.map(({ id, after }) => after ? activateDataset(id) : deactivateDataset(id)))
// Refrescar selección y datos locales tras cambios
setFullData(newFullData)
if (selectedDatasetId && !newDatasets[selectedDatasetId]) {
const ids = Object.keys(newDatasets)
setSelectedDatasetId(ids[0] || '')
}
setMessage('Datasets saved and activation applied')
setTimeout(() => setMessage(''), 2000)
} catch (error) {
console.error('Error saving datasets:', error)
setMessage(`Error saving datasets: ${error.message}`)
@ -231,6 +295,7 @@ export default function DatasetCompleteManager() {
onChange={saveDatasetVariables}
title={`Variables for: ${fullData.datasets?.[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
liveValues={liveValues}
/>
) : (
<Alert status="warning">

View File

@ -34,11 +34,13 @@ export default function FormTable({
title = "Data",
keyField = "id",
allowAdd = true,
allowDelete = true
allowDelete = true,
liveValues = null
}) {
const [editingKey, setEditingKey] = useState(null)
const [addingNew, setAddingNew] = useState(false)
const [newKey, setNewKey] = useState('')
const [editingFormData, setEditingFormData] = useState(null)
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
@ -173,6 +175,11 @@ export default function FormTable({
<HStack justify="space-between">
<HStack>
<Heading size="xs">{key}</Heading>
{data[key] && typeof data[key].enabled === 'boolean' && (
<Badge colorScheme={data[key].enabled ? 'green' : 'red'} size="sm">
{data[key].enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
{editingKey === key && (
<Badge colorScheme="orange" size="sm">Editing</Badge>
)}
@ -192,7 +199,7 @@ export default function FormTable({
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => setEditingKey(key)}
onClick={() => { setEditingFormData({ ...(data[key] || {}) }); setEditingKey(key) }}
/>
{allowDelete && (
<IconButton
@ -209,12 +216,19 @@ export default function FormTable({
</HStack>
</CardHeader>
<CardBody pt={0}>
{liveValues && liveValues[key] !== undefined && (
<Box mb={2}>
<Text fontSize="sm" color={muted}>
Live value: <Text as="span" fontWeight="semibold">{String(liveValues[key])}</Text>
</Text>
</Box>
)}
<Form
schema={itemSchema}
uiSchema={itemUiSchema}
formData={data[key] || {}}
formData={editingKey === key ? (editingFormData || {}) : (data[key] || {})}
validator={validator}
onChange={editingKey === key ? undefined : () => { }}
onChange={editingKey === key ? ({ formData }) => setEditingFormData(formData) : () => { }}
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
@ -226,7 +240,7 @@ export default function FormTable({
<Button type="submit" size="sm" colorScheme="blue">
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingKey(null)}>
<Button size="sm" variant="ghost" onClick={() => { setEditingKey(null); setEditingFormData(null) }}>
Cancel
</Button>
</HStack>

View File

@ -0,0 +1,215 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Box,
VStack,
HStack,
Text,
Button,
Card,
CardBody,
CardHeader,
Heading,
useColorModeValue,
Badge,
IconButton,
Divider,
Spacer,
} from '@chakra-ui/react'
import { EditIcon, SettingsIcon, DeleteIcon } from '@chakra-ui/icons'
import ChartjsPlot from './ChartjsPlot.jsx'
export default function PlotRealtimeViewer() {
const [sessions, setSessions] = useState(new Map())
const [loading, setLoading] = useState(false)
const intervalRef = useRef(null)
const muted = useColorModeValue('gray.600', 'gray.300')
const loadSessions = async () => {
try {
setLoading(true)
const res = await fetch('/api/plots')
const data = await res.json()
if (data && data.sessions) {
setSessions(prev => {
const next = new Map(prev)
const incomingIds = new Set()
for (const s of data.sessions) {
incomingIds.add(s.session_id)
const existing = next.get(s.session_id)
if (existing) {
// Mutate existing object to preserve reference
existing.name = s.name
existing.is_active = s.is_active
existing.is_paused = s.is_paused
existing.variables_count = s.variables_count
} else {
next.set(s.session_id, { ...s })
}
}
// Remove sessions not present anymore
for (const id of Array.from(next.keys())) {
if (!incomingIds.has(id)) next.delete(id)
}
return next
})
} else {
setSessions(new Map())
}
} catch {
setSessions(new Map())
} finally {
setLoading(false)
}
}
const refreshSession = async (sessionId) => {
try {
const res = await fetch(`/api/plots/${sessionId}/config`)
const data = await res.json()
if (data && data.success && data.config) {
setSessions(prev => {
const n = new Map(prev)
const existing = n.get(sessionId)
const varsCount = Array.isArray(data.config.variables)
? data.config.variables.length
: (data.config.variables ? Object.keys(data.config.variables).length : (existing?.variables_count || 0))
if (existing) {
existing.name = data.config.name
existing.is_active = data.config.is_active
existing.is_paused = data.config.is_paused
existing.variables_count = varsCount
} else {
n.set(sessionId, {
session_id: sessionId,
name: data.config.name,
is_active: data.config.is_active,
is_paused: data.config.is_paused,
variables_count: varsCount,
})
}
return n
})
}
} catch { /* ignore */ }
}
const controlSession = async (sessionId, action) => {
try {
await fetch(`/api/plots/${sessionId}/control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
await refreshSession(sessionId)
} catch { /* ignore */ }
}
useEffect(() => {
loadSessions()
intervalRef.current = setInterval(loadSessions, 5000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [])
const sessionsList = useMemo(() => Array.from(sessions.values()), [sessions])
if (loading && sessionsList.length === 0) {
return <Text color={muted}>Cargando sesiones de plots</Text>
}
if (sessionsList.length === 0) {
return (
<Card>
<CardBody>
<Text color={muted}>No hay sesiones de plot. Cree o edite plots en la sección superior.</Text>
</CardBody>
</Card>
)
}
return (
<VStack spacing={4} align="stretch">
{sessionsList.map((session) => (
<PlotRealtimeCard
key={session.session_id}
session={session}
onControl={controlSession}
onRefresh={refreshSession}
/>
))}
</VStack>
)
}
function PlotRealtimeCard({ session, onControl, onRefresh }) {
const cardBg = useColorModeValue('white', 'gray.700')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const muted = useColorModeValue('gray.600', 'gray.300')
const chartControlsRef = useRef(null)
const handleChartReady = (controls) => {
chartControlsRef.current = controls
}
const enhancedSession = {
...session,
onChartReady: handleChartReady,
}
const handleControlClick = async (action) => {
if (chartControlsRef.current) {
switch (action) {
case 'pause':
chartControlsRef.current.pauseStreaming()
break
case 'start':
case 'resume':
chartControlsRef.current.resumeStreaming()
break
case 'clear':
chartControlsRef.current.clearChart()
break
case 'stop':
chartControlsRef.current.pauseStreaming()
break
}
}
// No esperar a que el backend responda para aplicar efecto local
onControl(session.session_id, action)
}
return (
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<FlexHeader session={session} muted={muted} onRefresh={() => onRefresh(session.session_id)} />
</CardHeader>
<CardBody>
<ChartjsPlot session={enhancedSession} height="360px" />
<HStack mt={3} spacing={2}>
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green"> Start</Button>
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow"> Pause</Button>
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑 Clear</Button>
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red"> Stop</Button>
</HStack>
</CardBody>
</Card>
)
}
function FlexHeader({ session, muted, onRefresh }) {
return (
<HStack align="center">
<Box>
<Heading size="sm">📈 {session.name || session.session_id}</Heading>
<Text fontSize="sm" color={muted} mt={1}>
Variables: {session.variables_count || 0} | Status: <strong>{session.is_active ? (session.is_paused ? 'Paused' : 'Active') : 'Stopped'}</strong>
</Text>
</Box>
<Spacer />
<HStack>
<IconButton icon={<SettingsIcon />} size="sm" variant="outline" aria-label="Refresh status" onClick={onRefresh} />
</HStack>
</HStack>
)
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import {
FormControl, FormLabel, FormHelperText, Select, VStack, HStack,
Text, Badge, Box, Icon, Input, useColorModeValue, Spinner
@ -14,6 +14,9 @@ export function VariableSelectorWidget(props) {
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedDataset, setSelectedDataset] = useState('all')
const [liveValue, setLiveValue] = useState(undefined)
const [liveStatus, setLiveStatus] = useState('idle')
const esRef = useRef(null)
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
@ -63,6 +66,58 @@ export function VariableSelectorWidget(props) {
return variables
}, [datasetVariables])
// Subscribe to SSE for live value of selected variable
useEffect(() => {
// close previous stream
if (esRef.current) {
try { esRef.current.close() } catch { /* ignore */ }
esRef.current = null
}
const variable = value && allVariables.find(v => v.name === value)
const datasetId = variable?.dataset
if (!datasetId || !value) {
setLiveValue(undefined)
setLiveStatus('idle')
return
}
try {
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0`)
esRef.current = es
setLiveStatus('connecting')
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.type === 'values' && payload.values) {
setLiveValue(payload.values[value])
setLiveStatus('ok')
} else if (payload?.type === 'no_cache') {
setLiveStatus('waiting')
} else if (payload?.type === 'plc_disconnected' || payload?.type === 'dataset_inactive') {
setLiveValue(undefined)
setLiveStatus('offline')
}
} catch { /* ignore */ }
}
es.onerror = () => {
try { es.close() } catch { /* ignore */ }
esRef.current = null
setLiveStatus('error')
}
} catch {
setLiveStatus('error')
}
return () => {
if (esRef.current) {
try { esRef.current.close() } catch { /* ignore */ }
esRef.current = null
}
}
}, [value, allVariables])
// Filter variables based on search term and selected dataset
const filteredVariables = useMemo(() => {
let filtered = allVariables
@ -222,6 +277,11 @@ export function VariableSelectorWidget(props) {
PLC Address: {selectedVariable.area}{selectedVariable.db ? `${selectedVariable.db}.` : ''}{selectedVariable.offset}
{selectedVariable.streaming ? ' • Real-time streaming enabled' : ' • Static logging only'}
</Text>
<Text fontSize="sm">
Live value: {liveStatus === 'ok' && liveValue !== undefined ? (
<Text as="span" fontWeight="semibold">{String(liveValue)}</Text>
) : liveStatus === 'waiting' ? 'waiting…' : liveStatus === 'offline' ? 'offline' : liveStatus === 'error' ? 'error' : '—'}
</Text>
</VStack>
</Box>
)}

View File

@ -7,6 +7,7 @@ import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTempl
import { widgets } from '../components/rjsf/widgets.jsx'
import DatasetCompleteManager from '../components/DatasetCompleteManager.jsx'
import PlotCompleteManager from '../components/PlotCompleteManager.jsx'
import PlotRealtimeViewer from '../components/PlotRealtimeViewer.jsx'
import PLCConfigManager from '../components/PLCConfigManager.jsx'
import {
getStatus,
@ -240,7 +241,7 @@ export default function DashboardPage() {
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
</Flex>
<DatasetCompleteManager />
<DatasetCompleteManager status={status} />
</CardBody>
</Card>
)}
@ -253,6 +254,10 @@ export default function DashboardPage() {
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
</Flex>
<PlotCompleteManager />
<Box mt={4}>
<Text fontWeight="semibold" mb={2}>🔴 Real-time plots</Text>
<PlotRealtimeViewer />
</Box>
</CardBody>
</Card>
)}

View File

@ -604,8 +604,8 @@ function PlotSessionPanel({ session, onControl, onRemove, onEdit, onUpdateStatus
}
}
// Then handle backend control
await onControl(session.session_id, action)
// Then handle backend control (no await to avoid UI lag)
onControl(session.session_id, action)
}
const handleChartReady = (controls) => {

View File

@ -8,8 +8,26 @@ function toJsonOrThrow(res) {
}
export async function getStatus() {
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
try {
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
} catch (err) {
// Fallback: try health endpoint and synthesize a minimal status to keep UI usable
try {
const healthRes = await fetch(`${BASE_URL}/api/health`, { headers: { 'Accept': 'application/json' } })
if (healthRes.ok) {
const health = await healthRes.json()
return {
plc_connected: false,
streaming: false,
csv_recording: false,
disk_space_info: null,
health,
}
}
} catch { /* ignore */ }
throw err
}
}
export async function getEvents(limit = 50) {
@ -88,4 +106,21 @@ export async function putJson(path, body) {
return toJsonOrThrow(res)
}
// Datasets activation control
export async function activateDataset(datasetId) {
const res = await fetch(`${BASE_URL}/api/datasets/${encodeURIComponent(datasetId)}/activate`, {
method: 'POST',
headers: { 'Accept': 'application/json' },
})
return toJsonOrThrow(res)
}
export async function deactivateDataset(datasetId) {
const res = await fetch(`${BASE_URL}/api/datasets/${encodeURIComponent(datasetId)}/deactivate`, {
method: 'POST',
headers: { 'Accept': 'application/json' },
})
return toJsonOrThrow(res)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,9 @@
{
"last_state": {
"should_connect": true,
"should_connect": false,
"should_stream": false,
"active_datasets": [
"Fast",
"DAR"
]
"active_datasets": []
},
"auto_recovery_enabled": true,
"last_update": "2025-08-13T00:09:19.091418"
"last_update": "2025-08-13T14:54:09.753196"
}