Compare commits

...

7 Commits

Author SHA1 Message Date
Miguel 04f5122cc6 Actualización de la gestión de eventos de aplicación en application_events.json, añadiendo múltiples entradas para registrar eventos de inicio y errores de conexión al PLC. Se ajustaron las fechas de última actualización y se incrementó el total de entradas. Además, se implementó un nuevo endpoint de verificación de salud en main.py, mejorando la monitorización del estado del sistema. Se realizaron mejoras en la interfaz de usuario y se optimizó la carga de datos en el Dashboard, asegurando una experiencia más fluida y consistente. 2025-08-13 00:21:44 +02:00
Miguel d31f0819e2 Actualización de la gestión de datasets y variables, eliminando campos estáticos innecesarios y optimizando la estructura de datos. Se implementó un nuevo formato para las variables de plot, permitiendo la configuración de colores y estado de habilitación. Además, se mejoró la interfaz de usuario con componentes RJSF y se ajustaron los esquemas de configuración para reflejar estos cambios. Se realizaron mejoras en la lógica de carga y guardado en los gestores de configuración y plots, asegurando una experiencia más fluida y consistente. 2025-08-12 23:41:16 +02:00
Miguel bacc9933b3 Actualización de la gestión de datasets y plots en el Dashboard, integrando nuevos componentes que respetan los layouts definidos en los schemas UI. Se mejoró la presentación de formularios y se implementaron logs de debugging para facilitar la verificación de esquemas. Además, se ajustaron los esquemas de configuración y se eliminaron campos estáticos innecesarios, optimizando la interfaz para el usuario. 2025-08-12 21:22:20 +02:00
Miguel 500b68c4d5 Actualización del Dashboard para integrar la gestión de datasets y plots mediante nuevos componentes. Se implementaron tablas editables para la visualización y manipulación de datos, mejorando la experiencia del usuario. Además, se realizaron ajustes en los esquemas de configuración y se optimizó la presentación de formularios, asegurando una interfaz más intuitiva y funcional. 2025-08-12 20:50:53 +02:00
Miguel 745af5fa1f Reorganización del renderizado en el Dashboard para priorizar la configuración del PLC sobre los datasets. Se implementó un orden preferido utilizando `useMemo` para mejorar la experiencia del usuario. Además, se realizaron ajustes en la detección automática de esquemas, eliminando referencias a esquemas obsoletos y optimizando la carga de secciones en la interfaz. 2025-08-12 19:30:54 +02:00
Miguel 724af8afdf Implementación de mejoras significativas en la gestión de datasets y variables, incluyendo la creación de nuevos componentes para DatasetManager y DatasetVariableManager. Se actualizaron los esquemas de configuración para mejorar la usabilidad, añadiendo descripciones y ayudas contextuales. Se integraron widgets personalizados de Chakra UI en formularios RJSF, optimizando la experiencia del usuario. Además, se realizaron ajustes en las rutas de navegación y se mejoró la consistencia visual en toda la aplicación. 2025-08-12 19:26:47 +02:00
Miguel 4d4df0830b Refactorización completa de la gestión de datasets y plots, separando las definiciones y variables en archivos distintos. Se eliminaron archivos obsoletos como plc_datasets.json y plot_sessions.json. Se implementó una migración automática desde los archivos legacy a los nuevos formatos, mejorando la organización y flexibilidad del sistema. Se actualizaron los esquemas de configuración y se ajustaron las funciones de carga y guardado en ConfigManager y PlotManager para trabajar con los nuevos archivos separados. Además, se realizaron mejoras en la interfaz de usuario para reflejar estos cambios y se optimizó el manejo de errores en la carga de datos. 2025-08-12 18:34:12 +02:00
52 changed files with 8961 additions and 1098 deletions

View File

@ -0,0 +1,653 @@
# Memoria de Evolución del Proyecto
## Sistema de Tablas Editables para Datasets y Plots (10/08/2025)
### Solicitud del Usuario
El usuario solicitó cambiar la presentación de los datasets de un formato de formulario a un formato de tabla para poder leer/escribir campos directamente. Quería:
- Tablas con botones de eliminar para cada fila
- Control reutilizable que tome schema + data + uiSchema y genere tabla con botones
- Sistema maestro-detalle: tabla de datasets -> selector -> tabla de variables del dataset
- Mismo patrón para plots y sus variables
- Todo integrado en Dashboard.jsx
### Implementación Realizada
**1. Componente EditableTable.jsx**
- Componente reutilizable que convierte schemas JSON en tablas editables
- Maneja tanto arrays como objetos con keys
- Modales para agregar/editar items
- Soporte para widgets: text, select, checkbox, number/updown
- Botones de eliminar por fila
**2. DatasetTableManager.jsx**
- Gestiona datasets y variables en sistema maestro-detalle
- Tabla de dataset definitions con CRUD completo
- Selector de dataset que carga tabla de variables correspondiente
- Variables como objetos con keys (name, area, db, offset, type, streaming)
- Integración con APIs de configuración
**3. PlotTableManager.jsx**
- Similar a datasets pero para plots
- Plot definitions con propiedades como name, time_window, y_min/max, triggers
- Variables como array simple de strings
- Componente especializado PlotVariablesTable para arrays de strings
**4. Modificación Dashboard.jsx**
- Reemplazó secciones dataset-definitions/variables con DatasetTableManager
- Reemplazó secciones plot-definitions/variables con PlotTableManager
- Mantiene PLC config como formulario RJSF original
- Organización clara por tipo de gestión
### Decisiones Técnicas
- EditableTable maneja conversión automática entre arrays y objetos con keys
- Variables de datasets son objetos complejos, variables de plots son strings simples
- Guardado automático al hacer cambios con feedback visual
- Modales para evitar edición inline compleja
- Reutilización del sistema de schemas existente
### Corrección: Sistema de Formularios de Una Sola Fila (10/08/2025)
**Problema detectado**: Usuario reportó "No schema properties defined for this table" y solicitó formularios de una sola fila basados en schemas-ui.
**Causa**: Acceso incorrecto a propiedades del schema y enfoque muy complejo de tabla tradicional.
**Solución implementada**:
**1. FormTable.jsx - Componente corregido**
- Usa schemas RJSF directamente con additionalProperties
- Muestra cada objeto como un formulario completo en una tarjeta
- Modo readonly por defecto, modo edit al hacer clic en editar
- Integración completa con LayoutObjectFieldTemplate y widgets
**2. DatasetFormManager.jsx y PlotFormManager.jsx**
- Acceso correcto a schemas: `schema.properties.datasets`
- Logs de consola para debugging de carga de schemas
- Guardado que preserva estructura completa del config
- Sistema maestro-detalle funcional
**3. Presentación mejorada**
- Cada dataset/plot/variable se muestra como un formulario completo
- Botones de editar/eliminar por tarjeta
- Formularios que respetan completamente los UI schemas
- Generación automática de IDs para nuevos elementos
### Beneficios
- Formularios de una sola fila como solicitó el usuario
- Usa completamente los schemas-ui existentes
- CRUD completo desde interfaz gráfica
- Debugging mejorado con logs de consola
- Mantiene integridad completa con schemas JSON existentes
- Patrón reutilizable para futuras entidades
### Integración con Layouts UI Schema (10/08/2025)
**Solicitud del Usuario**: Que las tablas usen los layouts definidos en los schemas UI.
**Implementación**:
**1. FormTable.jsx mejorado**
- Logs de debugging para verificar schema y uiSchema recibidos
- showErrorList={false} para formularios más limpios
- Preservación completa del uiSchema.additionalProperties
**2. Managers actualizados**
- Logs de debugging en DatasetFormManager y PlotFormManager
- Acceso correcto a schemas completos sin modificar
- Verificación de ui:layout en variables: area(3), db(2), offset(3), bit(2), type(2) en primera fila
- streaming(12) en segunda fila como define el schema UI
**3. LayoutObjectFieldTemplate**
- Ya soporta ui:layout con SimpleGrid columns={12}
- Interpreta width de cada campo correctamente
- Maneja múltiples filas de layout
**Resultado esperado**: Los formularios ahora deben mostrar los campos con los layouts exactos definidos en los archivos uischema.json, especialmente las variables de dataset con su layout específico de 2 filas.
### Componentes Completos con Layout UI Respetado (10/08/2025)
**Problema identificado**: Usuario reportó que el layout definido en dataset-definitions.uischema.json no se respetaba.
**Causa**: Solo mostrábamos datasets individuales, pero no el schema completo con active_datasets, current_dataset_id, etc.
**Solución final**:
**1. DatasetCompleteManager.jsx**
- Formulario completo de dataset-definitions con schema y uiSchema completos
- Respeta ui:layout definido: active_datasets(3), current_dataset_id(3), last_update(3), version(4)
- Tabla de datasets individuales con FormTable
- Tabla de variables con layout específico: area(3), db(2), offset(3), bit(2), type(2) + streaming(12)
**2. PlotCompleteManager.jsx**
- Formulario completo de plot-definitions con schema completo
- Tabla de plots individuales como tarjetas de formulario
- Gestión de variables de plot como array de strings
**3. Dashboard.jsx actualizado**
- Usa DatasetCompleteManager y PlotCompleteManager
- Mantiene funcionalidad híbrida: formulario completo + tablas editables
- Respeta completamente los layouts UI definidos en schemas
**Resultado**: Ahora se muestran tanto los formularios completos con layouts UI como las tablas editables solicitadas por el usuario.
### Simplificación: Eliminación de Campos Estáticos (10/08/2025)
**Solicitud del Usuario**: Eliminar área innecesaria de datos estáticos como cabecera de datasets. No necesita: active_datasets, current_dataset_id, last_update, version.
**Problema**: UI mostraba formularios completos con campos de configuración global que el usuario no necesita editar.
**Solución implementada**:
**1. DatasetCompleteManager simplificado**
- ❌ Removido formulario completo con campos estáticos
- ✅ Solo tabla de datasets individuales con FormTable
- ✅ Tabla de variables del dataset con layout UI específico
- ✅ saveDatasets() mantiene campos necesarios para backend automáticamente
**2. PlotCompleteManager simplificado**
- ❌ Removido formulario completo con configuración global
- ✅ Solo tabla de plots individuales
- ✅ Gestión de variables de plot
- ✅ savePlots() preserva session_counter, version automáticamente
**3. Arquitectura híbrida inteligente**
- 👁️ UI: Solo muestra lo que el usuario necesita editar
- 🔧 Backend: Preserva automáticamente campos técnicos necesarios
- 💾 Datos: active_datasets se calcula automáticamente desde datasets.enabled
- 🔄 current_dataset_id se mantiene basado en selección del usuario
**Resultado final**: Interfaz limpia enfocada solo en datasets y plots individuales, sin campos estáticos innecesarios.
### Refactorización Completa del Sistema de Datasets (10/08/2025)
**Problema detectado**: Usuario solicitó eliminar campos estáticos innecesarios y asegurar consistencia entre schemas y backend.
**Análisis del problema**:
- Backend dependía de active_datasets para funcionalidad crítica (CSV recording, plotting)
- Existía duplicación de datos: datasets[].enabled vs active_datasets
- Campos como version, last_update, current_dataset_id eran innecesarios
- Inconsistencia entre schemas, uischemas y datos
**Solución arquitectónica implementada**:
**1. Backend refactorizado (config_manager.py, schema_manager.py)**
- ✅ active_datasets ahora se calcula automáticamente desde datasets[].enabled
- ✅ Función _update_active_datasets() para sincronización automática
- ✅ activate_dataset()/deactivate_dataset() solo modifican enabled field
- ✅ save_datasets() solo guarda datasets sin campos estáticos
- ✅ Eliminado current_dataset_id del archivo (solo UI)
**2. Schemas simplificados y consistentes**
- ✅ dataset-definitions.schema.json: solo "datasets" property
- ✅ dataset-definitions.uischema.json: solo datasets, sin layout estático
- ✅ dataset-variables.schema.json: solo "dataset_variables" property
- ✅ Eliminados: active_datasets, current_dataset_id, version, last_update
**3. Datos actualizados y consistentes**
- ✅ dataset_definitions.json: solo {"datasets": {...}}
- ✅ dataset_variables.json: solo {"dataset_variables": {...}}
- ✅ Sin campos estáticos redundantes
**4. Frontend simplificado**
- ✅ DatasetCompleteManager: solo envía {"datasets": {...}}
- ✅ PlotCompleteManager: mantiene campos técnicos necesarios
- ✅ writeConfig simplificado sin campos estáticos
**Resultado arquitectónico**:
- 🎯 **Single Source of Truth**: datasets[].enabled es la única fuente de activación
- 🔄 **Sincronización automática**: active_datasets calculado en tiempo real
- 🧹 **Schemas limpios**: sin redundancia entre archivos de configuración
- 💻 **UI limpia**: solo muestra lo esencial para el usuario
- 🔧 **Backend optimizado**: funcionalidad preservada con arquitectura mejorada
### Verificación Final de Consistencia Completa (10/08/2025)
**Verificación sistemática realizada**: Usuario solicitó verificar consistencia entre datos, schemas y UI schemas para datasets y plots.
**Problemas detectados y corregidos**:
**1. Plot Definitions - Inconsistencias corregidas**
- ❌ **Antes**: plot_definitions.json contenía session_counter, last_saved, version
- ❌ **Antes**: plot-definitions.schema.json incluía campos estáticos
- ❌ **Antes**: plot_manager.py.save_plots() guardaba campos estáticos
- ✅ **Después**: Solo {"plots": {...}} en datos, schema y guardado
**2. Plot Variables - Inconsistencias corregidas**
- ❌ **Antes**: plot_variables.json contenía version, last_update
- ❌ **Antes**: plot-variables.schema.json incluía campos estáticos
- ✅ **Después**: Solo {"plot_variables": {...}} en datos y schema
**3. Schema Manager - Corregido**
- ❌ **Antes**: Añadía timestamps automáticamente en write_config
- ❌ **Antes**: Manejaba config_id "plots" en lugar de "plot-definitions"
- ✅ **Después**: plot-definitions manejado correctamente sin campos estáticos
- ✅ **Después**: Sin timestamps automáticos
**4. Plot Manager - Corregido**
- ❌ **Antes**: save_plots() guardaba session_counter, last_saved, version
- ✅ **Después**: save_plots() solo guarda plots y plot_variables esenciales
**Estado final de consistencia verificado**:
**Datasets**: ✅ Completamente consistentes
- dataset_definitions.json: {"datasets": {...}}
- dataset-definitions.schema.json: solo "datasets" property
- dataset-definitions.uischema.json: solo datasets UI
- dataset_variables.json: {"dataset_variables": {...}}
- dataset-variables.schema.json: solo "dataset_variables" property
**Plots**: ✅ Completamente consistentes
- plot_definitions.json: {"plots": {...}}
- plot-definitions.schema.json: solo "plots" property
- plot-definitions.uischema.json: solo plots UI (ya estaba bien)
- plot_variables.json: {"plot_variables": {...}}
- plot-variables.schema.json: solo "plot_variables" property
**Backend**: ✅ Completamente actualizado
- config_manager.py: datasets con active_datasets calculado automáticamente
- schema_manager.py: manejo correcto de dataset-definitions y plot-definitions
- plot_manager.py: save_plots() sin campos estáticos
**Resultado**: Consistencia total verificada y corregida en todo el sistema.
### Corrección del Layout UI Schema en FormTable (10/08/2025)
**Problema reportado**: Usuario informó que el layout definido en `dataset-definitions.uischema.json` no funcionaba en `Dashboard.jsx`.
**Causa identificada**:
- `DatasetCompleteManager.jsx` pasaba `datasetUiSchema.datasets` como `uiSchema` a `FormTable`
- `FormTable.jsx` buscaba incorrectamente `uiSchema.additionalProperties`
- `datasetUiSchema.datasets` ya ERA el contenido de `additionalProperties`
- Doble acceso causaba pérdida de configuración de layout
**Solución implementada**:
- Corregida línea 57 en `FormTable.jsx`:
- ❌ **Antes**: `const itemUiSchema = uiSchema.additionalProperties || {}`
- ✅ **Después**: `const itemUiSchema = uiSchema || {}`
**Verificación técnica**:
- `LayoutObjectFieldTemplate.jsx` ya soportaba `ui:layout` correctamente
- Layout esperado en datasets: 4 campos en fila horizontal (name: 3, prefix: 3, sampling_interval: 3, enabled: 3)
- `SimpleGrid` con 12 columnas procesando widths correctamente
**Resultado**: Los layouts definidos en `dataset-definitions.uischema.json` ahora se aplican correctamente en las tablas de formularios del Dashboard.
**Extensión a Variables**: Mismo problema detectado y corregido para variables de datasets.
**Problema en variables**:
- `DatasetCompleteManager.jsx` líneas 72-73 no accedían al nivel correcto del uiSchema
- Acceso incorrecto: `...variables` (faltaba `.additionalProperties`)
- Layout definido en: `dataset_variables.additionalProperties.variables.additionalProperties.ui:layout`
**Corrección implementada**:
```jsx
// ❌ ANTES - Acceso incompleto
setVariableSchema(...variables)
setVariableUiSchema(...variables || {})
// ✅ DESPUÉS - Acceso completo
setVariableSchema(...variables.additionalProperties)
setVariableUiSchema(...variables.additionalProperties || {})
```
**Layout esperado para variables**:
- Fila 1: area(3) + db(2) + offset(3) + bit(2) + type(2) = 12 columnas
- Fila 2: streaming(12) = 12 columnas
**Resultado final**: Ambos layouts (datasets y variables) funcionan correctamente en el Dashboard.
### Sistema de Colores para Variables de Plot (10/08/2025)
**Solicitud del Usuario**: Agregar soporte para colores en las variables de plot de tiempo real. El área de variables de los plots debe ser igual al área de variables de los datasets, mostrándose como una lista de forms RJSF que respete el layout y el esquema.
**Problemática identificada**:
- `plot_variables.json` solo contenía arrays de strings: `["UR29_Brix", "UR29_ma", ...]`
- No había configuración de colores para variables individuales
- Colores hardcodeados en `plot_manager.py` y `plotting.js`
- Interfaz de plot variables no usaba formularios RJSF como datasets
**Solución arquitectónica implementada**:
**1. Esquemas actualizados para soporte de colores**
- ✅ `plot-variables.schema.json`: Variables como objetos con `color` y `enabled` properties
- ✅ `plot-variables.uischema.json`: Formularios RJSF con widget color y presets de 12 colores
- ✅ Layout configurado: enabled(6) + color(6) en misma fila
**2. Datos migrados a nueva estructura**
- ❌ **Antes**: `{"variables": ["UR29_Brix", "UR29_ma", ...]}`
- ✅ **Después**:
```json
{"variables": {
"UR29_Brix": {"color": "#3498db", "enabled": true},
"UR29_ma": {"color": "#e74c3c", "enabled": true}
}}
```
**3. Backend Python actualizado (plot_manager.py)**
- ✅ `PlotSession.__init__()`: Soporte para nueva estructura con retrocompatibilidad
- ✅ `get_plot_data()`: Usa colores específicos de configuración
- ✅ `self.variable_colors`: Mapa de variable → color configurado
- ✅ Backward compatibility: convierte arrays antiguos automáticamente
**4. Frontend React actualizado (PlotCompleteManager.jsx)**
- ❌ **Eliminado**: Componente manual `PlotVariablesManager`
- ✅ **Implementado**: Uso de `FormTable` con esquemas RJSF igual que datasets
- ✅ Carga de esquemas de variables separados con debugging
- ✅ Variables como objetos en lugar de arrays
**5. Frontend JavaScript actualizado (plotting.js)**
- ✅ `getEnabledVariables()`: Maneja tanto formato anterior como nuevo
- ✅ `createStreamingChart()`: Usa colores de configuración por variable
- ✅ Formulario de creación: Genera nueva estructura con colores
- ✅ Retrocompatibilidad completa para configuraciones existentes
**Características nuevas implementadas**:
- 🎨 **Widget selector de colores**: Paleta visual con 12 colores predefinidos
- ✅ **Toggle por variable**: Habilitar/deshabilitar variables individualmente
- 📋 **Formularios RJSF**: Interfaz consistente con gestión de variables de datasets
- 🔄 **Retrocompatibilidad**: Configuraciones anteriores funcionan automáticamente
- 🎯 **Colores específicos**: Cada variable mantiene su color en plots de tiempo real
**Resultado arquitectónico**:
- 📊 **Interfaz unificada**: Variables de plot y dataset tienen la misma experiencia UI
- 🎨 **Colores configurables**: Usuarios pueden personalizar colores para cada variable
- 🔧 **Arquitectura consistente**: FormTable + esquemas RJSF en ambos contextos
- 💾 **Migración automática**: Sistema detecta y convierte formatos antiguos
- ⚡ **Performance preservada**: No impacto en plots de tiempo real
### Widget Selector de Variables para Plot Variables (10/08/2025)
**Solicitud del Usuario**: Poder editar el nombre de la variable en la tabla de variables del plot. Idealmente crear un widget que permita elegir de la lista de variables de todos los datasets, con filtro por dataset.
**Problemática identificada**:
- Variables de plot se editaban como text libre (propenso a errores de tipeo)
- No había validación de que las variables existan en los datasets
- Sin ayuda visual para encontrar variables disponibles
- No se mostraba información contextual (dataset origen, tipo PLC, etc.)
**Solución implementada**:
**1. Widget VariableSelectorWidget personalizado**
- ✅ **Carga automática**: Obtiene variables de todos los datasets vía API
- ✅ **Búsqueda inteligente**: Input de búsqueda por nombre, dataset, tipo o dirección PLC
- ✅ **Filtro por dataset**: Dropdown para filtrar variables por dataset específico
- ✅ **Información contextual**: Badges con dataset, tipo PLC, dirección, estado streaming
- ✅ **Vista detallada**: Info completa de variable seleccionada con dirección PLC
**2. Estructura de datos actualizada**
- ❌ **Antes**: `{"UR29_Brix": {"color": "#3498db", "enabled": true}}`
- ✅ **Después**:
```json
{"var_1": {"variable_name": "UR29_Brix", "color": "#3498db", "enabled": true}}
```
**3. Esquemas actualizados**
- ✅ `plot-variables.schema.json`: Añadido campo `variable_name` requerido
- ✅ `plot-variables.uischema.json`: Widget `VariableSelectorWidget` para campo variable_name
- ✅ Layout actualizado: variable_name(12) en fila 1, enabled(6) + color(6) en fila 2
**4. Backend adaptado (plot_manager.py)**
- ✅ **Retrocompatibilidad triple**: Soporte para arrays, objetos con claves, y nuevo formato
- ✅ **Detección automática**: Identifica formato por presencia de campo `variable_name`
- ✅ **Extracción correcta**: Obtiene nombre real de variable desde `variable_name` property
**5. Frontend JavaScript actualizado (plotting.js)**
- ✅ **Compatibilidad múltiple**: `getEnabledVariables()` maneja todos los formatos
- ✅ **Creación de plots**: Genera nueva estructura con IDs únicos (`var_1`, `var_2`, etc.)
- ✅ **Edición de plots**: Carga variables correctamente desde cualquier formato
**Características del widget implementadas**:
- 🔍 **Búsqueda en tiempo real**: Busca por nombre, dataset, tipo, dirección
- 📊 **Filtro por dataset**: Dropdown "All Datasets" o dataset específico
- 🏷️ **Badges informativos**: Dataset origen, tipo PLC, dirección, streaming status
- 📋 **Información detallada**: Vista expandida con dirección PLC completa
- 📈 **Contador de resultados**: "Showing X of Y variables" con criterios activos
- ⚡ **Carga asíncrona**: Spinner mientras carga variables de datasets
**Resultado final**:
- 🎯 **Selección inteligente**: Solo variables que existen en datasets disponibles
- 🔍 **Búsqueda avanzada**: Múltiples criterios de filtrado y búsqueda
- 📊 **Información contextual**: Saber qué dataset y tipo PLC tiene cada variable
- ✅ **Sin errores de tipeo**: Solo selección de variables válidas
- 🔄 **Retrocompatibilidad completa**: Todas las configuraciones anteriores siguen funcionando
### Habilitadores de Edición para Configuración PLC (08/01/2025)
**Solicitud del Usuario**: Implementar habilitadores de edición (Edit/Save/Cancel) para la configuración PLC en Dashboard.jsx, siguiendo el mismo patrón que el resto del dashboard. Además, corregir error de React Hooks en VariableSelectorWidget.
**Problemática identificada**:
- Configuración PLC usaba enfoque diferente a otros managers (SectionForm + SectionControls)
- Error de hooks en VariableSelectorWidget: `useColorModeValue` llamado condicionalmente
- Inconsistencia en UX entre secciones del dashboard
**Análisis realizado**:
- DatasetCompleteManager y PlotCompleteManager usan FormTable con habilitadores integrados
- VariableSelectorWidget tenía hooks `useColorModeValue` dentro de bloque condicional `{selectedVariable && (...)}`
- Configuración PLC es formulario simple (no tablas complejas como datasets/plots)
**Solución implementada**:
**1. Corrección de Hooks en VariableSelectorWidget.jsx**
- ❌ **Antes**: `useColorModeValue` llamado condicionalmente en líneas 198, 200
- ✅ **Después**: Hooks movidos al inicio del componente:
```jsx
const selectedVarBgColor = useColorModeValue('blue.50', 'blue.900')
const selectedVarBorderColor = useColorModeValue('blue.200', 'blue.700')
```
- ✅ **Reemplazadas** llamadas condicionales con variables predefinidas
**2. Nuevo componente PLCConfigManager.jsx**
- ✅ **Estructura similar** a DatasetCompleteManager pero para formulario simple
- ✅ **Estados de edición**: loading, editing, saving, originalData, currentData
- ✅ **Habilitadores**: Edit → modo edición, Save → guardado, Cancel → restaurar original
- ✅ **UI consistente**: Card con header, badges "Editing", botones en toolbar
- ✅ **Formulario RJSF**: readonly por defecto, editable solo en modo edit
**3. Dashboard.jsx actualizado**
- ❌ **Removido**: Sección PLC con SectionForm + SectionControls
- ✅ **Implementado**: Uso directo de PLCConfigManager
- ✅ **Consistencia**: Mismo patrón visual que otros managers
- ✅ **Preservados**: SectionForm/SectionControls para otras secciones
**Características implementadas**:
- 🎛️ **Modo readonly por defecto**: Formulario no editable hasta hacer clic en Edit
- ✏️ **Modo edición**: Badge "Editing" y botones Save/Cancel visibles
- 💾 **Guardado con feedback**: Loading spinner y mensaje de confirmación
- ❌ **Cancelación inteligente**: Restaura datos originales al cancelar
- 🔄 **Recarga automática**: Obtiene configuración actualizada al cargar
**Patrón de UX unificado**:
- 🧩 **PLC Configuration**: Edit/Save/Cancel con formulario simple
- 📊 **Dataset Management**: FormTable con Edit/Save/Cancel por fila
- 📈 **Plot Management**: FormTable con Edit/Save/Cancel por fila
- ⚙️ **Otras secciones**: SectionForm + SectionControls (mantenido para compatibilidad)
**Resultado arquitectónico**:
- ✅ **UX consistente**: Todos los managers siguen el mismo patrón de habilitadores
- 🐛 **Hooks corregidos**: Sin más warnings de orden de hooks en consola
- 🧹 **Código organizado**: Cada tipo de configuración tiene su manager específico
- 🔧 **Mantenibilidad**: Patrón claro y reutilizable para futuras configuraciones
### Corrección de Schema PLC - Error 400 API (08/01/2025)
**Problema detectado**: Usuario reportó error HTTP 400 al intentar guardar configuración PLC desde PLCConfigManager.
**Error específico**:
```
api/config/plc:1 Failed to load resource: the server responded with a status of 400 (BAD REQUEST)
Error saving PLC config: Error: HTTP 400
```
**Diagnóstico realizado**:
- ✅ **API writeConfig**: Funcionando correctamente, envía datos a `/api/config/plc` con PUT
- ✅ **PLCConfigManager**: Estructura de datos correcta enviada al backend
- ✅ **Backend schema_manager.py**: Espera estructura con `plc_config`, `udp_config`, etc.
**Causa raíz identificada**: Inconsistencia en schema JSON de validación
- ❌ **Schema plc.schema.json línea 32**:
```json
"max_hours": {
"default": null,
"minimum": 1,
"title": "Max Hours",
"type": "integer" // ← PROBLEMA: no puede ser integer Y null
}
```
- ❌ **Datos plc_config.json**: `"max_hours": null`
- ❌ **Validación JSON Schema**: Falló porque null no es un integer válido
**Solución implementada**:
- ✅ **Corregido plc.schema.json línea 32**:
```json
"max_hours": {
"default": null,
"minimum": 1,
"title": "Max Hours",
"type": ["integer", "null"] // ← CORREGIDO: permite integer O null
}
```
**Debugging temporal agregado y removido**:
- 🔍 **Logs añadidos**: Para ver datos cargados y enviados al backend
- 🧹 **Logs removidos**: Una vez identificado y solucionado el problema
**Resultado**:
- ✅ **Validación JSON Schema**: Ahora acepta correctamente `max_hours: null`
- ✅ **Guardado PLC**: Funciona sin errores 400
- ✅ **Compatibilidad**: Mantiene estructura de datos existente
- 🎛️ **PLCConfigManager**: Completamente funcional con habilitadores Edit/Save/Cancel
### Implementación de Chart.js Plot Area en React SPA (08/01/2025)
**Solicitud del Usuario**: Corregir el área de "📊 Chart.js Plot Area" en Plots.jsx que no estaba mostrando el plot con Chart.js + streaming + zooming como en index.html legacy con plotting.js.
**Problemática identificada**:
- Plots.jsx tenía solo un placeholder con texto estático en lugar de charts reales
- Faltaba integración de Chart.js, chartjs-plugin-streaming y chartjs-plugin-zoom en React
- No había componente React equivalente a la funcionalidad completa de plotting.js
- Frontend React no tenía las librerías de Chart.js cargadas
**Análisis de funcionalidad legacy**:
- **plotting.js**: Implementación completa con PlotManager class
- **Chart.js con streaming**: chartjs-plugin-streaming para datos en tiempo real
- **Funcionalidad de zoom**: chartjs-plugin-zoom para pan/zoom en eje X
- **Control de estado**: play/pause/stop/clear con manejo local y backend
- **Datos en tiempo real**: Actualización automática vía onStreamingRefresh
- **Escalas de tiempo**: Realtime scale con fallback a time scale
**Solución arquitectónica implementada**:
**1. Nuevo componente ChartjsPlot.jsx**
- ✅ **Chart.js integrado**: Detección automática de librerías y registro de plugins
- ✅ **Streaming en tiempo real**: onStreamingRefresh con fetch de datos desde backend
- ✅ **Zoom y pan**: Registro automático de chartjs-plugin-zoom si está disponible
- ✅ **Fallback mode**: Modo time scale si realtime scale no está disponible
- ✅ **Control de estado**: pauseStreaming(), resumeStreaming(), clearChart()
- ✅ **Variables dinámicas**: Soporte para getEnabledVariables con colores configurables
- ✅ **Responsive design**: Canvas responsive con contadores de data points
**2. Integración de librerías en index.html**
```html
<!-- Chart.js Libraries - Load in strict order -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@2"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
<script src="https://unpkg.com/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0"></script>
```
**3. PlotSessionPanel mejorado con control dual**
- ✅ **Control local inmediato**: Botones actúan primero en chart local para respuesta instantánea
- ✅ **Control backend**: Sincronización con backend para persistir estado
- ✅ **Referencias coordinadas**: chartControlsRef para comunicación con ChartjsPlot
- ✅ **Enhanced session**: Callback onChartReady para integración bidireccional
**4. Características técnicas implementadas**:
- 🎯 **Streaming mode**: chartjs-plugin-streaming con onRefresh callback
- 📊 **Realtime scale**: Ventana de tiempo deslizante automática
- 🔍 **Zoom integration**: Pan y zoom en eje X con limits configurables
- ⏸️ **Estado de pausa**: Manejo de gaps con insertNaNOnNextIngest
- 🎨 **Variables con colores**: Soporte completo para colores personalizados
- 📈 **Múltiples datasets**: Manejo de variables de diferentes datasets
- 🔄 **Retrocompatibilidad**: Soporte para formatos legacy de configuración
**5. Control flow implementado**:
```javascript
// Botón → Control local inmediato → Control backend
handleControl('pause') → chartControls.pauseStreaming() → onControl('pause', sessionId)
```
**6. Arquitectura de streaming**:
- 📡 **Auto-refresh**: Intervalo automático con chartjs-plugin-streaming
- 🔄 **Manual fallback**: Intervalo manual si realtime scale no disponible
- 📊 **Data fetching**: GET `/api/plots/${sessionId}/data`
- 🧹 **Cleanup**: Limpieza automática de datos antiguos en ventana de tiempo
- ⚡ **Performance**: Update con modo 'quiet' para render optimizado
**Resultado arquitectónico**:
- 📈 **Plots funcionales**: Charts de tiempo real completos en React SPA
- 🔄 **Paridad funcional**: Misma funcionalidad que plotting.js legacy
- 🎛️ **Control integrado**: Botones de control coordinados frontend-backend
- 🧩 **Componente reutilizable**: ChartjsPlot.jsx listo para otros contextos
- ⚡ **Performance optimizada**: Streaming y rendering eficientes
- 🔍 **Zoom y navegación**: Funcionalidad completa de exploración de datos
### Corrección de Errores de Integración Chart.js (08/01/2025)
**Problemas reportados por el usuario**:
- Error 500 en `/api/plots`: "GET http://localhost:5173/api/plots 500 (Internal Server Error)"
- Warning de React: "Expected useImperativeHandle() first argument to either be a ref callback or React.createRef() object"
- Error JSON: "Failed to execute 'json' on 'Response': Unexpected end of JSON input"
**Diagnóstico realizado**:
- El endpoint `/api/plots` estaba definido correctamente en `main.py` línea 1382
- El método `get_all_sessions_status()` existía en `PlotManager` (core/plot_manager.py línea 418)
- Error en `ChartjsPlot.jsx`: uso incorrecto de `useImperativeHandle` con `React.forwardRef(() => ({}))`
- Falta de debugging para diagnosticar el error 500 del backend
**Soluciones implementadas**:
**1. Corregido warning de React en ChartjsPlot.jsx**
- ❌ **Antes**: `React.useImperativeHandle(React.forwardRef(() => ({})), () => ({ ... }))`
- ✅ **Después**: Removido `useImperativeHandle` incorrecto, usando callback props únicamente
- ✅ **Resultado**: Eliminado warning de React, funcionalidad preserved via `session.onChartReady`
**2. Debugging mejorado en backend (main.py)**
- ✅ **Logs detallados**: Trazabilidad completa en endpoint `/api/plots`
- ✅ **Exception handling**: Traceback completo en consola para diagnosticar errores
- ✅ **Step-by-step logging**: Verificación de streamer, plot_manager y sessions
**3. Nuevo endpoint de salud**
```python
@app.route("/api/health", methods=["GET"])
def health_check():
# Verifica estado de streamer y plot_manager
return {"status": "ok", "streamer": "initialized", "plot_manager": "available"}
```
**4. Manejo de errores mejorado en frontend (Plots.jsx)**
- ✅ **Error HTTP parsing**: Captura respuesta de error como texto para mejor debugging
- ✅ **Status code checking**: Verificación específica de `response.ok`
- ✅ **Toast notifications**: Notificaciones de error visibles al usuario
- ✅ **Graceful degradation**: Continuar funcionamiento con sesiones vacías en caso de error
**5. Verificación de salud proactiva**
- ✅ **Health check inicial**: Verificar backend antes de cargar sesiones
- ✅ **Plot manager validation**: Advertir si plot manager no está disponible
- ✅ **Connection error handling**: Manejo de errores de conectividad de red
**Flujo de diagnóstico implementado**:
```javascript
// Frontend
checkBackendHealth() → fetch('/api/health') → plot_manager status
loadExistingSessions() → fetch('/api/plots') → detailed error logging
ChartjsPlot render → Chart.js integration → streaming setup
```
**Resultado de debugging**:
- 🔍 **Visibilidad completa**: Logs detallados en backend y frontend
- 🏥 **Health monitoring**: Estado de componentes críticos verificado
- 🐛 **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

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"workbench.colorCustomizations": {
"titleBar.activeBackground": "#470606",
}

View File

@ -9343,8 +9343,221 @@
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T15:19:58.846807",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T18:13:05.291909",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T18:27:58.167381",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T19:08:01.450637",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T19:46:18.227132",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T20:49:00.212504",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T20:55:02.435268",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T20:56:20.348940",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T21:01:03.404131",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T22:01:09.404583",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T23:11:22.325098",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T23:22:17.993572",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T23:53:32.510938",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T23:58:32.355026",
"level": "error",
"event_type": "plc_connection_failed",
"message": "Failed to connect to PLC 10.1.33.12",
"details": {
"ip": "10.1.33.12",
"rack": 0,
"slot": 2,
"error": "b' TCP : Unreachable peer'"
}
},
{
"timestamp": "2025-08-12T23:58:56.858849",
"level": "error",
"event_type": "plc_connection_failed",
"message": "Failed to connect to PLC 10.1.33.12",
"details": {
"ip": "10.1.33.12",
"rack": 0,
"slot": 2,
"error": "b' TCP : Unreachable peer'"
}
},
{
"timestamp": "2025-08-12T23:59:37.851777",
"level": "error",
"event_type": "plc_connection_failed",
"message": "Failed to connect to PLC 10.1.33.12",
"details": {
"ip": "10.1.33.12",
"rack": 0,
"slot": 2,
"error": "b' TCP : Unreachable peer'"
}
},
{
"timestamp": "2025-08-13T00:06:18.623862",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T00:09:00.492977",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T00:09:19.048137",
"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-13T00:09:19.071123",
"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-13T00:09:19.091418",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-13T00:09:19.114647",
"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": [
"Fast",
"DAR"
]
}
},
{
"timestamp": "2025-08-13T00:16:07.795758",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T00:16:39.106757",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-13T00:19:23.495379",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
}
],
"last_updated": "2025-08-12T15:06:11.269817",
"total_entries": 874
"last_updated": "2025-08-13T00:19:23.495379",
"total_entries": 899
}

View File

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

View File

@ -1,69 +1,52 @@
{
"datasets": {
"dataset_variables": {
"DAR": {
"name": "DAR",
"prefix": "gateway_phoenix",
"variables": {
"UR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"type": "real",
"streaming": true,
"db": 1011
"type": "real"
},
"UR29_ma": {
"area": "db",
"db": 1011,
"offset": 1296,
"type": "real",
"streaming": true,
"db": 1011
"type": "real"
},
"fUR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"type": "real",
"streaming": false,
"db": 1011
"type": "real"
}
},
"streaming_variables": [
"UR29_Brix",
"UR29_ma"
],
"sampling_interval": 1.0,
"enabled": true,
"created": "2025-08-08T15:47:18.566053"
]
},
"Fast": {
"name": "Fast",
"prefix": "fast",
"variables": {
"fUR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"type": "real",
"streaming": false,
"db": 1011
"type": "real"
},
"fUR29_ma": {
"area": "db",
"db": 1011,
"offset": 1296,
"type": "real",
"streaming": false,
"db": 1011
"type": "real"
}
},
"streaming_variables": [],
"sampling_interval": 0.1,
"enabled": true,
"created": "2025-08-09T02:06:26.840011"
"streaming_variables": []
}
},
"active_datasets": [
"Fast",
"DAR"
],
"current_dataset_id": "Fast",
"version": "1.0",
"last_update": "2025-08-10T01:45:12.551768"
}
}

View File

@ -1,21 +1,21 @@
{
"plc_config": {
"ip": "10.1.33.12",
"rack": 0,
"slot": 2
},
"udp_config": {
"host": "127.0.0.1",
"port": 9870
},
"sampling_interval": 0.1,
"csv_config": {
"records_directory": "records",
"rotation_enabled": true,
"max_size_mb": 1000,
"max_days": 30,
"max_hours": null,
"cleanup_interval_hours": 24,
"last_cleanup": "2025-08-09T22:43:54.224975"
}
"plc_config": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2
},
"udp_config": {
"host": "127.0.0.1",
"port": 9870
},
"sampling_interval": 0.1,
"csv_config": {
"records_directory": "records",
"rotation_enabled": true,
"max_size_mb": 1000,
"max_days": 30,
"max_hours": null,
"cleanup_interval_hours": 24,
"last_cleanup": "2025-08-13T00:09:19.306354"
}
}

View File

@ -0,0 +1,14 @@
{
"plots": {
"plot_1": {
"name": "UR29",
"time_window": 75,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true,
"session_id": "plot_1"
}
}
}

View File

@ -1,23 +0,0 @@
{
"plots": {
"plot_1": {
"name": "UR29",
"variables": [
"UR29_Brix",
"UR29_ma",
"fUR29_Brix",
"fUR29_ma"
],
"time_window": 75,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true,
"session_id": "plot_1"
}
},
"session_counter": 2,
"last_saved": "2025-08-10T00:37:46.525175",
"version": "1.0"
}

View File

@ -0,0 +1,28 @@
{
"plot_variables": {
"plot_1": {
"variables": {
"var_1": {
"variable_name": "UR29_Brix",
"color": "#3498db",
"enabled": true
},
"var_2": {
"variable_name": "UR29_ma",
"color": "#e74c3c",
"enabled": true
},
"var_3": {
"variable_name": "fUR29_Brix",
"color": "#2ecc71",
"enabled": true
},
"var_4": {
"variable_name": "fUR29_ma",
"color": "#f39c12",
"enabled": true
}
}
}
}
}

View File

@ -0,0 +1,73 @@
{
"$id": "dataset-definitions.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Schema for dataset definitions (metadata only, no variables)",
"properties": {
"datasets": {
"additionalProperties": {
"properties": {
"created": {
"title": "Created",
"type": [
"string",
"null"
]
},
"enabled": {
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
},
"title": "Dataset Enabled",
"type": "boolean"
},
"name": {
"description": "Human-readable name of the dataset",
"maxLength": 60,
"minLength": 1,
"title": "Dataset Name",
"type": "string"
},
"prefix": {
"description": "Prefix for CSV files",
"maxLength": 20,
"minLength": 1,
"pattern": "^[a-zA-Z0-9_-]+$",
"title": "CSV Prefix",
"type": "string"
},
"sampling_interval": {
"description": "Leave empty to use the global interval",
"maximum": 10,
"minimum": 0.01,
"title": "Sampling interval (s)",
"type": [
"number",
"null"
]
}
},
"required": [
"name",
"prefix"
],
"type": "object"
},
"title": "Dataset Definitions",
"type": "object"
}
},
"required": [
"datasets"
],
"title": "Dataset Definitions",
"type": "object"
}

View File

@ -1,34 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"$id": "dataset-variables.schema.json",
"title": "Dataset Variables",
"description": "Schema for variables assigned to each dataset",
"type": "object",
"additionalProperties": false,
"properties": {
"datasets": {
"dataset_variables": {
"type": "object",
"title": "Datasets",
"title": "Variables by Dataset",
"description": "Variables organized by dataset ID",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"variables": {
"type": "object",
"title": "Dataset Variables",
"additionalProperties": {
"type": "object",
"properties": {
@ -108,76 +95,16 @@
"type": "string"
},
"default": []
},
"sampling_interval": {
"type": [
"number",
"null"
],
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
],
"title": "Created"
}
},
"required": [
"name",
"prefix",
"variables",
"streaming_variables"
]
}
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
"default": []
},
"current_dataset_id": {
"type": [
"string",
"null"
],
"title": "Current Dataset Id"
},
"version": {
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
],
"title": "Last Update"
}
},
"required": [
"datasets"
"dataset_variables"
]
}

View File

@ -1,183 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"type": "object",
"additionalProperties": false,
"properties": {
"datasets": {
"type": "object",
"title": "Datasets",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"area": {
"type": "string",
"title": "Memory Area",
"enum": [
"db",
"mw",
"m",
"pew",
"pe",
"paw",
"pa",
"e",
"a",
"mb"
]
},
"db": {
"type": [
"integer",
"null"
],
"title": "DB Number",
"minimum": 1,
"maximum": 9999
},
"offset": {
"type": "integer",
"title": "Offset",
"minimum": 0,
"maximum": 8191
},
"bit": {
"type": [
"integer",
"null"
],
"title": "Bit Position",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"title": "Data Type",
"enum": [
"real",
"int",
"bool",
"dint",
"word",
"byte",
"uint",
"udint",
"sint",
"usint"
]
},
"streaming": {
"type": "boolean",
"title": "Stream to PlotJuggler",
"default": false
}
},
"required": [
"area",
"offset",
"type"
]
}
},
"streaming_variables": {
"type": "array",
"title": "Streaming variables",
"items": {
"type": "string"
},
"default": []
},
"sampling_interval": {
"type": [
"number",
"null"
],
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
],
"title": "Created"
}
},
"required": [
"name",
"prefix",
"variables",
"streaming_variables"
]
}
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
"default": []
},
"current_dataset_id": {
"type": [
"string",
"null"
],
"title": "Current Dataset Id"
},
"version": {
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
],
"title": "Last Update"
}
},
"required": [
"datasets"
]
}

View File

@ -29,7 +29,10 @@
"default": null,
"minimum": 1,
"title": "Max Hours",
"type": "integer"
"type": [
"integer",
"null"
]
},
"max_size_mb": {
"default": 1000,

View File

@ -0,0 +1,76 @@
{
"$id": "plot-definitions.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Schema for plot session definitions (metadata only, no variables)",
"properties": {
"plots": {
"additionalProperties": {
"properties": {
"name": {
"description": "Human-readable name of the plot session",
"title": "Plot Name",
"type": "string"
},
"session_id": {
"title": "Session Id",
"type": "string"
},
"time_window": {
"default": 60,
"description": "Time window in seconds",
"maximum": 3600,
"minimum": 5,
"title": "Time window (s)",
"type": "integer"
},
"trigger_enabled": {
"default": false,
"title": "Enable Trigger",
"type": "boolean"
},
"trigger_on_true": {
"default": true,
"title": "Trigger on True",
"type": "boolean"
},
"trigger_variable": {
"title": "Trigger Variable",
"type": [
"string",
"null"
]
},
"y_max": {
"description": "Leave empty for auto",
"title": "Y Max",
"type": [
"number",
"null"
]
},
"y_min": {
"description": "Leave empty for auto",
"title": "Y Min",
"type": [
"number",
"null"
]
}
},
"required": [
"name",
"time_window"
],
"type": "object"
},
"title": "Plot Definitions",
"type": "object"
}
},
"required": [
"plots"
],
"title": "Plot Definitions",
"type": "object"
}

View File

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plot-variables.schema.json",
"title": "Plot Variables",
"description": "Schema for variables assigned to each plot session",
"type": "object",
"additionalProperties": false,
"properties": {
"plot_variables": {
"type": "object",
"title": "Variables by Plot",
"description": "Variables organized by plot ID",
"additionalProperties": {
"type": "object",
"properties": {
"variables": {
"type": "object",
"title": "Plot Variables",
"description": "Variables configuration for plotting with colors",
"additionalProperties": {
"type": "object",
"properties": {
"variable_name": {
"type": "string",
"title": "Variable Name",
"description": "Select a variable from available dataset variables"
},
"color": {
"type": "string",
"title": "Plot Color",
"description": "Hex color code for the variable in the plot",
"pattern": "^#[0-9A-Fa-f]{6}$",
"default": "#3498db"
},
"enabled": {
"type": "boolean",
"title": "Enable Plotting",
"description": "Whether this variable should be shown in the plot",
"default": true
}
},
"required": [
"variable_name",
"color",
"enabled"
]
}
}
},
"required": [
"variables"
]
}
}
},
"required": [
"plot_variables"
]
}

View File

@ -1,100 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
"plots": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Plot Name",
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
"minItems": 1
},
"time_window": {
"type": "integer",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
},
"y_min": {
"type": [
"number",
"null"
],
"title": "Y Min",
"description": "Leave empty for auto"
},
"y_max": {
"type": [
"number",
"null"
],
"title": "Y Max",
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [
"string",
"null"
],
"title": "Trigger Variable"
},
"trigger_enabled": {
"type": "boolean",
"title": "Enable Trigger",
"default": false
},
"trigger_on_true": {
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"session_id": {
"type": "string",
"title": "Session Id"
}
},
"required": [
"name",
"variables",
"time_window"
]
}
},
"session_counter": {
"type": "integer",
"minimum": 0,
"default": 0
},
"last_saved": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string",
"default": "1.0"
}
},
"required": [
"plots"
]
}

View File

@ -0,0 +1,77 @@
{
"datasets": {
"additionalProperties": {
"created": {
"ui:help": "Timestamp when this dataset was created",
"ui:readonly": true,
"ui:widget": "text"
},
"enabled": {
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
"ui:widget": "checkbox"
},
"name": {
"ui:help": "Human-readable name for this dataset",
"ui:placeholder": "e.g., Temperature Sensors, Production Line A"
},
"prefix": {
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
"ui:placeholder": "e.g., temp, line_a, sensors"
},
"sampling_interval": {
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval.",
"ui:placeholder": "Leave empty to use global interval",
"ui:widget": "updown"
},
"ui:order": [
"name",
"prefix",
"enabled",
"sampling_interval",
"created"
]
},
"ui:column": 3,
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
"ui:options": {
"addable": true,
"orderable": true,
"removable": true
},
"ui:layout": [
[
{
"name": "name",
"width": 3
},
{
"name": "prefix",
"width": 3
},
{
"name": "sampling_interval",
"width": 3
},
{
"name": "enabled",
"width": 3
}
]
],
"name": {
"ui:column": 3
},
"prefix": {
"ui:column": 3
},
"sampling_interval": {
"ui:column": 3
},
"enabled": {
"ui:column": 3
}
},
"ui:order": [
"datasets"
]
}

View File

@ -0,0 +1,186 @@
{
"dataset_variables": {
"ui:description": "⚙️ Configure PLC variables for each dataset - specify memory areas, data types, and streaming options",
"ui:options": {
"addable": true,
"orderable": false,
"removable": true
},
"additionalProperties": {
"ui:order": [
"variables",
"streaming_variables"
],
"variables": {
"ui:description": "🔧 PLC Variable Definitions",
"ui:help": "Define PLC memory locations, data types, and properties for each variable",
"ui:options": {
"addable": true,
"orderable": true,
"removable": true
},
"additionalProperties": {
"ui:order": [
"area",
"db",
"offset",
"bit",
"type",
"streaming"
],
"ui:layout": [
[
{
"name": "area",
"width": 3
},
{
"name": "db",
"width": 2
},
{
"name": "offset",
"width": 3
},
{
"name": "bit",
"width": 2
},
{
"name": "type",
"width": 2
}
],
[
{
"name": "streaming",
"width": 12
}
]
],
"area": {
"ui:widget": "select",
"ui:help": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
"ui:options": {
"enumOptions": [
{
"value": "db",
"label": "🗃️ DB (Data Block)"
},
{
"value": "mw",
"label": "📊 MW (Memory Word)"
},
{
"value": "m",
"label": "💾 M (Memory)"
},
{
"value": "pew",
"label": "📥 PEW (Process Input Word)"
},
{
"value": "pe",
"label": "📥 PE (Process Input)"
},
{
"value": "paw",
"label": "📤 PAW (Process Output Word)"
},
{
"value": "pa",
"label": "📤 PA (Process Output)"
},
{
"value": "e",
"label": "🔌 E (Input)"
},
{
"value": "a",
"label": "🔌 A (Output)"
},
{
"value": "mb",
"label": "💾 MB (Memory Byte)"
}
]
}
},
"db": {
"ui:widget": "updown",
"ui:help": "Data Block number (required for DB area)",
"ui:placeholder": "1011"
},
"offset": {
"ui:widget": "updown",
"ui:help": "Byte offset within the memory area"
},
"bit": {
"ui:widget": "updown",
"ui:help": "Bit position (0-7) for bit-addressable areas"
},
"type": {
"ui:widget": "select",
"ui:help": "PLC data type",
"ui:options": {
"enumOptions": [
{
"value": "real",
"label": "🔢 REAL (32-bit float)"
},
{
"value": "int",
"label": "🔢 INT (16-bit signed)"
},
{
"value": "bool",
"label": "✅ BOOL (1-bit boolean)"
},
{
"value": "dint",
"label": "🔢 DINT (32-bit signed)"
},
{
"value": "word",
"label": "🔢 WORD (16-bit unsigned)"
},
{
"value": "byte",
"label": "🔢 BYTE (8-bit unsigned)"
},
{
"value": "uint",
"label": "🔢 UINT (16-bit unsigned)"
},
{
"value": "udint",
"label": "🔢 UDINT (32-bit unsigned)"
},
{
"value": "sint",
"label": "🔢 SINT (8-bit signed)"
},
{
"value": "usint",
"label": "🔢 USINT (8-bit unsigned)"
}
]
}
},
"streaming": {
"ui:widget": "checkbox",
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
}
}
},
"streaming_variables": {
"ui:widget": "checkboxes",
"ui:description": "📡 Streaming Variables",
"ui:help": "Variables that are streamed in real-time to PlotJuggler. This list is automatically updated when you enable/disable streaming on individual variables above."
}
}
},
"ui:order": [
"dataset_variables"
]
}

View File

@ -1,42 +0,0 @@
{
"datasets": {
"ui:description": "Define datasets, their variables and streaming flags.",
"items": {
"name": {
"ui:placeholder": "Temperature Sensors"
},
"prefix": {
"ui:placeholder": "temp"
},
"sampling_interval": {
"ui:widget": "updown"
},
"enabled": {
"ui:widget": "checkbox"
},
"variables": {
"ui:description": "Variables inside this dataset",
"items": {
"area": {
"ui:widget": "select"
},
"db": {
"ui:widget": "updown"
},
"offset": {
"ui:widget": "updown"
},
"bit": {
"ui:widget": "updown"
},
"type": {
"ui:widget": "select"
},
"streaming": {
"ui:widget": "checkbox"
}
}
}
}
}
}

View File

@ -0,0 +1,77 @@
{
"plots": {
"items": {
"time_window": {
"ui:widget": "updown"
},
"trigger_enabled": {
"ui:widget": "checkbox"
},
"trigger_on_true": {
"ui:widget": "checkbox"
},
"y_max": {
"ui:widget": "updown"
},
"y_min": {
"ui:widget": "updown"
}
},
"ui:description": "Plot session configuration (time window, Y axis, triggers)",
"ui:layout": [
[
{
"name": "session_id",
"width": 2
},
{
"name": "name",
"width": 4
},
{
"name": "trigger_variable",
"width": 4
},
{
"name": "trigger_enabled",
"width": 2
}
],
[
{
"name": "time_window",
"width": 4
},
{
"name": "y_min",
"width": 4
},
{
"name": "y_max",
"width": 4
}
]
],
"session_id": {
"ui:column": 2
},
"name": {
"ui:column": 4
},
"trigger_variable": {
"ui:column": 4
},
"trigger_enabled": {
"ui:column": 2
},
"time_window": {
"ui:column": 4
},
"y_min": {
"ui:column": 4
},
"y_max": {
"ui:column": 4
}
}
}

View File

@ -0,0 +1,82 @@
{
"plot_variables": {
"ui:description": "📊 Configure plot variables with colors and settings for real-time visualization",
"ui:options": {
"addable": true,
"orderable": false,
"removable": true
},
"additionalProperties": {
"ui:order": [
"variables"
],
"variables": {
"ui:description": "🎨 Plot Variable Configuration",
"ui:help": "Configure colors and display settings for each variable in the plot",
"ui:options": {
"addable": true,
"orderable": true,
"removable": true
},
"additionalProperties": {
"ui:order": [
"variable_name",
"enabled",
"color"
],
"ui:layout": [
[
{
"name": "variable_name",
"width": 12
}
],
[
{
"name": "enabled",
"width": 6
},
{
"name": "color",
"width": 6
}
]
],
"variable_name": {
"ui:widget": "VariableSelectorWidget",
"ui:help": "🔍 Select a variable from the available dataset variables",
"ui:description": "Choose from existing PLC variables defined in your datasets"
},
"enabled": {
"ui:widget": "checkbox",
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
},
"color": {
"ui:widget": "color",
"ui:help": "🎨 Select the color for this variable in the plot",
"ui:placeholder": "#3498db",
"ui:options": {
"presetColors": [
"#3498db",
"#e74c3c",
"#2ecc71",
"#f39c12",
"#9b59b6",
"#1abc9c",
"#34495e",
"#e67e22",
"#95a5a6",
"#f1c40f",
"#8e44ad",
"#16a085"
]
}
}
}
}
}
},
"ui:order": [
"plot_variables"
]
}

View File

@ -1,21 +0,0 @@
{
"plots": {
"items": {
"time_window": {
"ui:widget": "updown"
},
"y_min": {
"ui:widget": "updown"
},
"y_max": {
"ui:widget": "updown"
},
"trigger_enabled": {
"ui:widget": "checkbox"
},
"trigger_on_true": {
"ui:widget": "checkbox"
}
}
}
}

View File

@ -2,8 +2,6 @@ import json
import os
import sys
from datetime import datetime
from typing import Dict, Any, Optional, List, Set
from pathlib import Path
def resource_path(relative_path):
@ -28,6 +26,16 @@ class ConfigManager:
data_dir = os.path.join("config", "data")
os.makedirs(resource_path(data_dir), exist_ok=True)
self.config_file = resource_path(os.path.join(data_dir, "plc_config.json"))
# New separated dataset files
self.dataset_definitions_file = resource_path(
os.path.join(data_dir, "dataset_definitions.json")
)
self.dataset_variables_file = resource_path(
os.path.join(data_dir, "dataset_variables.json")
)
# Legacy dataset file for migration
self.datasets_file = resource_path(os.path.join(data_dir, "plc_datasets.json"))
self.state_file = resource_path("system_state.json")
@ -117,56 +125,152 @@ class ConfigManager:
self.logger.error(f"Error saving configuration: {e}")
def load_datasets(self):
"""Load datasets configuration from JSON file"""
"""Load datasets configuration from separated JSON files"""
try:
if os.path.exists(self.datasets_file):
with open(self.datasets_file, "r") as f:
datasets_data = json.load(f)
self.datasets = datasets_data.get("datasets", {})
self.active_datasets = set(datasets_data.get("active_datasets", []))
self.current_dataset_id = datasets_data.get("current_dataset_id")
# Validate current_dataset_id exists
if (
self.current_dataset_id
and self.current_dataset_id not in self.datasets
):
self.current_dataset_id = None
# Set default current dataset if none selected
if not self.current_dataset_id and self.datasets:
self.current_dataset_id = next(iter(self.datasets.keys()))
if self.logger:
self.logger.info(
f"Datasets loaded from {self.datasets_file}: {len(self.datasets)} datasets, {len(self.active_datasets)} active"
)
# Try to load from new separated files first
if os.path.exists(self.dataset_definitions_file) and os.path.exists(
self.dataset_variables_file
):
self._load_datasets_separated()
# Fall back to legacy file and migrate if needed
elif os.path.exists(self.datasets_file):
self._migrate_datasets_from_legacy()
else:
# No datasets found, start empty
self.datasets = {}
self.active_datasets = set()
self.current_dataset_id = None
if self.logger:
self.logger.info(
"No datasets file found, starting with empty datasets"
)
self.logger.info("No datasets found, starting with empty datasets")
# Validate current_dataset_id exists
if self.current_dataset_id and self.current_dataset_id not in self.datasets:
self.current_dataset_id = None
# Set default current dataset if none selected
if not self.current_dataset_id and self.datasets:
self.current_dataset_id = next(iter(self.datasets.keys()))
except Exception as e:
if self.logger:
self.logger.error(f"Error loading datasets: {e}")
def save_datasets(self):
"""Save datasets configuration to JSON file"""
def _load_datasets_separated(self):
"""Load datasets from separated definition and variable files"""
try:
datasets_data = {
"datasets": self.datasets,
"active_datasets": list(self.active_datasets),
"current_dataset_id": self.current_dataset_id,
"version": "1.0",
"last_update": datetime.now().isoformat(),
}
with open(self.datasets_file, "w") as f:
json.dump(datasets_data, f, indent=4)
# Load definitions
with open(self.dataset_definitions_file, "r") as f:
definitions_data = json.load(f)
# Load variables
with open(self.dataset_variables_file, "r") as f:
variables_data = json.load(f)
# Merge data back to legacy format for compatibility
self.datasets = {}
dataset_defs = definitions_data.get("datasets", {})
dataset_vars = variables_data.get("dataset_variables", {})
for dataset_id, definition in dataset_defs.items():
variables_info = dataset_vars.get(dataset_id, {})
self.datasets[dataset_id] = {
**definition,
"variables": variables_info.get("variables", {}),
"streaming_variables": variables_info.get(
"streaming_variables", []
),
}
# Calculate active_datasets automatically from enabled field
self.active_datasets = set()
for dataset_id, definition in dataset_defs.items():
if definition.get("enabled", False):
self.active_datasets.add(dataset_id)
# current_dataset_id is optional for UI, use first available if not set
self.current_dataset_id = definitions_data.get("current_dataset_id")
if not self.current_dataset_id and self.datasets:
self.current_dataset_id = next(iter(self.datasets.keys()))
if self.logger:
self.logger.info(
f"Datasets configuration saved to {self.datasets_file}"
f"Datasets loaded from separated files: {len(self.datasets)} "
f"datasets, {len(self.active_datasets)} active"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error loading separated datasets: {e}")
raise
def _migrate_datasets_from_legacy(self):
"""Migrate datasets from legacy single file to separated files"""
try:
with open(self.datasets_file, "r") as f:
legacy_data = json.load(f)
self.datasets = legacy_data.get("datasets", {})
self.active_datasets = set(legacy_data.get("active_datasets", []))
self.current_dataset_id = legacy_data.get("current_dataset_id")
# Save to new separated format
self.save_datasets()
if self.logger:
self.logger.info(
f"Migrated datasets from legacy file: {len(self.datasets)} "
f"datasets, {len(self.active_datasets)} active"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error migrating legacy datasets: {e}")
raise
def save_datasets(self):
"""Save datasets configuration to separated JSON files"""
try:
# timestamp removed as we don't save static fields anymore
# Prepare definitions data - only datasets, no static fields
definitions_data = {
"datasets": {},
}
# Prepare variables data - only variables, no static fields
variables_data = {
"dataset_variables": {},
}
# Split datasets into definitions and variables
for dataset_id, dataset_info in self.datasets.items():
# Extract definition (metadata only)
definition = {
key: value
for key, value in dataset_info.items()
if key not in ["variables", "streaming_variables"]
}
definitions_data["datasets"][dataset_id] = definition
# Extract variables
variables_data["dataset_variables"][dataset_id] = {
"variables": dataset_info.get("variables", {}),
"streaming_variables": dataset_info.get("streaming_variables", []),
}
# Save both files
with open(self.dataset_definitions_file, "w") as f:
json.dump(definitions_data, f, indent=4)
with open(self.dataset_variables_file, "w") as f:
json.dump(variables_data, f, indent=4)
if self.logger:
self.logger.info(
f"Datasets configuration saved to separated files: "
f"{self.dataset_definitions_file} and {self.dataset_variables_file}"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error saving datasets: {e}")
@ -520,8 +624,8 @@ class ConfigManager:
if dataset_id not in self.datasets:
raise ValueError(f"Dataset '{dataset_id}' does not exist")
self.active_datasets.add(dataset_id)
self.datasets[dataset_id]["enabled"] = True
self._update_active_datasets()
self.save_datasets()
def deactivate_dataset(self, dataset_id: str):
@ -529,10 +633,17 @@ class ConfigManager:
if dataset_id not in self.datasets:
raise ValueError(f"Dataset '{dataset_id}' does not exist")
self.active_datasets.discard(dataset_id)
self.datasets[dataset_id]["enabled"] = False
self._update_active_datasets()
self.save_datasets()
def _update_active_datasets(self):
"""Update active_datasets based on enabled field of each dataset"""
self.active_datasets = set()
for dataset_id, dataset in self.datasets.items():
if dataset.get("enabled", False):
self.active_datasets.add(dataset_id)
def get_status(self):
"""Get configuration status"""
total_variables = sum(

View File

@ -3,9 +3,8 @@ import time
import json
import os
from collections import deque
from datetime import datetime, timedelta
from datetime import datetime
from typing import Dict, Any, Optional, List, Set
import logging
def resource_path(relative_path):
@ -28,7 +27,59 @@ class PlotSession:
def __init__(self, session_id: str, config: Dict[str, Any]):
self.session_id = session_id
self.name = config.get("name", f"Plot {session_id}")
self.variables = config.get("variables", []) # Lista de nombres de variables
# Handle new variable structure with colors and enabled state
variables_config = config.get("variables", {})
if isinstance(variables_config, list):
# Backward compatibility: convert old list format to new object format
self.variables = variables_config
self.variable_colors = {}
# Default colors for backward compatibility
default_colors = [
"#FF6384",
"#36A2EB",
"#FFCE56",
"#4BC0C0",
"#9966FF",
"#FF9F40",
"#8A2BE2",
"#FF1493",
"#00CED1",
"#32CD32",
]
for i, var in enumerate(self.variables):
self.variable_colors[var] = default_colors[i % len(default_colors)]
elif isinstance(variables_config, dict):
# Check if this is the old object format (variable names as keys)
# or new format (variable_name as property)
first_var_config = next(iter(variables_config.values()), {})
if "variable_name" in first_var_config:
# New format: variable_name as property
self.variables = []
self.variable_colors = {}
for var_id, var_config in variables_config.items():
if var_config.get("enabled", True):
var_name = var_config.get("variable_name")
if var_name:
self.variables.append(var_name)
self.variable_colors[var_name] = var_config.get(
"color", "#3498db"
)
else:
# Old object format: variable names as keys
self.variables = []
self.variable_colors = {}
for var_name, var_config in variables_config.items():
if var_config.get("enabled", True):
self.variables.append(var_name)
self.variable_colors[var_name] = var_config.get(
"color", "#3498db"
)
else:
# Empty or invalid config
self.variables = []
self.variable_colors = {}
self.time_window = config.get(
"time_window", 60
) # Ventana de tiempo en segundos
@ -61,20 +112,6 @@ class PlotSession:
for var in self.variables:
self.data[var] = deque(maxlen=max_points)
# Colores para las variables
self.colors = [
"#FF6384",
"#36A2EB",
"#FFCE56",
"#4BC0C0",
"#9966FF",
"#FF9F40",
"#FF6384",
"#C9CBCF",
"#4BC0C0",
"#FF6384",
]
def add_data_point(self, variable: str, timestamp: float, value: Any) -> bool:
"""Agregar un punto de datos a la sesión"""
if not self.is_active or self.is_paused or variable not in self.data:
@ -128,14 +165,14 @@ class PlotSession:
}
)
color_index = i % len(self.colors)
# Use configured color for this variable
variable_color = self.variable_colors.get(variable, "#3498db")
datasets.append(
{
"label": variable,
"data": data_points,
"borderColor": self.colors[color_index],
"backgroundColor": self.colors[color_index]
+ "20", # 20 = 12% opacity
"borderColor": variable_color,
"backgroundColor": variable_color + "20", # 20 = 12% opacity
"fill": False,
"tension": 0.1,
}
@ -228,9 +265,18 @@ class PlotManager:
self.logger = logger
# Persistent storage (reorganized under config/data)
self.plots_file = resource_path(
os.path.join("config", "data", "plot_sessions.json")
data_dir = os.path.join("config", "data")
# New separated plot files
self.plot_definitions_file = resource_path(
os.path.join(data_dir, "plot_definitions.json")
)
self.plot_variables_file = resource_path(
os.path.join(data_dir, "plot_variables.json")
)
# 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()
@ -375,80 +421,172 @@ class PlotManager:
return [session.get_status() for session in self.sessions.values()]
def load_plots(self):
"""Cargar plots persistentes desde archivo"""
"""Cargar plots persistentes desde archivos separados"""
try:
if os.path.exists(self.plots_file):
with open(self.plots_file, "r", encoding="utf-8") as f:
data = json.load(f)
plots_data = data.get("plots", {})
for session_id, plot_config in plots_data.items():
# Crear sesión con configuración guardada
session = PlotSession(session_id, plot_config)
# 🔑 CAMBIO: Cargar plots existentes como inactivos (usuario decide cuando activarlos)
session.is_active = False
session.is_paused = False
self.sessions[session_id] = session
# Actualizar contador para evitar IDs duplicados
try:
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
except (IndexError, ValueError):
pass
if self.logger and self.sessions:
self.logger.info(
f"Loaded {len(self.sessions)} persistent plot sessions (stopped, ready for manual start)"
)
# Try to load from new separated files first
if os.path.exists(self.plot_definitions_file) and os.path.exists(
self.plot_variables_file
):
self._load_plots_separated()
# Fall back to legacy file and migrate if needed
elif os.path.exists(self.plots_file):
self._migrate_plots_from_legacy()
else:
# No plots found, start empty
self.sessions = {}
self.session_counter = 0
if self.logger:
self.logger.info(
"No persistent plots file found, starting with empty plots"
)
self.logger.info("No plot sessions found, starting fresh")
except Exception as e:
if self.logger:
self.logger.error(f"Error loading persistent plots: {e}")
self.logger.error(f"Error loading plot sessions: {e}")
# Continue with empty sessions
self.sessions = {}
self.session_counter = 0
def _load_plots_separated(self):
"""Load plots from separated definition and variable files"""
try:
# Load definitions
with open(self.plot_definitions_file, "r", encoding="utf-8") as f:
definitions_data = json.load(f)
# Load variables
with open(self.plot_variables_file, "r", encoding="utf-8") as f:
variables_data = json.load(f)
# Merge data back for session creation
plots_data = definitions_data.get("plots", {})
plot_vars = variables_data.get("plot_variables", {})
for session_id, plot_config in plots_data.items():
# Add variables to config
variables_info = plot_vars.get(session_id, {})
full_config = {
**plot_config,
"variables": variables_info.get("variables", []),
}
# Create session with full configuration
session = PlotSession(session_id, full_config)
# Load plots as inactive (user decides when to activate)
session.is_active = False
session.is_paused = False
self.sessions[session_id] = session
# Update counter to avoid duplicate IDs
try:
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
except (IndexError, ValueError):
pass
# Load counter from definitions
saved_counter = definitions_data.get("session_counter", len(self.sessions))
if saved_counter > self.session_counter:
self.session_counter = saved_counter
if self.logger and self.sessions:
self.logger.info(
f"Loaded {len(self.sessions)} plot sessions from separated files "
"(stopped, ready for manual start)"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error loading separated plots: {e}")
raise
def _migrate_plots_from_legacy(self):
"""Migrate plots from legacy single file to separated files"""
try:
with open(self.plots_file, "r", encoding="utf-8") as f:
legacy_data = json.load(f)
plots_data = legacy_data.get("plots", {})
for session_id, plot_config in plots_data.items():
# Create session with legacy configuration
session = PlotSession(session_id, plot_config)
session.is_active = False
session.is_paused = False
self.sessions[session_id] = session
# Update counter to avoid duplicate IDs
try:
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
except (IndexError, ValueError):
pass
# Load counter from legacy
saved_counter = legacy_data.get("session_counter", len(self.sessions))
if saved_counter > self.session_counter:
self.session_counter = saved_counter
# Save to new separated format
self.save_plots()
if self.logger:
self.logger.info(
f"Migrated {len(self.sessions)} plot sessions from legacy file"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error migrating legacy plots: {e}")
raise
def save_plots(self):
"""Guardar plots al archivo de persistencia"""
"""Guardar plots a archivos separados de persistencia"""
try:
plots_data = {}
# Prepare definitions data - only plots, no static fields
definitions_data = {
"plots": {},
}
# Prepare variables data - only variables, no static fields
variables_data = {
"plot_variables": {},
}
# Split sessions into definitions and variables
for session_id, session in self.sessions.items():
# Guardar configuración de la sesión (sin datos temporales)
plots_data[session_id] = {
# Extract definition (metadata without variables)
definitions_data["plots"][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,
"session_id": session_id, # Para referencia
"session_id": session_id,
}
data = {
"plots": plots_data,
"session_counter": self.session_counter,
"last_saved": datetime.now().isoformat(),
"version": "1.0",
}
# Extract variables
variables_data["plot_variables"][session_id] = {
"variables": session.variables,
}
with open(self.plots_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Save both files
with open(self.plot_definitions_file, "w", encoding="utf-8") as f:
json.dump(definitions_data, f, indent=2, ensure_ascii=False)
with open(self.plot_variables_file, "w", encoding="utf-8") as f:
json.dump(variables_data, f, indent=2, ensure_ascii=False)
if self.logger:
self.logger.debug(f"Saved {len(plots_data)} plot sessions to disk")
self.logger.debug(
f"Saved {len(self.sessions)} plot sessions to separated files"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error saving persistent plots: {e}")
self.logger.error(f"Error saving plot sessions: {e}")
def update_session_config(self, session_id: str, config: Dict[str, Any]) -> bool:
"""Actualizar configuración de una sesión existente"""

View File

@ -1,5 +1,6 @@
import json
import os
from datetime import datetime
from typing import Any, Dict, Optional
try:
@ -46,8 +47,13 @@ class ConfigSchemaManager:
os.makedirs(data_dir, exist_ok=True)
self.config_files: Dict[str, str] = {
"plc": os.path.join(data_dir, "plc_config.json"),
"datasets": os.path.join(data_dir, "plc_datasets.json"),
"plots": os.path.join(data_dir, "plot_sessions.json"),
"datasets": os.path.join(data_dir, "plc_datasets.json"), # Legacy support
"plots": os.path.join(data_dir, "plot_sessions.json"), # Legacy support
# New separated schemas
"dataset-definitions": os.path.join(data_dir, "dataset_definitions.json"),
"dataset-variables": os.path.join(data_dir, "dataset_variables.json"),
"plot-definitions": os.path.join(data_dir, "plot_definitions.json"),
"plot-variables": os.path.join(data_dir, "plot_variables.json"),
}
self._load_all_schemas()
@ -109,19 +115,68 @@ class ConfigSchemaManager:
"csv_config": self.config_manager.csv_config,
}
elif config_id == "datasets":
# Use ConfigManager data (which now loads from separated files)
return {
"datasets": self.config_manager.datasets,
"active_datasets": list(self.config_manager.active_datasets),
"current_dataset_id": self.config_manager.current_dataset_id,
"version": "1.0",
"last_update": datetime.now().isoformat(),
}
elif config_id == "plots":
# Leer del archivo para reflejar persistencia actual
# Build from PlotManager data (which now loads from separated files)
plots_data = {}
for session_id, session in self.plot_manager.sessions.items():
plots_data[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,
"session_id": session_id,
}
return {
"plots": plots_data,
"session_counter": self.plot_manager.session_counter,
"last_saved": datetime.now().isoformat(),
"version": "1.0",
}
# New separated configurations
elif config_id in [
"dataset-definitions",
"dataset-variables",
"plot-definitions",
"plot-variables",
]:
path = self.config_files[config_id]
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {"plots": {}, "session_counter": 0, "version": "1.0"}
# Return default structure for each type
if config_id == "dataset-definitions":
return {
"datasets": {},
"active_datasets": [],
"current_dataset_id": None,
"version": "1.0",
"last_update": None,
}
elif config_id == "dataset-variables":
return {"dataset_variables": {}, "version": "1.0", "last_update": None}
elif config_id == "plot-definitions":
return {
"plots": {},
"session_counter": 0,
"last_saved": None,
"version": "1.0",
}
elif config_id == "plot-variables":
return {"plot_variables": {}, "version": "1.0", "last_update": None}
else:
raise ValueError(f"Unknown config id '{config_id}'")
@ -185,33 +240,69 @@ class ConfigSchemaManager:
json.dump(self.read_config("plc"), f, indent=2)
return {"success": True}
if config_id == "datasets":
# Reemplazar estructuras completas de datasets de manera controlada
if config_id == "dataset-definitions":
# Manejar solo datasets individuales, calcular active_datasets automáticamente
datasets = data.get("datasets", {})
active = set(data.get("active_datasets", []))
current = data.get("current_dataset_id")
# Sobrescribir en ConfigManager y persistir
self.config_manager.datasets = datasets
self.config_manager.active_datasets = set(active)
self.config_manager.current_dataset_id = (
current if current in datasets else None
)
# Actualizar datasets en ConfigManager
self.config_manager.datasets.update(datasets)
# Calcular active_datasets automáticamente desde enabled field
self.config_manager._update_active_datasets()
# current_dataset_id es opcional para UI
current = data.get("current_dataset_id")
if current and current in datasets:
self.config_manager.current_dataset_id = current
self.config_manager.save_datasets()
return {"success": True}
if config_id == "plot-definitions":
# Manejar solo plots individuales, sin campos estáticos
plots = data.get("plots", {})
try:
# Clear existing sessions
self.plot_manager.sessions.clear()
# Load new sessions from plots data
for session_id, plot_config in plots.items():
# Import PlotSession from plot_manager
from .plot_manager import PlotSession
session = PlotSession(session_id, plot_config)
session.is_active = False
session.is_paused = False
self.plot_manager.sessions[session_id] = session
# Keep session_counter internally, but don't save it in files
if hasattr(self.plot_manager, "session_counter"):
pass # Keep existing counter
else:
self.plot_manager.session_counter = 0
# Save to separated files
self.plot_manager.save_plots()
except Exception as e:
if self.logger:
self.logger.warning(f"Could not update plot sessions: {e}")
raise ValueError(f"Failed to update plots: {e}")
return {"success": True}
if config_id == "plots":
# Guardar directamente y recargar gestor de plots
# Direct file write for variables (no static fields)
if config_id in ["dataset-variables", "plot-variables"]:
# Save only essential data, no timestamps
path = self.config_files[config_id]
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Recargar desde persistencia
try:
self.plot_manager.load_plots()
except Exception as e:
if self.logger:
self.logger.warning(f"Could not reload plot sessions: {e}")
# Log successful save
if self.logger:
self.logger.info(f"Saved {config_id} configuration to {path}")
return {"success": True}
raise ValueError(f"Unknown config id '{config_id}'")
@ -225,7 +316,15 @@ class ConfigSchemaManager:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.read_config("plc"), f, indent=2)
elif config_id == "datasets":
self.config_manager.save_datasets()
self.config_manager.save_datasets() # Now saves to separated files
elif config_id == "plots":
self.plot_manager.save_plots()
self.plot_manager.save_plots() # Now saves to separated files
elif config_id in [
"dataset-definitions",
"dataset-variables",
"plot-definitions",
"plot-variables",
]:
# These are already stored as files, no special sync needed
pass
return path

View File

@ -11,6 +11,14 @@
<body>
<div id="root"></div>
<!-- Chart.js Libraries - Load in strict order (compatible versions) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@2"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
<script src="https://unpkg.com/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0"></script>
<script type="module" src="/src/main.jsx"></script>
</body>

View File

@ -2,12 +2,12 @@ import React from 'react'
import recLogo from './assets/logo/record.png'
import { Routes, Route, Link } from 'react-router-dom'
import { Box, Container, Flex, HStack, Select, Button, Heading, Text, useColorMode, useColorModeValue, Stack } from '@chakra-ui/react'
import StatusPage from './pages/Status.jsx'
import EventsPage from './pages/Events.jsx'
import ConfigPage from './pages/Config.jsx'
import PlotsPage from './pages/Plots.jsx'
import PLCConfigModal from './components/PLCConfigModal.jsx'
import DashboardPage from './pages/Dashboard.jsx'
import DatasetManager from './components/DatasetManager.jsx'
function Home() {
return (
@ -85,10 +85,10 @@ function NavBar() {
<Heading as="span" size="sm">PLC Streamer</Heading>
</HStack>
<HStack spacing={2}>
<Button as={Link} to="/status" size="sm" variant="outline">Status</Button>
<Button as={Link} to="/datasets" size="sm" variant="outline">📊 Datasets</Button>
<Button as={Link} to="/events" size="sm" variant="outline">Events</Button>
<Button as={Link} to="/config" size="sm" variant="outline">Config</Button>
<Button as={Link} to="/plots" size="sm" variant="outline">Plots</Button>
<Button as={Link} to="/plots" size="sm" variant="outline">📈 Plots</Button>
<ColorModeSelector />
</HStack>
</Flex>
@ -102,19 +102,13 @@ function App() {
return (
<Box bg={useColorModeValue('gray.50', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} minH="100vh">
<NavBar />
<Container mt={3} mb={4}>
<Button size="sm" onClick={() => setShowPLCModal(true)} leftIcon={<span></span>}>
PLC Config
</Button>
</Container>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="/datasets" element={<DatasetManager />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/plots" element={<PlotsPage />} />
</Routes>
<PLCConfigModal show={showPLCModal} onClose={() => setShowPLCModal(false)} />
</Box>
)
}

View File

@ -0,0 +1,577 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
// Chart.js Plot Component with Streaming and Zoom
const ChartjsPlot = ({ session, height = '400px' }) => {
const canvasRef = useRef(null);
const chartRef = useRef(null);
const sessionDataRef = useRef({
lastDataFetch: 0,
datasetIndex: new Map(),
lastPushedXByDataset: new Map(),
ingestPaused: false,
insertNaNOnNextIngest: false,
isPaused: false,
isRealTimeMode: true,
refreshRate: 1000
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [dataPointsCount, setDataPointsCount] = useState(0);
const bgColor = useColorModeValue('white', 'gray.800');
const textColor = useColorModeValue('gray.600', 'gray.300');
// Chart.js colors for variables
const colors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#8A2BE2', '#FF1493', '#00CED1', '#32CD32',
'#FFB347', '#DA70D6', '#40E0D0', '#EE82EE', '#90EE90'
];
const getColor = useCallback((variable, index = null) => {
if (index !== null) {
return colors[index % colors.length];
}
const hash = hashCode(variable);
return colors[hash % colors.length];
}, [colors]);
const hashCode = (str) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
};
const getEnabledVariables = useCallback((variables) => {
if (!variables) return [];
if (Array.isArray(variables)) {
return variables.map((variable, index) => ({
name: variable,
color: getColor(variable, index),
enabled: true
}));
} else if (typeof variables === 'object') {
const firstVarConfig = Object.values(variables)[0];
if (firstVarConfig && 'variable_name' in firstVarConfig) {
return Object.entries(variables)
.filter(([id, config]) => config.enabled !== false && config.variable_name)
.map(([id, config]) => ({
name: config.variable_name,
color: config.color || getColor(config.variable_name),
enabled: config.enabled !== false
}));
} else {
return Object.entries(variables)
.filter(([name, config]) => config.enabled !== false)
.map(([name, config]) => ({
name: name,
color: config.color || getColor(name),
enabled: config.enabled !== false
}));
}
}
return [];
}, [getColor]);
const createStreamingChart = useCallback(async () => {
if (!canvasRef.current || !session?.config) return;
try {
// Ensure Chart.js and plugins are loaded
if (typeof window.Chart === 'undefined') {
throw new Error('Chart.js not loaded');
}
const Chart = window.Chart;
// Register zoom plugin if available
try {
if (window.ChartZoom && !Chart.registry.plugins.get('zoom')) {
Chart.register(window.ChartZoom);
}
} catch (e) {
console.warn('⚠️ Zoom plugin registration failed:', e);
}
const ctx = canvasRef.current.getContext('2d');
// Destroy existing chart
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
const config = session.config;
const enabledVariables = getEnabledVariables(config.variables);
const datasets = enabledVariables.map((variableInfo, index) => {
const color = variableInfo.color || getColor(variableInfo.name, index);
return {
label: variableInfo.name,
data: [],
borderColor: color,
backgroundColor: color + '20',
borderWidth: 2,
fill: false,
spanGaps: true,
pointRadius: 1,
pointHoverRadius: 4,
tension: 0.4
};
});
// Initialize dataset index mapping
sessionDataRef.current.datasetIndex.clear();
enabledVariables.forEach((variableInfo, index) => {
sessionDataRef.current.datasetIndex.set(variableInfo.name, index);
});
const chartConfig = {
type: 'line',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
layout: {
padding: { bottom: 16 }
},
scales: {
x: {
type: 'realtime',
realtime: {
duration: (config.time_window || 60) * 1000,
refresh: sessionDataRef.current.refreshRate,
delay: sessionDataRef.current.refreshRate * 2,
frameRate: 30,
pause: !session.is_active || session.is_paused,
onRefresh: (chart) => {
onStreamingRefresh(chart);
}
},
ticks: {
display: true,
autoSkip: true,
maxRotation: 0
},
time: {
displayFormats: {
millisecond: 'HH:mm:ss.SSS',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'HH:mm'
}
},
title: {
display: true,
text: 'Tiempo'
}
},
y: {
title: {
display: true,
text: 'Valor'
},
min: config.y_min,
max: config.y_max
}
},
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
},
zoom: {
pan: {
enabled: true,
mode: 'x'
},
zoom: {
pinch: { enabled: true },
wheel: { enabled: true },
mode: 'x'
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
elements: {
point: {
radius: 0,
hoverRadius: 3
},
line: {
tension: 0.1,
borderWidth: 2
}
}
}
};
// 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();
}
setIsLoading(false);
setError(null);
} catch (error) {
console.error('Error creating chart:', error);
setError(error.message);
setIsLoading(false);
}
}, [session, getEnabledVariables, getColor]);
const onStreamingRefresh = useCallback(async (chart) => {
if (!session?.session_id) return;
try {
const now = Date.now();
const refreshRate = sessionDataRef.current.refreshRate;
const minInterval = Math.max(refreshRate * 0.5, 50);
if (now - sessionDataRef.current.lastDataFetch < minInterval) {
return;
}
sessionDataRef.current.lastDataFetch = now;
// Fetch data from backend
const response = await fetch(`/api/plots/${session.session_id}/data`);
if (!response.ok) return;
const plotData = await response.json();
// Add new data to chart
addNewDataToStreaming(plotData, now);
updatePointsCounter(plotData);
} 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;
const chart = chartRef.current;
const sessionData = sessionDataRef.current;
// Check if paused
if (sessionData.ingestPaused) return;
let pointsAdded = 0;
chart.data.datasets.forEach((chartDataset, datasetIndex) => {
const lastPushedX = sessionData.lastPushedXByDataset.get(datasetIndex) || 0;
const backendDataset = plotData.datasets ? plotData.datasets[datasetIndex] : undefined;
if (!backendDataset || !Array.isArray(backendDataset.data) || backendDataset.data.length === 0) {
return;
}
// Filter and normalize points
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;
let xNum = typeof p.x === 'number' ? p.x : Number(p.x);
if (!isFinite(xNum)) continue;
if (xNum < 1e12) xNum = xNum * 1000; // seconds -> ms
if (xNum > lastPushedX) newPoints.push({ x: xNum, y: yNum });
}
if (newPoints.length === 0) return;
// Sort by x and ensure monotonicity
newPoints.sort((a, b) => a.x - b.x);
// Insert NaN gap if resuming after pause
if (sessionData.insertNaNOnNextIngest) {
const firstX = Math.max(newPoints[0].x, lastPushedX + 1);
let gapX = firstX - 1;
if (gapX <= lastPushedX) {
gapX = firstX - 0.001;
}
chartDataset.data.push({ x: gapX, y: NaN });
}
// Add all new points
let lastX = lastPushedX;
for (const p of newPoints) {
const finalX = Math.max(p.x, lastX + 1);
chartDataset.data.push({ x: finalX, y: p.y });
lastX = finalX;
pointsAdded++;
}
sessionData.lastPushedXByDataset.set(datasetIndex, lastX);
});
// Update chart
if (!sessionData.isRealTimeMode) {
cleanupOldDataFallback();
chart.update('quiet');
} else if (pointsAdded > 0) {
chart.update('quiet');
}
// Clear NaN insertion flag
if (pointsAdded > 0 && sessionData.insertNaNOnNextIngest) {
sessionData.insertNaNOnNextIngest = false;
}
}, []);
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]);
const updatePointsCounter = useCallback((plotData) => {
const totalPoints = plotData.data_points_count || 0;
setDataPointsCount(totalPoints);
}, []);
const startManualRefresh = useCallback(() => {
const sessionData = sessionDataRef.current;
if (sessionData.manualInterval) {
clearInterval(sessionData.manualInterval);
}
sessionData.manualInterval = setInterval(() => {
if (chartRef.current) {
onStreamingRefresh(chartRef.current);
}
}, sessionData.refreshRate);
}, [onStreamingRefresh]);
// Control functions for external use
const pauseStreaming = useCallback(() => {
const sessionData = sessionDataRef.current;
if (!chartRef.current) return;
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = true;
}
chart.update('quiet');
} else {
if (sessionData.manualInterval) {
clearInterval(sessionData.manualInterval);
sessionData.manualInterval = null;
}
}
sessionData.ingestPaused = true;
sessionData.isPaused = true;
}, []);
const resumeStreaming = useCallback(() => {
const sessionData = sessionDataRef.current;
if (!chartRef.current) return;
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = false;
}
chart.update('quiet');
} else {
if (!sessionData.manualInterval) {
startManualRefresh();
}
}
sessionData.insertNaNOnNextIngest = true;
sessionData.ingestPaused = false;
sessionData.isPaused = false;
}, [startManualRefresh]);
const clearChart = useCallback(() => {
if (!chartRef.current) return;
chartRef.current.data.datasets.forEach(dataset => {
if (dataset.data) {
dataset.data.length = 0;
}
});
chartRef.current.update('quiet');
setDataPointsCount(0);
}, []);
// Not using useImperativeHandle since we're exposing functions through props callback
// Also expose control functions through props for easier access
React.useEffect(() => {
if (typeof session?.onChartReady === 'function') {
session.onChartReady({
pauseStreaming,
resumeStreaming,
clearChart
});
}
}, [pauseStreaming, resumeStreaming, clearChart, session]);
// Update chart when session status changes
useEffect(() => {
if (!chartRef.current || !session) return;
const shouldPause = !session.is_active || session.is_paused;
if (shouldPause) {
pauseStreaming();
} else {
resumeStreaming();
}
}, [session?.is_active, session?.is_paused, pauseStreaming, resumeStreaming]);
// Initialize chart
useEffect(() => {
if (session?.config) {
createStreamingChart();
}
return () => {
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
if (sessionDataRef.current.manualInterval) {
clearInterval(sessionDataRef.current.manualInterval);
}
};
}, [createStreamingChart]);
if (isLoading) {
return (
<Box
height={height}
bg={bgColor}
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
borderWidth="1px"
borderColor="gray.200"
>
<Text color={textColor}>Loading Chart.js...</Text>
</Box>
);
}
if (error) {
return (
<Box
height={height}
bg={bgColor}
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
borderWidth="1px"
borderColor="red.200"
flexDirection="column"
p={4}
>
<Text color="red.500" fontSize="lg" mb={2}> Chart Error</Text>
<Text color={textColor} fontSize="sm" textAlign="center">{error}</Text>
<Text color={textColor} fontSize="xs" mt={2}>
Make sure Chart.js and chartjs-plugin-streaming are loaded
</Text>
</Box>
);
}
return (
<Box
height={height}
bg={bgColor}
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
position="relative"
>
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '100%',
borderRadius: '6px'
}}
/>
<Box
position="absolute"
top={2}
right={2}
bg="rgba(0,0,0,0.7)"
color="white"
px={2}
py={1}
borderRadius="sm"
fontSize="xs"
>
Points: {dataPointsCount}
</Box>
</Box>
);
};
export default ChartjsPlot;

View File

@ -0,0 +1,245 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Button
} from '@chakra-ui/react'
// No necesitamos Form completo, solo FormTable
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } 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() {
const [fullData, setFullData] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
const [datasetSchema, setDatasetSchema] = useState(null)
const [datasetUiSchema, setDatasetUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas completos
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('dataset-definitions'),
getSchema('dataset-variables')
])
// Cargar datos
const [datasetDataResp, variableDataResp] = await Promise.all([
readConfig('dataset-definitions'),
readConfig('dataset-variables')
])
// Usar schemas completos
setDatasetSchema(datasetSchemaResp.schema)
setDatasetUiSchema(datasetSchemaResp.ui_schema || {})
// Schema para variables individuales
const variableSchemaPath = variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables?.additionalProperties
const variableUiSchemaPath = variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables?.additionalProperties
setVariableSchema(variableSchemaPath)
setVariableUiSchema(variableUiSchemaPath || {})
setFullData(datasetDataResp.data || {})
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
// Seleccionar dataset actual
const currentId = datasetDataResp.data?.current_dataset_id
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
if (currentId && datasetIds.includes(currentId)) {
setSelectedDatasetId(currentId)
} else if (datasetIds.length > 0) {
setSelectedDatasetId(datasetIds[0])
}
} catch (error) {
console.error('Error loading complete data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveFullData = async (newData) => {
setSaving(true)
try {
await writeConfig('dataset-definitions', newData)
setFullData(newData)
setMessage('Datasets saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving data:', error)
setMessage(`Error saving datasets: ${error.message}`)
} finally {
setSaving(false)
}
}
const saveDatasets = async (newDatasets) => {
try {
// Solo enviar datasets, el backend calcula active_datasets automáticamente
const newFullData = {
datasets: newDatasets
}
await saveFullData(newFullData)
} catch (error) {
console.error('Error saving datasets:', error)
setMessage(`Error saving datasets: ${error.message}`)
}
}
const saveDatasetVariables = async (newVariables) => {
try {
const updatedDatasetVariables = {
...datasetVariables,
[selectedDatasetId]: {
variables: newVariables,
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
}
}
const saveData = {
dataset_variables: updatedDatasetVariables
}
await writeConfig('dataset-variables', saveData)
setDatasetVariables(updatedDatasetVariables)
setMessage('Dataset variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentDatasetVariables = useMemo(() => {
return selectedDatasetId && datasetVariables[selectedDatasetId]
? datasetVariables[selectedDatasetId].variables || {}
: {}
}, [selectedDatasetId, datasetVariables])
const datasetOptions = Object.entries(fullData.datasets || {}).map(([id, dataset]) => ({
value: id,
label: `${dataset.name || id} (${id})`
}))
if (loading) {
return <Text>Loading dataset configuration...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Datasets */}
<Card>
<CardHeader>
<Heading size="sm">📊 Dataset Management</Heading>
<Text fontSize="sm" color={muted}>
Manage your datasets: create, edit and configure
</Text>
</CardHeader>
<CardBody>
{datasetSchema?.properties?.datasets ? (
<FormTable
schema={datasetSchema.properties.datasets}
uiSchema={datasetUiSchema.datasets}
data={fullData.datasets || {}}
onChange={saveDatasets}
title="Datasets"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Dataset schema for individual datasets not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Dataset */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Dataset Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Dataset:</Text>
<Select
size="sm"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
placeholder="Select dataset"
width="200px"
>
{datasetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedDatasetId ? (
<Alert status="info">
<AlertIcon />
Select a dataset to manage its variables
</Alert>
) : variableSchema ? (
<FormTable
schema={variableSchema}
uiSchema={variableUiSchema}
data={currentDatasetVariables}
onChange={saveDatasetVariables}
title={`Variables for: ${fullData.datasets?.[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
/>
) : (
<Alert status="warning">
<AlertIcon />
Variable schema not available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,237 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider
} from '@chakra-ui/react'
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* DatasetFormManager - Gestiona datasets y variables usando FormTable
*/
export default function DatasetFormManager() {
const [datasets, setDatasets] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
const [datasetSchema, setDatasetSchema] = useState(null)
const [datasetUiSchema, setDatasetUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('dataset-definitions'),
getSchema('dataset-variables')
])
console.log('Dataset schema response:', datasetSchemaResp)
console.log('Variable schema response:', variableSchemaResp)
// Cargar datos
const [datasetDataResp, variableDataResp] = await Promise.all([
readConfig('dataset-definitions'),
readConfig('dataset-variables')
])
console.log('Dataset data response:', datasetDataResp)
console.log('Variable data response:', variableDataResp)
// Extraer schemas correctamente
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
console.log('Dataset full schema:', datasetSchemaResp.schema?.properties?.datasets)
console.log('Dataset full uiSchema:', datasetSchemaResp.ui_schema?.datasets)
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
console.log('Variable full schema:', variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
console.log('Variable full uiSchema:', variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables)
setDatasets(datasetDataResp.data?.datasets || {})
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
// Seleccionar primer dataset
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
if (datasetIds.length > 0 && !selectedDatasetId) {
setSelectedDatasetId(datasetIds[0])
}
} catch (error) {
console.error('Error loading data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveDatasets = async (newDatasets) => {
try {
const currentConfig = await readConfig('dataset-definitions')
const saveData = {
...currentConfig.data,
datasets: newDatasets,
last_update: new Date().toISOString()
}
await writeConfig('dataset-definitions', saveData)
setDatasets(newDatasets)
setMessage('Datasets saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving datasets:', error)
setMessage(`Error saving datasets: ${error.message}`)
}
}
const saveDatasetVariables = async (newVariables) => {
try {
const currentConfig = await readConfig('dataset-variables')
const updatedDatasetVariables = {
...datasetVariables,
[selectedDatasetId]: {
variables: newVariables,
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
}
}
const saveData = {
...currentConfig.data,
dataset_variables: updatedDatasetVariables,
last_update: new Date().toISOString()
}
await writeConfig('dataset-variables', saveData)
setDatasetVariables(updatedDatasetVariables)
setMessage('Dataset variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentDatasetVariables = useMemo(() => {
return selectedDatasetId && datasetVariables[selectedDatasetId]
? datasetVariables[selectedDatasetId].variables || {}
: {}
}, [selectedDatasetId, datasetVariables])
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
value: id,
label: `${dataset.name || id} (${id})`
}))
if (loading) {
return <Text>Loading datasets...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Datasets */}
<Card>
<CardHeader>
<Heading size="sm">📊 Dataset Definitions</Heading>
</CardHeader>
<CardBody>
{datasetSchema ? (
<FormTable
schema={datasetSchema}
uiSchema={datasetUiSchema}
data={datasets}
onChange={saveDatasets}
title="Datasets"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Dataset schema not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Dataset */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Dataset Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Dataset:</Text>
<Select
size="sm"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
placeholder="Select dataset"
width="200px"
>
{datasetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedDatasetId ? (
<Alert status="info">
<AlertIcon />
Select a dataset to manage its variables
</Alert>
) : variableSchema ? (
<FormTable
schema={variableSchema}
uiSchema={variableUiSchema}
data={currentDatasetVariables}
onChange={saveDatasetVariables}
title={`Variables for: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
/>
) : (
<Alert status="warning">
<AlertIcon />
Variable schema not available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,287 @@
import React, { useState, useEffect } from 'react'
import {
Box, Container, Heading, HStack, VStack, Button, Tabs, TabList, TabPanels, Tab, TabPanel,
Card, CardBody, CardHeader, Grid, GridItem, Text, Badge, Alert, AlertIcon,
useColorModeValue, Flex, Spacer, IconButton, useToast
} from '@chakra-ui/react'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
import DatasetVariableManager from './DatasetVariableManager.jsx'
export default function DatasetManager() {
const [datasetsSchema, setDatasetsSchema] = useState(null)
const [datasetsUiSchema, setDatasetsUiSchema] = useState(null)
const [datasetsData, setDatasetsData] = useState(null)
const [variablesSchema, setVariablesSchema] = useState(null)
const [variablesUiSchema, setVariablesUiSchema] = useState(null)
const [variablesData, setVariablesData] = useState(null)
const [loading, setLoading] = useState(true)
const [selectedDatasetId, setSelectedDatasetId] = useState(null)
const toast = useToast()
const cardBg = useColorModeValue('white', 'gray.700')
const borderColor = useColorModeValue('gray.200', 'gray.600')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [
datasetsSchemaResp, datasetsDataResp,
variablesSchemaResp, variablesDataResp
] = await Promise.all([
getSchema('dataset-definitions'),
readConfig('dataset-definitions'),
getSchema('dataset-variables'),
readConfig('dataset-variables')
])
setDatasetsSchema(datasetsSchemaResp.schema)
setDatasetsUiSchema(datasetsSchemaResp.ui_schema)
setDatasetsData(datasetsDataResp.data)
setVariablesSchema(variablesSchemaResp.schema)
setVariablesUiSchema(variablesSchemaResp.ui_schema)
setVariablesData(variablesDataResp.data)
// Set first dataset as selected if none selected
if (datasetsDataResp.data?.datasets && !selectedDatasetId) {
const firstDataset = Object.keys(datasetsDataResp.data.datasets)[0]
setSelectedDatasetId(firstDataset)
}
} catch (error) {
toast({
title: 'Error loading data',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
})
} finally {
setLoading(false)
}
}
const handleDatasetsSave = async ({ formData }) => {
try {
await writeConfig('dataset-definitions', formData)
setDatasetsData(formData)
toast({
title: 'Dataset definitions saved',
status: 'success',
duration: 3000,
isClosable: true
})
} catch (error) {
toast({
title: 'Error saving datasets',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
})
}
}
const handleVariablesSave = async ({ formData }) => {
try {
await writeConfig('dataset-variables', formData)
setVariablesData(formData)
toast({
title: 'Dataset variables saved',
status: 'success',
duration: 3000,
isClosable: true
})
} catch (error) {
toast({
title: 'Error saving variables',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
})
}
}
if (loading) {
return (
<Container maxW="container.xl" py={4}>
<Text>Loading dataset manager...</Text>
</Container>
)
}
return (
<Container maxW="container.xl" py={4}>
<VStack spacing={4} align="stretch">
<Flex align="center">
<Heading size="lg">📊 Dataset Manager</Heading>
<Spacer />
<Button size="sm" variant="outline" onClick={loadData}>
🔄 Refresh
</Button>
</Flex>
<DatasetOverview
datasets={datasetsData?.datasets || {}}
variables={variablesData?.dataset_variables || {}}
selectedDatasetId={selectedDatasetId}
onSelectDataset={setSelectedDatasetId}
/>
<Tabs variant="enclosed" colorScheme="blue">
<TabList>
<Tab>📋 Dataset Definitions</Tab>
<Tab> Dataset Variables</Tab>
<Tab>🔧 Variable Manager</Tab>
</TabList>
<TabPanels>
<TabPanel p={0} pt={4}>
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Heading size="md">Dataset Metadata Configuration</Heading>
<Text fontSize="sm" color="gray.500" mt={1}>
Configure dataset names, prefixes, sampling intervals and enable/disable datasets
</Text>
</CardHeader>
<CardBody>
{datasetsSchema && (
<Form
schema={datasetsSchema}
formData={datasetsData}
validator={validator}
onSubmit={handleDatasetsSave}
onChange={({ formData }) => setDatasetsData(formData)}
uiSchema={datasetsUiSchema}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<HStack spacing={2} mt={4}>
<Button type="submit" colorScheme="blue">💾 Save Definitions</Button>
<Button variant="outline" onClick={loadData}>🔄 Reset</Button>
</HStack>
</Form>
)}
</CardBody>
</Card>
</TabPanel>
<TabPanel p={0} pt={4}>
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Heading size="md">Dataset Variables Configuration</Heading>
<Text fontSize="sm" color="gray.500" mt={1}>
Raw JSON configuration for variables assigned to each dataset
</Text>
</CardHeader>
<CardBody>
{variablesSchema && (
<Form
schema={variablesSchema}
formData={variablesData}
validator={validator}
onSubmit={handleVariablesSave}
onChange={({ formData }) => setVariablesData(formData)}
uiSchema={variablesUiSchema}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<HStack spacing={2} mt={4}>
<Button type="submit" colorScheme="blue">💾 Save Variables</Button>
<Button variant="outline" onClick={loadData}>🔄 Reset</Button>
</HStack>
</Form>
)}
</CardBody>
</Card>
</TabPanel>
<TabPanel p={0} pt={4}>
<DatasetVariableManager
datasets={datasetsData?.datasets || {}}
variables={variablesData?.dataset_variables || {}}
selectedDatasetId={selectedDatasetId}
onSelectDataset={setSelectedDatasetId}
onVariablesUpdate={(newVariables) => {
const updatedData = {
...variablesData,
dataset_variables: newVariables
}
handleVariablesSave({ formData: updatedData })
}}
/>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</Container>
)
}
function DatasetOverview({ datasets, variables, selectedDatasetId, onSelectDataset }) {
const cardBg = useColorModeValue('white', 'gray.700')
const selectedBg = useColorModeValue('blue.50', 'blue.900')
const borderColor = useColorModeValue('gray.200', 'gray.600')
return (
<Box>
<Heading size="md" mb={3}>📊 Datasets Overview</Heading>
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
{Object.entries(datasets).map(([id, dataset]) => {
const varCount = variables[id]?.variables ? Object.keys(variables[id].variables).length : 0
const streamingCount = variables[id]?.streaming_variables?.length || 0
const isSelected = selectedDatasetId === id
return (
<Card
key={id}
bg={isSelected ? selectedBg : cardBg}
borderColor={isSelected ? 'blue.500' : borderColor}
borderWidth={isSelected ? 2 : 1}
cursor="pointer"
onClick={() => onSelectDataset(id)}
_hover={{ borderColor: 'blue.300' }}
>
<CardBody>
<VStack align="start" spacing={2}>
<HStack justify="space-between" w="full">
<Heading size="sm">{dataset.name}</Heading>
<Badge colorScheme={dataset.enabled ? 'green' : 'red'}>
{dataset.enabled ? 'Active' : 'Inactive'}
</Badge>
</HStack>
<Text fontSize="sm" color="gray.500">
ID: {id} Prefix: {dataset.prefix}
</Text>
<HStack spacing={4}>
<Text fontSize="sm">
🔧 {varCount} variables
</Text>
<Text fontSize="sm">
📡 {streamingCount} streaming
</Text>
</HStack>
{dataset.sampling_interval && (
<Text fontSize="sm" color="blue.500">
{dataset.sampling_interval}s interval
</Text>
)}
</VStack>
</CardBody>
</Card>
)
})}
</Grid>
</Box>
)
}

View File

@ -0,0 +1,264 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider
} from '@chakra-ui/react'
import EditableTable from './EditableTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* DatasetTableManager - Componente para gestionar datasets y sus variables
* Muestra tabla de datasets y tabla de variables del dataset seleccionado
*/
export default function DatasetTableManager() {
const [datasets, setDatasets] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
const [datasetSchema, setDatasetSchema] = useState(null)
const [datasetUiSchema, setDatasetUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
// Cargar schemas y datos al montar
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('dataset-definitions'),
getSchema('dataset-variables')
])
// Cargar datos de configuración
const [datasetDataResp, variableDataResp] = await Promise.all([
readConfig('dataset-definitions'),
readConfig('dataset-variables')
])
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
setDatasets(datasetDataResp.data?.datasets || {})
setDatasetVariables(datasetDataResp.data?.dataset_variables || {})
// Seleccionar el primer dataset si existe
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
if (datasetIds.length > 0 && !selectedDatasetId) {
setSelectedDatasetId(datasetIds[0])
}
} catch (error) {
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveDatasets = async (newDatasets) => {
setSaving(true)
setMessage('')
try {
// Construir el objeto completo para guardar
const saveData = {
datasets: newDatasets,
// Mantener otros campos existentes
active_datasets: [], // Esto se puede gestionar por separado
current_dataset_id: selectedDatasetId,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('dataset-definitions', saveData)
setDatasets(newDatasets)
setMessage('Datasets saved successfully')
} catch (error) {
setMessage(`Error saving datasets: ${error.message}`)
} finally {
setSaving(false)
}
}
const saveDatasetVariables = async (newVariables) => {
setSaving(true)
setMessage('')
try {
const updatedDatasetVariables = {
...datasetVariables,
[selectedDatasetId]: {
variables: newVariables,
streaming_variables: [] // Esto se puede calcular automáticamente
}
}
const saveData = {
dataset_variables: updatedDatasetVariables,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('dataset-variables', saveData)
setDatasetVariables(updatedDatasetVariables)
setMessage('Dataset variables saved successfully')
} catch (error) {
setMessage(`Error saving variables: ${error.message}`)
} finally {
setSaving(false)
}
}
// Convertir datos de datasets para el componente EditableTable
const datasetsForTable = useMemo(() => {
return Object.entries(datasets).map(([id, data]) => ({
id,
...data
}))
}, [datasets])
// Convertir variables del dataset seleccionado para el componente EditableTable
const variablesForTable = useMemo(() => {
if (!selectedDatasetId || !datasetVariables[selectedDatasetId]) {
return []
}
const variables = datasetVariables[selectedDatasetId].variables || {}
return Object.entries(variables).map(([name, data]) => ({
name,
...data
}))
}, [selectedDatasetId, datasetVariables])
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
value: id,
label: `${dataset.name} (${id})`
}))
if (loading) {
return <Text>Loading datasets...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Datasets */}
<Card>
<CardHeader>
<Heading size="sm">📊 Datasets</Heading>
</CardHeader>
<CardBody>
{datasetSchema ? (
<EditableTable
schema={datasetSchema}
uiSchema={datasetUiSchema.additionalProperties || {}}
data={datasetsForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newDatasets = {}
newData.forEach(item => {
const { id, ...rest } = item
newDatasets[id] = rest
})
saveDatasets(newDatasets)
}}
title="Dataset Definitions"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
No dataset schema available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Selector de Dataset y Tabla de Variables */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Dataset Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Dataset:</Text>
<Select
size="sm"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
placeholder="Select dataset"
width="200px"
>
{datasetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedDatasetId ? (
<Alert status="info">
<AlertIcon />
Select a dataset to manage its variables
</Alert>
) : variableSchema ? (
<EditableTable
schema={variableSchema}
uiSchema={variableUiSchema.additionalProperties || {}}
data={variablesForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newVariables = {}
newData.forEach(item => {
const { name, ...rest } = item
newVariables[name] = rest
})
saveDatasetVariables(newVariables)
}}
title={`Variables for dataset: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
/>
) : (
<Alert status="warning">
<AlertIcon />
No variable schema available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,426 @@
import React, { useState } from 'react'
import {
Box, VStack, HStack, Card, CardBody, CardHeader, Heading, Text, Button,
Grid, GridItem, Badge, Select, FormControl, FormLabel, Input, NumberInput,
NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper,
Checkbox, IconButton, useColorModeValue, Flex, Spacer, useToast,
Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter,
ModalCloseButton, useDisclosure, Alert, AlertIcon
} from '@chakra-ui/react'
const PLC_AREAS = [
{ value: 'db', label: 'DB (Data Block)' },
{ value: 'mw', label: 'MW (Memory Word)' },
{ value: 'm', label: 'M (Memory)' },
{ value: 'pew', label: 'PEW (Process Input Word)' },
{ value: 'pe', label: 'PE (Process Input)' },
{ value: 'paw', label: 'PAW (Process Output Word)' },
{ value: 'pa', label: 'PA (Process Output)' },
{ value: 'e', label: 'E (Input)' },
{ value: 'a', label: 'A (Output)' },
{ value: 'mb', label: 'MB (Memory Byte)' }
]
const DATA_TYPES = [
{ value: 'real', label: 'REAL (32-bit float)' },
{ value: 'int', label: 'INT (16-bit signed)' },
{ value: 'bool', label: 'BOOL (1-bit)' },
{ value: 'dint', label: 'DINT (32-bit signed)' },
{ value: 'word', label: 'WORD (16-bit unsigned)' },
{ value: 'byte', label: 'BYTE (8-bit unsigned)' },
{ value: 'uint', label: 'UINT (16-bit unsigned)' },
{ value: 'udint', label: 'UDINT (32-bit unsigned)' },
{ value: 'sint', label: 'SINT (8-bit signed)' },
{ value: 'usint', label: 'USINT (8-bit unsigned)' }
]
export default function DatasetVariableManager({
datasets,
variables,
selectedDatasetId,
onSelectDataset,
onVariablesUpdate
}) {
const { isOpen, onOpen, onClose } = useDisclosure()
const [editingVariable, setEditingVariable] = useState(null)
const toast = useToast()
const cardBg = useColorModeValue('white', 'gray.700')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const selectedDataset = selectedDatasetId ? datasets[selectedDatasetId] : null
const datasetVariables = selectedDatasetId ? variables[selectedDatasetId]?.variables || {} : {}
const streamingVariables = selectedDatasetId ? variables[selectedDatasetId]?.streaming_variables || [] : []
const handleAddVariable = () => {
setEditingVariable({
name: '',
area: 'db',
db: 1,
offset: 0,
type: 'real',
streaming: false
})
onOpen()
}
const handleEditVariable = (varName) => {
const variable = datasetVariables[varName]
setEditingVariable({
name: varName,
...variable
})
onOpen()
}
const handleSaveVariable = (variableData) => {
if (!selectedDatasetId) return
const newVariables = { ...variables }
// Initialize dataset if it doesn't exist
if (!newVariables[selectedDatasetId]) {
newVariables[selectedDatasetId] = {
variables: {},
streaming_variables: []
}
}
const oldName = editingVariable?.name
const newName = variableData.name
// Remove old variable if name changed
if (oldName && oldName !== newName && newVariables[selectedDatasetId].variables[oldName]) {
delete newVariables[selectedDatasetId].variables[oldName]
// Update streaming_variables array
newVariables[selectedDatasetId].streaming_variables =
newVariables[selectedDatasetId].streaming_variables.filter(v => v !== oldName)
}
// Add/update variable
const { name, ...varConfig } = variableData
newVariables[selectedDatasetId].variables[name] = varConfig
// Update streaming_variables array
const currentStreamingVars = newVariables[selectedDatasetId].streaming_variables.filter(v => v !== name)
if (varConfig.streaming) {
currentStreamingVars.push(name)
}
newVariables[selectedDatasetId].streaming_variables = currentStreamingVars
onVariablesUpdate(newVariables)
onClose()
setEditingVariable(null)
toast({
title: 'Variable saved',
status: 'success',
duration: 2000,
isClosable: true
})
}
const handleDeleteVariable = (varName) => {
if (!selectedDatasetId) return
const newVariables = { ...variables }
delete newVariables[selectedDatasetId].variables[varName]
newVariables[selectedDatasetId].streaming_variables =
newVariables[selectedDatasetId].streaming_variables.filter(v => v !== varName)
onVariablesUpdate(newVariables)
toast({
title: 'Variable deleted',
status: 'info',
duration: 2000,
isClosable: true
})
}
const toggleVariableStreaming = (varName) => {
if (!selectedDatasetId) return
const newVariables = { ...variables }
const variable = newVariables[selectedDatasetId].variables[varName]
variable.streaming = !variable.streaming
const currentStreamingVars = newVariables[selectedDatasetId].streaming_variables.filter(v => v !== varName)
if (variable.streaming) {
currentStreamingVars.push(varName)
}
newVariables[selectedDatasetId].streaming_variables = currentStreamingVars
onVariablesUpdate(newVariables)
}
if (!selectedDataset) {
return (
<Alert status="info">
<AlertIcon />
Select a dataset from the overview above to manage its variables.
</Alert>
)
}
return (
<VStack spacing={4} align="stretch">
<Card bg={cardBg} borderColor={borderColor}>
<CardHeader>
<Flex align="center">
<Box>
<Heading size="md">Variables for "{selectedDataset.name}"</Heading>
<Text fontSize="sm" color="gray.500" mt={1}>
ID: {selectedDatasetId} {Object.keys(datasetVariables).length} variables {streamingVariables.length} streaming
</Text>
</Box>
<Spacer />
<Button colorScheme="blue" onClick={handleAddVariable}>
Add Variable
</Button>
</Flex>
</CardHeader>
<CardBody>
{Object.keys(datasetVariables).length === 0 ? (
<Text color="gray.500" textAlign="center" py={8}>
No variables configured for this dataset.
Click "Add Variable" to get started.
</Text>
) : (
<Grid templateColumns="repeat(auto-fill, minmax(350px, 1fr))" gap={4}>
{Object.entries(datasetVariables).map(([varName, variable]) => (
<VariableCard
key={varName}
name={varName}
variable={variable}
isStreaming={streamingVariables.includes(varName)}
onEdit={() => handleEditVariable(varName)}
onDelete={() => handleDeleteVariable(varName)}
onToggleStreaming={() => toggleVariableStreaming(varName)}
/>
))}
</Grid>
)}
</CardBody>
</Card>
<VariableEditModal
isOpen={isOpen}
onClose={() => {
onClose()
setEditingVariable(null)
}}
variable={editingVariable}
onSave={handleSaveVariable}
/>
</VStack>
)
}
function VariableCard({ name, variable, isStreaming, onEdit, onDelete, onToggleStreaming }) {
const cardBg = useColorModeValue('gray.50', 'gray.600')
const borderColor = useColorModeValue('gray.200', 'gray.500')
const getAreaLabel = (area) => PLC_AREAS.find(a => a.value === area)?.label || area.toUpperCase()
const getTypeLabel = (type) => DATA_TYPES.find(t => t.value === type)?.label || type.toUpperCase()
return (
<Card bg={cardBg} borderColor={borderColor} size="sm">
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Heading size="sm" color="blue.600">{name}</Heading>
<HStack spacing={1}>
<IconButton
icon="✏️"
size="xs"
variant="ghost"
onClick={onEdit}
aria-label="Edit variable"
/>
<IconButton
icon="🗑️"
size="xs"
variant="ghost"
colorScheme="red"
onClick={onDelete}
aria-label="Delete variable"
/>
</HStack>
</HStack>
<Grid templateColumns="1fr 1fr" gap={2} w="full" fontSize="sm">
<Text><strong>Area:</strong> {getAreaLabel(variable.area)}</Text>
<Text><strong>Type:</strong> {getTypeLabel(variable.type)}</Text>
{variable.area === 'db' && (
<Text><strong>DB:</strong> {variable.db}</Text>
)}
<Text><strong>Offset:</strong> {variable.offset}</Text>
{variable.bit !== undefined && (
<Text><strong>Bit:</strong> {variable.bit}</Text>
)}
</Grid>
<HStack justify="space-between" w="full">
<Badge colorScheme={isStreaming ? 'green' : 'gray'}>
{isStreaming ? '📡 Streaming' : '📴 Not streaming'}
</Badge>
<Button
size="xs"
variant="outline"
colorScheme={isStreaming ? 'red' : 'green'}
onClick={onToggleStreaming}
>
{isStreaming ? 'Disable' : 'Enable'} Streaming
</Button>
</HStack>
</VStack>
</CardBody>
</Card>
)
}
function VariableEditModal({ isOpen, onClose, variable, onSave }) {
const [formData, setFormData] = useState(variable || {})
React.useEffect(() => {
setFormData(variable || {})
}, [variable])
const handleSubmit = (e) => {
e.preventDefault()
if (!formData.name || !formData.area || !formData.type) {
return
}
onSave(formData)
}
const isEditing = variable?.name
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{isEditing ? `Edit Variable: ${variable.name}` : 'Add New Variable'}
</ModalHeader>
<ModalCloseButton />
<form onSubmit={handleSubmit}>
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Variable Name</FormLabel>
<Input
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Temperature_Tank_1"
/>
</FormControl>
<Grid templateColumns="1fr 1fr" gap={4} w="full">
<FormControl isRequired>
<FormLabel>Memory Area</FormLabel>
<Select
value={formData.area || 'db'}
onChange={(e) => setFormData({ ...formData, area: e.target.value })}
>
{PLC_AREAS.map(area => (
<option key={area.value} value={area.value}>
{area.label}
</option>
))}
</Select>
</FormControl>
<FormControl isRequired>
<FormLabel>Data Type</FormLabel>
<Select
value={formData.type || 'real'}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
{DATA_TYPES.map(type => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</Select>
</FormControl>
</Grid>
<Grid templateColumns="1fr 1fr 1fr" gap={4} w="full">
{formData.area === 'db' && (
<FormControl>
<FormLabel>DB Number</FormLabel>
<NumberInput
value={formData.db || 1}
onChange={(_, num) => setFormData({ ...formData, db: num })}
min={1} max={9999}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
)}
<FormControl isRequired>
<FormLabel>Offset</FormLabel>
<NumberInput
value={formData.offset || 0}
onChange={(_, num) => setFormData({ ...formData, offset: num })}
min={0} max={8191}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
{['e', 'a', 'mb'].includes(formData.area) && (
<FormControl>
<FormLabel>Bit Position</FormLabel>
<NumberInput
value={formData.bit || 0}
onChange={(_, num) => setFormData({ ...formData, bit: num })}
min={0} max={7}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</FormControl>
)}
</Grid>
<FormControl>
<Checkbox
isChecked={formData.streaming || false}
onChange={(e) => setFormData({ ...formData, streaming: e.target.checked })}
>
Enable streaming to PlotJuggler
</Checkbox>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="outline" mr={3} onClick={onClose}>
Cancel
</Button>
<Button type="submit" colorScheme="blue">
{isEditing ? 'Update' : 'Add'} Variable
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,355 @@
import React, { useState, useMemo } from 'react'
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Input,
Select,
Checkbox,
IconButton,
HStack,
VStack,
Text,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
Alert,
AlertIcon,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
useColorModeValue
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
/**
* EditableTable - Componente reutilizable para editar arrays de objetos en forma de tabla
*
* @param {Object} schema - JSON Schema que define la estructura de los objetos
* @param {Object} uiSchema - UI Schema con widgets y configuraciones
* @param {Array} data - Array de objetos a editar
* @param {Function} onChange - Callback que se ejecuta al cambiar los datos: (newData) => void
* @param {string} title - Título de la tabla
* @param {string} keyField - Campo que actúa como clave única (ej: 'id', 'name')
*/
export default function EditableTable({
schema,
uiSchema = {},
data = [],
onChange,
title = "Data",
keyField = "id",
allowAdd = true,
allowEdit = true,
allowDelete = true
}) {
const [editingKey, setEditingKey] = useState(null)
const [editingData, setEditingData] = useState({})
const [newItem, setNewItem] = useState({})
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
// Extraer propiedades del schema
const properties = useMemo(() => {
if (!schema?.properties) return {}
return schema.properties
}, [schema])
const propertyNames = useMemo(() => {
return Object.keys(properties)
}, [properties])
// Convertir array de objetos a formato objeto con keys
const dataAsObject = useMemo(() => {
if (Array.isArray(data)) {
const result = {}
data.forEach((item, index) => {
const key = item[keyField] || `item_${index}`
result[key] = item
})
return result
}
return data || {}
}, [data, keyField])
const dataKeys = Object.keys(dataAsObject)
const handleDelete = (key) => {
const newDataObj = { ...dataAsObject }
delete newDataObj[key]
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
}
const handleAdd = () => {
if (!newItem[keyField]) {
alert(`Please provide a ${keyField}`)
return
}
const newDataObj = { ...dataAsObject }
newDataObj[newItem[keyField]] = { ...newItem }
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
setNewItem({})
onAddClose()
}
const handleEdit = () => {
if (!editingKey) return
const newDataObj = { ...dataAsObject }
newDataObj[editingKey] = { ...editingData }
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
setEditingKey(null)
setEditingData({})
onEditClose()
}
const openEdit = (key) => {
setEditingKey(key)
setEditingData({ ...dataAsObject[key] })
onEditOpen()
}
const renderInput = (propertyName, value, setValue, itemData = {}) => {
const property = properties[propertyName]
const uiConfig = uiSchema[propertyName] || {}
const widget = uiConfig['ui:widget'] || 'text'
const commonProps = {
size: 'sm',
value: value || '',
onChange: (e) => setValue({ ...itemData, [propertyName]: e.target.value })
}
if (property?.enum && widget === 'select') {
return (
<Select {...commonProps} placeholder="Select...">
{property.enum.map(option => (
<option key={option} value={option}>{option}</option>
))}
</Select>
)
}
if (property?.type === 'boolean' || widget === 'checkbox') {
return (
<Checkbox
isChecked={!!value}
onChange={(e) => setValue({ ...itemData, [propertyName]: e.target.checked })}
/>
)
}
if (property?.type === 'number' || property?.type === 'integer' || widget === 'updown') {
return (
<NumberInput
size="sm"
value={value || ''}
onChange={(valueString) => setValue({ ...itemData, [propertyName]: parseFloat(valueString) || null })}
min={property?.minimum}
max={property?.maximum}
step={property?.type === 'integer' ? 1 : 0.01}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
)
}
return <Input {...commonProps} placeholder={uiConfig['ui:placeholder'] || ''} />
}
const renderValue = (propertyName, value) => {
const property = properties[propertyName]
if (property?.type === 'boolean') {
return <Checkbox isChecked={!!value} isReadOnly />
}
if (value === null || value === undefined) {
return <Text color={muted}>-</Text>
}
return <Text>{String(value)}</Text>
}
const getColumnTitle = (propertyName) => {
const property = properties[propertyName]
return property?.title || propertyName
}
if (propertyNames.length === 0) {
return (
<Alert status="warning">
<AlertIcon />
No schema properties defined for this table
</Alert>
)
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
{allowAdd && (
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
Add Item
</Button>
)}
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
{propertyNames.map(prop => (
<Th key={prop}>{getColumnTitle(prop)}</Th>
))}
{(allowEdit || allowDelete) && <Th width="100px">Actions</Th>}
</Tr>
</Thead>
<Tbody>
{dataKeys.length === 0 ? (
<Tr>
<Td colSpan={propertyNames.length + 1}>
<Text color={muted} textAlign="center">No items</Text>
</Td>
</Tr>
) : (
dataKeys.map(key => (
<Tr key={key}>
{propertyNames.map(prop => (
<Td key={prop}>
{renderValue(prop, dataAsObject[key][prop])}
</Td>
))}
{(allowEdit || allowDelete) && (
<Td>
<HStack spacing={1}>
{allowEdit && (
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(key)}
/>
)}
{allowDelete && (
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(key)}
/>
)}
</HStack>
</Td>
)}
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isAddOpen} onClose={onAddClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add New Item</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
{propertyNames.map(prop => (
<Box key={prop} width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
{getColumnTitle(prop)}
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
</Text>
{renderInput(prop, newItem[prop], setNewItem, newItem)}
{uiSchema[prop]?.['ui:help'] && (
<Text fontSize="xs" color={muted} mt={1}>
{uiSchema[prop]['ui:help']}
</Text>
)}
</Box>
))}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Item: {editingKey}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
{propertyNames.map(prop => (
<Box key={prop} width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
{getColumnTitle(prop)}
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
</Text>
{renderInput(prop, editingData[prop], setEditingData, editingData)}
{uiSchema[prop]?.['ui:help'] && (
<Text fontSize="xs" color={muted} mt={1}>
{uiSchema[prop]['ui:help']}
</Text>
)}
</Box>
))}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}

View File

@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react'
import {
Box,
VStack,
HStack,
Text,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
IconButton,
Flex,
Badge
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from './rjsf/widgets.jsx'
/**
* FormTable - Muestra objetos como filas de formularios usando schemas RJSF
*/
export default function FormTable({
schema,
uiSchema = {},
data = {},
onChange,
title = "Data",
keyField = "id",
allowAdd = true,
allowDelete = true
}) {
const [editingKey, setEditingKey] = useState(null)
const [addingNew, setAddingNew] = useState(false)
const [newKey, setNewKey] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
if (!schema || !schema.additionalProperties) {
return (
<Alert status="warning">
<AlertIcon />
Schema not available for {title}
</Alert>
)
}
const itemSchema = schema.additionalProperties
// Usar el uiSchema directamente ya que se pasa el contenido de additionalProperties desde el parent
const itemUiSchema = uiSchema || {}
const dataKeys = Object.keys(data)
const handleAdd = (formData) => {
if (!newKey) {
alert('Please provide a key/ID')
return
}
const newData = {
...data,
[newKey]: formData
}
onChange(newData)
setAddingNew(false)
setNewKey('')
}
const handleEdit = (key, formData) => {
const newData = {
...data,
[key]: formData
}
onChange(newData)
setEditingKey(null)
}
const handleDelete = (key) => {
if (confirm(`¿Eliminar "${key}"?`)) {
const newData = { ...data }
delete newData[key]
onChange(newData)
}
}
const generateNewKey = () => {
const baseName = keyField === 'id' ? 'item' : 'new'
let counter = 1
let newKey = `${baseName}_${counter}`
while (data[newKey]) {
counter++
newKey = `${baseName}_${counter}`
}
return newKey
}
const startAdd = () => {
setNewKey(generateNewKey())
setAddingNew(true)
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
{allowAdd && (
<Button size="sm" leftIcon={<AddIcon />} onClick={startAdd}>
Add Item
</Button>
)}
</HStack>
{dataKeys.length === 0 && !addingNew && (
<Box p={4} borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Text color={muted} textAlign="center">No items found</Text>
</Box>
)}
{/* Formulario para agregar nuevo item */}
{addingNew && (
<Card borderColor="blue.200" borderWidth="2px">
<CardHeader pb={2}>
<HStack justify="space-between">
<Heading size="xs" color="blue.600">
Adding: {newKey}
</Heading>
<Button size="xs" variant="ghost" onClick={() => setAddingNew(false)}>
Cancel
</Button>
</HStack>
</CardHeader>
<CardBody pt={0}>
<Form
schema={itemSchema}
uiSchema={itemUiSchema}
formData={{}}
validator={validator}
onSubmit={({ formData }) => handleAdd(formData)}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
showErrorList={false}
>
<HStack mt={3}>
<Button type="submit" size="sm" colorScheme="blue">
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setAddingNew(false)}>
Cancel
</Button>
</HStack>
</Form>
</CardBody>
</Card>
)}
{/* Formularios para items existentes */}
{dataKeys.map(key => (
<Card key={key} borderWidth="1px" borderColor={borderColor}>
<CardHeader pb={2}>
<HStack justify="space-between">
<HStack>
<Heading size="xs">{key}</Heading>
{editingKey === key && (
<Badge colorScheme="orange" size="sm">Editing</Badge>
)}
</HStack>
<HStack spacing={1}>
{editingKey === key ? (
<Button
size="xs"
variant="ghost"
onClick={() => setEditingKey(null)}
>
Cancel
</Button>
) : (
<>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => setEditingKey(key)}
/>
{allowDelete && (
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(key)}
/>
)}
</>
)}
</HStack>
</HStack>
</CardHeader>
<CardBody pt={0}>
<Form
schema={itemSchema}
uiSchema={itemUiSchema}
formData={data[key] || {}}
validator={validator}
onChange={editingKey === key ? undefined : () => { }}
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
readonly={editingKey !== key}
showErrorList={false}
>
{editingKey === key && (
<HStack mt={3}>
<Button type="submit" size="sm" colorScheme="blue">
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingKey(null)}>
Cancel
</Button>
</HStack>
)}
</Form>
</CardBody>
</Card>
))}
</VStack>
)
}

View File

@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react'
import {
Box,
VStack,
HStack,
Text,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Badge,
IconButton
} from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from './rjsf/widgets.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PLCConfigManager - Gestiona la configuración PLC con habilitadores de edición
* Incluye: formulario principal con Save/Cancel buttons
*/
export default function PLCConfigManager() {
const [schema, setSchema] = useState(null)
const [uiSchema, setUiSchema] = useState({})
const [originalData, setOriginalData] = useState({})
const [currentData, setCurrentData] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schema y datos
const [schemaResp, dataResp] = await Promise.all([
getSchema('plc'),
readConfig('plc')
])
setSchema(schemaResp.schema)
setUiSchema(schemaResp.ui_schema || {})
setOriginalData(dataResp.data || {})
setCurrentData(dataResp.data || {})
} catch (error) {
console.error('Error loading PLC config:', error)
setMessage(`Error loading configuration: ${error.message}`)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
await writeConfig('plc', currentData)
setOriginalData(currentData)
setEditing(false)
setMessage('PLC configuration saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving PLC config:', error)
setMessage(`Error saving configuration: ${error.message}`)
} finally {
setSaving(false)
}
}
const handleCancel = () => {
setCurrentData(originalData)
setEditing(false)
setMessage('')
}
const handleEdit = () => {
setEditing(true)
setMessage('')
}
const handleChange = ({ formData }) => {
setCurrentData(formData)
}
if (loading) {
return <Text>Loading PLC configuration...</Text>
}
if (!schema) {
return (
<Alert status="warning">
<AlertIcon />
PLC configuration schema not available
</Alert>
)
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
<Card borderWidth="1px" borderColor={editing ? 'blue.200' : borderColor}>
<CardHeader pb={2}>
<HStack justify="space-between">
<HStack>
<Heading size="sm">🧩 PLC Configuration</Heading>
{editing && (
<Badge colorScheme="orange" size="sm">Editing</Badge>
)}
</HStack>
<HStack spacing={2}>
{editing ? (
<>
<Button
size="sm"
colorScheme="blue"
isLoading={saving}
onClick={handleSave}
>
💾 Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
isDisabled={saving}
>
Cancel
</Button>
</>
) : (
<IconButton
icon={<EditIcon />}
size="sm"
variant="outline"
onClick={handleEdit}
/>
)}
</HStack>
</HStack>
<Text fontSize="sm" color={muted}>
Configure PLC connection, UDP streaming and CSV recording settings
</Text>
</CardHeader>
<CardBody pt={0}>
<Form
schema={schema}
uiSchema={uiSchema}
formData={currentData}
validator={validator}
onChange={editing ? handleChange : () => { }}
onSubmit={editing ? () => handleSave() : undefined}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
readonly={!editing}
showErrorList={false}
>
{/* No submit button here, we handle it in the header */}
<div />
</Form>
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,264 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Button
} from '@chakra-ui/react'
// No necesitamos Form completo, solo FormTable
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PlotCompleteManager - Gestiona plots y variables de forma simplificada
* Incluye: tabla de plots individuales + variables (sin campos estáticos de configuración)
*/
export default function PlotCompleteManager() {
const [fullData, setFullData] = useState({})
const [plotVariables, setPlotVariables] = useState({})
const [selectedPlotId, setSelectedPlotId] = useState('')
const [plotSchema, setPlotSchema] = useState(null)
const [plotUiSchema, setPlotUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas completos
const [plotSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('plot-definitions'),
getSchema('plot-variables')
])
console.log('Complete plot schema response:', plotSchemaResp)
console.log('Complete variable schema response:', variableSchemaResp)
// Cargar datos
const [plotDataResp, variableDataResp] = await Promise.all([
readConfig('plot-definitions'),
readConfig('plot-variables')
])
console.log('Complete plot data response:', plotDataResp)
console.log('Complete variable data response:', variableDataResp)
// Usar schemas completos
setPlotSchema(plotSchemaResp.schema)
setPlotUiSchema(plotSchemaResp.ui_schema || {})
// Debug para schema de variables
console.log('Variable schema structure:', variableSchemaResp.schema)
console.log('Variable schema props:', variableSchemaResp.schema?.properties)
console.log('Plot variables props:', variableSchemaResp.schema?.properties?.plot_variables)
console.log('Additional props:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties)
console.log('Variables property:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables)
console.log('Final variables schema:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables?.additionalProperties)
// Debug para UI schema de variables
console.log('Variable ui schema structure:', variableSchemaResp.ui_schema)
console.log('UI schema plot_variables:', variableSchemaResp.ui_schema?.plot_variables)
console.log('UI schema additionalProperties:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties)
console.log('UI schema variables:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables)
console.log('Final ui schema:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables?.additionalProperties)
// Schema para variables individuales (debe contener additionalProperties)
setVariableSchema(variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables)
setVariableUiSchema(variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables?.additionalProperties || {})
setFullData(plotDataResp.data || {})
setPlotVariables(variableDataResp.data?.plot_variables || {})
// Seleccionar primer plot
const plotIds = Object.keys(plotDataResp.data?.plots || {})
if (plotIds.length > 0 && !selectedPlotId) {
setSelectedPlotId(plotIds[0])
}
} catch (error) {
console.error('Error loading complete plot data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveFullData = async (newData) => {
setSaving(true)
try {
await writeConfig('plot-definitions', newData)
setFullData(newData)
setMessage('Plot configuration saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving full plot data:', error)
setMessage(`Error saving plot configuration: ${error.message}`)
} finally {
setSaving(false)
}
}
const savePlots = async (newPlots) => {
try {
// Solo enviar plots, mantener campos técnicos automáticamente
const newFullData = {
plots: newPlots,
session_counter: fullData.session_counter || 0,
last_saved: new Date().toISOString(),
version: fullData.version || "1.0"
}
await saveFullData(newFullData)
} catch (error) {
console.error('Error saving plots:', error)
setMessage(`Error saving plots: ${error.message}`)
}
}
const savePlotVariables = async (newVariables) => {
try {
const updatedPlotVariables = {
...plotVariables,
[selectedPlotId]: {
variables: newVariables
}
}
const saveData = {
plot_variables: updatedPlotVariables
}
await writeConfig('plot-variables', saveData)
setPlotVariables(updatedPlotVariables)
setMessage('Plot variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving plot variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentPlotVariables = useMemo(() => {
return selectedPlotId && plotVariables[selectedPlotId]
? plotVariables[selectedPlotId].variables || {}
: {}
}, [selectedPlotId, plotVariables])
const plotOptions = Object.entries(fullData.plots || {}).map(([id, plot]) => ({
value: id,
label: `${plot.name || id} (${id})`
}))
if (loading) {
return <Text>Loading plot configuration...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Plots */}
<Card>
<CardHeader>
<Heading size="sm">📈 Plot Management</Heading>
<Text fontSize="sm" color={muted}>
Manage your plots: create, edit and configure
</Text>
</CardHeader>
<CardBody>
{plotSchema?.properties?.plots ? (
<FormTable
schema={plotSchema.properties.plots}
uiSchema={plotUiSchema.plots}
data={fullData.plots || {}}
onChange={savePlots}
title="Plots"
keyField="session_id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Plot schema for individual plots not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Plot */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Plot Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Plot:</Text>
<Select
size="sm"
value={selectedPlotId}
onChange={(e) => setSelectedPlotId(e.target.value)}
placeholder="Select plot"
width="200px"
>
{plotOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedPlotId ? (
<Alert status="info">
<AlertIcon />
Select a plot to manage its variables
</Alert>
) : variableSchema ? (
<FormTable
schema={variableSchema}
uiSchema={variableUiSchema}
data={currentPlotVariables}
onChange={savePlotVariables}
title={`Variables for: ${fullData.plots?.[selectedPlotId]?.name || selectedPlotId}`}
keyField="variable_name"
/>
) : (
<Alert status="warning">
<AlertIcon />
Variable schema not available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,402 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Button,
Input,
IconButton,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PlotVariablesManager - Componente para gestionar array de strings (variables de plot)
*/
function PlotVariablesManager({ variables = [], onChange, title = "Variables" }) {
const [newVariable, setNewVariable] = useState('')
const [editingIndex, setEditingIndex] = useState(null)
const [editingValue, setEditingValue] = useState('')
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const handleAdd = () => {
if (!newVariable.trim()) return
const newVariables = [...variables, newVariable.trim()]
onChange(newVariables)
setNewVariable('')
onAddClose()
}
const handleEdit = () => {
if (editingIndex === null || !editingValue.trim()) return
const newVariables = [...variables]
newVariables[editingIndex] = editingValue.trim()
onChange(newVariables)
setEditingIndex(null)
setEditingValue('')
onEditClose()
}
const handleDelete = (index) => {
if (confirm('¿Eliminar esta variable?')) {
const newVariables = variables.filter((_, i) => i !== index)
onChange(newVariables)
}
}
const openEdit = (index) => {
setEditingIndex(index)
setEditingValue(variables[index])
onEditOpen()
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
Add Variable
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Variable Name</Th>
<Th width="100px">Actions</Th>
</Tr>
</Thead>
<Tbody>
{variables.length === 0 ? (
<Tr>
<Td colSpan={2}>
<Text color={muted} textAlign="center">No variables</Text>
</Td>
</Tr>
) : (
variables.map((variable, index) => (
<Tr key={index}>
<Td>{variable}</Td>
<Td>
<HStack spacing={1}>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(index)}
/>
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(index)}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isAddOpen} onClose={onAddClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}
/**
* PlotFormManager - Gestiona plots y variables usando FormTable
*/
export default function PlotFormManager() {
const [plots, setPlots] = useState({})
const [plotVariables, setPlotVariables] = useState({})
const [selectedPlotId, setSelectedPlotId] = useState('')
const [plotSchema, setPlotSchema] = useState(null)
const [plotUiSchema, setPlotUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
getSchema('plot-definitions'),
getSchema('plot-variables')
])
console.log('Plot schema response:', plotSchemaResp)
console.log('Plot variable schema response:', plotVariableSchemaResp)
// Cargar datos
const [plotDataResp, plotVariableDataResp] = await Promise.all([
readConfig('plot-definitions'),
readConfig('plot-variables')
])
console.log('Plot data response:', plotDataResp)
console.log('Plot variable data response:', plotVariableDataResp)
// Extraer schemas
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
console.log('Plot full schema:', plotSchemaResp.schema?.properties?.plots)
console.log('Plot full uiSchema:', plotSchemaResp.ui_schema?.plots)
setPlots(plotDataResp.data?.plots || {})
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
// Seleccionar primer plot
const plotIds = Object.keys(plotDataResp.data?.plots || {})
if (plotIds.length > 0 && !selectedPlotId) {
setSelectedPlotId(plotIds[0])
}
} catch (error) {
console.error('Error loading plot data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const savePlots = async (newPlots) => {
try {
const currentConfig = await readConfig('plot-definitions')
const saveData = {
...currentConfig.data,
plots: newPlots,
last_saved: new Date().toISOString()
}
await writeConfig('plot-definitions', saveData)
setPlots(newPlots)
setMessage('Plots saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving plots:', error)
setMessage(`Error saving plots: ${error.message}`)
}
}
const savePlotVariables = async (newVariables) => {
try {
const currentConfig = await readConfig('plot-variables')
const updatedPlotVariables = {
...plotVariables,
[selectedPlotId]: {
variables: newVariables
}
}
const saveData = {
...currentConfig.data,
plot_variables: updatedPlotVariables,
last_update: new Date().toISOString()
}
await writeConfig('plot-variables', saveData)
setPlotVariables(updatedPlotVariables)
setMessage('Plot variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving plot variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentPlotVariables = useMemo(() => {
return selectedPlotId && plotVariables[selectedPlotId]
? plotVariables[selectedPlotId].variables || []
: []
}, [selectedPlotId, plotVariables])
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
value: id,
label: `${plot.name || id} (${id})`
}))
if (loading) {
return <Text>Loading plots...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Plots */}
<Card>
<CardHeader>
<Heading size="sm">📈 Plot Definitions</Heading>
</CardHeader>
<CardBody>
{plotSchema ? (
<FormTable
schema={plotSchema}
uiSchema={plotUiSchema}
data={plots}
onChange={savePlots}
title="Plots"
keyField="session_id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Plot schema not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Plot */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Plot Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Plot:</Text>
<Select
size="sm"
value={selectedPlotId}
onChange={(e) => setSelectedPlotId(e.target.value)}
placeholder="Select plot"
width="200px"
>
{plotOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedPlotId ? (
<Alert status="info">
<AlertIcon />
Select a plot to manage its variables
</Alert>
) : (
<PlotVariablesManager
variables={currentPlotVariables}
onChange={savePlotVariables}
title={`Variables for: ${plots[selectedPlotId]?.name || selectedPlotId}`}
/>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,414 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
IconButton,
Input,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import EditableTable from './EditableTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PlotVariablesTable - Componente especializado para editar variables de plots (array de strings)
*/
function PlotVariablesTable({ variables = [], onChange, title = "Variables" }) {
const [newVariable, setNewVariable] = useState('')
const { isOpen, onOpen, onClose } = useDisclosure()
const [editingIndex, setEditingIndex] = useState(null)
const [editingValue, setEditingValue] = useState('')
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const handleAdd = () => {
if (!newVariable.trim()) return
const newVariables = [...variables, newVariable.trim()]
onChange(newVariables)
setNewVariable('')
onClose()
}
const handleEdit = () => {
if (editingIndex === null || !editingValue.trim()) return
const newVariables = [...variables]
newVariables[editingIndex] = editingValue.trim()
onChange(newVariables)
setEditingIndex(null)
setEditingValue('')
onEditClose()
}
const handleDelete = (index) => {
const newVariables = variables.filter((_, i) => i !== index)
onChange(newVariables)
}
const openEdit = (index) => {
setEditingIndex(index)
setEditingValue(variables[index])
onEditOpen()
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
<Button size="sm" leftIcon={<AddIcon />} onClick={onOpen}>
Add Variable
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Variable Name</Th>
<Th width="100px">Actions</Th>
</Tr>
</Thead>
<Tbody>
{variables.length === 0 ? (
<Tr>
<Td colSpan={2}>
<Text color={muted} textAlign="center">No variables</Text>
</Td>
</Tr>
) : (
variables.map((variable, index) => (
<Tr key={index}>
<Td>{variable}</Td>
<Td>
<HStack spacing={1}>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(index)}
/>
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(index)}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}
/**
* PlotTableManager - Componente para gestionar plots y sus variables
* Muestra tabla de plots y tabla de variables del plot seleccionado
*/
export default function PlotTableManager() {
const [plots, setPlots] = useState({})
const [plotVariables, setPlotVariables] = useState({})
const [selectedPlotId, setSelectedPlotId] = useState('')
const [plotSchema, setPlotSchema] = useState(null)
const [plotUiSchema, setPlotUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
// Cargar schemas y datos al montar
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
getSchema('plot-definitions'),
getSchema('plot-variables')
])
// Cargar datos de configuración
const [plotDataResp, plotVariableDataResp] = await Promise.all([
readConfig('plot-definitions'),
readConfig('plot-variables')
])
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
setPlots(plotDataResp.data?.plots || {})
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
// Seleccionar el primer plot si existe
const plotIds = Object.keys(plotDataResp.data?.plots || {})
if (plotIds.length > 0 && !selectedPlotId) {
setSelectedPlotId(plotIds[0])
}
} catch (error) {
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const savePlots = async (newPlots) => {
setSaving(true)
setMessage('')
try {
// Construir el objeto completo para guardar
const saveData = {
plots: newPlots,
session_counter: 0, // Esto se puede gestionar por separado
last_saved: new Date().toISOString(),
version: "1.0"
}
await writeConfig('plot-definitions', saveData)
setPlots(newPlots)
setMessage('Plots saved successfully')
} catch (error) {
setMessage(`Error saving plots: ${error.message}`)
} finally {
setSaving(false)
}
}
const savePlotVariables = async (newVariables) => {
setSaving(true)
setMessage('')
try {
const updatedPlotVariables = {
...plotVariables,
[selectedPlotId]: {
variables: newVariables
}
}
const saveData = {
plot_variables: updatedPlotVariables,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('plot-variables', saveData)
setPlotVariables(updatedPlotVariables)
setMessage('Plot variables saved successfully')
} catch (error) {
setMessage(`Error saving variables: ${error.message}`)
} finally {
setSaving(false)
}
}
// Convertir datos de plots para el componente EditableTable
const plotsForTable = useMemo(() => {
return Object.entries(plots).map(([id, data]) => ({
id,
...data
}))
}, [plots])
// Variables del plot seleccionado
const variablesForTable = useMemo(() => {
if (!selectedPlotId || !plotVariables[selectedPlotId]) {
return []
}
return plotVariables[selectedPlotId].variables || []
}, [selectedPlotId, plotVariables])
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
value: id,
label: `${plot.name} (${id})`
}))
if (loading) {
return <Text>Loading plots...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Plots */}
<Card>
<CardHeader>
<Heading size="sm">📈 Plots</Heading>
</CardHeader>
<CardBody>
{plotSchema ? (
<EditableTable
schema={plotSchema}
uiSchema={plotUiSchema.additionalProperties || {}}
data={plotsForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newPlots = {}
newData.forEach(item => {
const { id, ...rest } = item
newPlots[id] = rest
})
savePlots(newPlots)
}}
title="Plot Definitions"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
No plot schema available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Selector de Plot y Tabla de Variables */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Plot Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Plot:</Text>
<Select
size="sm"
value={selectedPlotId}
onChange={(e) => setSelectedPlotId(e.target.value)}
placeholder="Select plot"
width="200px"
>
{plotOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedPlotId ? (
<Alert status="info">
<AlertIcon />
Select a plot to manage its variables
</Alert>
) : (
<PlotVariablesTable
variables={variablesForTable}
onChange={savePlotVariables}
title={`Variables for plot: ${plots[selectedPlotId]?.name || selectedPlotId}`}
/>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,290 @@
import React from 'react'
import {
FormControl, FormLabel, FormHelperText, Select, NumberInput, NumberInputField,
NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper, Checkbox, Input,
HStack, VStack, Badge, Text, Box, Icon, useColorModeValue
} from '@chakra-ui/react'
// Widget for PLC Memory Area selection with visual indicators
export function PlcAreaWidget(props) {
const { id, options, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [] } = props
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
const areaIcons = {
'db': '🗃️',
'mw': '📊',
'm': '💾',
'pew': '📥',
'pe': '📥',
'paw': '📤',
'pa': '📤',
'e': '🔌',
'a': '🔌',
'mb': '💾'
}
const areaDescriptions = {
'db': 'Data Block - Structured data storage',
'mw': 'Memory Word - 16-bit memory words',
'm': 'Memory - Bit-addressable memory',
'pew': 'Process Input Word - 16-bit input words',
'pe': 'Process Input - Bit-addressable inputs',
'paw': 'Process Output Word - 16-bit output words',
'pa': 'Process Output - Bit-addressable outputs',
'e': 'Input - Digital inputs',
'a': 'Output - Digital outputs',
'mb': 'Memory Byte - 8-bit memory bytes'
}
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Select
id={id}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? options.emptyValue : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
>
<option value="">Select memory area...</option>
{options.enumOptions && options.enumOptions.map((option, i) => (
<option key={i} value={option.value}>
{areaIcons[option.value]} {option.label}
</option>
))}
</Select>
{value && areaDescriptions[value] && (
<Text fontSize="xs" color="gray.500" mt={1}>
{areaDescriptions[value]}
</Text>
)}
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
// Widget for PLC Data Type selection with size information
export function PlcDataTypeWidget(props) {
const { id, options, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [] } = props
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
const typeSizes = {
'real': '32-bit',
'int': '16-bit',
'bool': '1-bit',
'dint': '32-bit',
'word': '16-bit',
'byte': '8-bit',
'uint': '16-bit',
'udint': '32-bit',
'sint': '8-bit',
'usint': '8-bit'
}
const typeColors = {
'real': 'blue',
'int': 'green',
'bool': 'purple',
'dint': 'green',
'word': 'orange',
'byte': 'orange',
'uint': 'green',
'udint': 'green',
'sint': 'green',
'usint': 'green'
}
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Select
id={id}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? options.emptyValue : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
>
<option value="">Select data type...</option>
{options.enumOptions && options.enumOptions.map((option, i) => (
<option key={i} value={option.value}>
{option.label}
</option>
))}
</Select>
{value && typeSizes[value] && (
<HStack mt={1} spacing={2}>
<Badge colorScheme={typeColors[value]} size="sm">
{typeSizes[value]}
</Badge>
<Text fontSize="xs" color="gray.500">
{value === 'real' ? 'Floating point number' :
value === 'bool' ? 'Boolean true/false' :
value.includes('int') ? 'Integer number' :
'Binary data'}
</Text>
</HStack>
)}
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
// Enhanced number input widget with PLC-specific validations
export function PlcNumberWidget(props) {
const { id, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [], schema } = props
const min = schema.minimum
const max = schema.maximum
const step = schema.multipleOf || 1
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<NumberInput
id={id}
value={value || ''}
onChange={(_, num) => onChange(isNaN(num) ? undefined : num)}
onBlur={onBlur && (() => onBlur(id, value))}
onFocus={onFocus && (() => onFocus(id, value))}
min={min}
max={max}
step={step}
precision={schema.type === 'integer' ? 0 : 2}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
{(min !== undefined || max !== undefined) && (
<FormHelperText fontSize="xs">
Valid range: {min ?? '-∞'} to {max ?? '∞'}
</FormHelperText>
)}
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
// Enhanced checkbox widget for streaming configuration
export function PlcStreamingWidget(props) {
const { id, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [] } = props
const streamingBg = useColorModeValue('green.50', 'green.900')
const inactiveBg = useColorModeValue('gray.50', 'gray.700')
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
<Box
p={3}
borderRadius="md"
bg={value ? streamingBg : inactiveBg}
border="1px"
borderColor={value ? 'green.200' : 'gray.200'}
>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<Text fontSize="sm" fontWeight="medium">
{value ? '📡' : '📴'} Real-time Streaming
</Text>
<Badge colorScheme={value ? 'green' : 'gray'} size="sm">
{value ? 'Enabled' : 'Disabled'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{value
? 'This variable will be streamed to PlotJuggler for real-time visualization'
: 'Enable to stream this variable to PlotJuggler in real-time'
}
</Text>
</VStack>
<Checkbox
id={id}
isChecked={value || false}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur && (() => onBlur(id, value))}
onFocus={onFocus && (() => onFocus(id, value))}
colorScheme="green"
size="lg"
/>
</HStack>
</Box>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
// Variable name input with validation and suggestions
export function PlcVariableNameWidget(props) {
const { id, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [] } = props
const isValid = value && /^[a-zA-Z][a-zA-Z0-9_]*$/.test(value)
const borderColor = useColorModeValue('gray.300', 'gray.600')
const errorBorderColor = useColorModeValue('red.300', 'red.500')
const suggestions = [
'Temperature_Tank_1',
'Pressure_Line_A',
'Motor_Speed_RPM',
'Valve_Position',
'Flow_Rate_LPM',
'Level_Percentage'
]
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Input
id={id}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
placeholder="e.g., Temperature_Tank_1"
borderColor={rawErrors.length > 0 ? errorBorderColor : borderColor}
_focus={{ borderColor: rawErrors.length > 0 ? errorBorderColor : 'blue.500' }}
/>
<FormHelperText fontSize="xs">
{!value ? (
<>Use descriptive names like: {suggestions.slice(0, 2).join(', ')}</>
) : !isValid ? (
<Text color="orange.500">
Variable names should start with a letter and contain only letters, numbers, and underscores
</Text>
) : (
<Text color="green.500"> Valid variable name</Text>
)}
</FormHelperText>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
// Widget map to register custom widgets
export const plcWidgets = {
PlcAreaWidget,
PlcDataTypeWidget,
PlcNumberWidget,
PlcStreamingWidget,
PlcVariableNameWidget
}

View File

@ -0,0 +1,247 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
FormControl, FormLabel, FormHelperText, Select, VStack, HStack,
Text, Badge, Box, Icon, Input, useColorModeValue, Spinner
} from '@chakra-ui/react'
import { SearchIcon } from '@chakra-ui/icons'
import { readConfig } from '../../services/api.js'
// Widget for selecting existing dataset variables with filtering and search
export function VariableSelectorWidget(props) {
const { id, value, required, disabled, readonly, label, onChange, onBlur, onFocus, rawErrors = [] } = props
const [datasetVariables, setDatasetVariables] = useState({})
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [selectedDataset, setSelectedDataset] = useState('all')
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
const bgColor = useColorModeValue('white', 'gray.800')
const selectedVarBgColor = useColorModeValue('blue.50', 'blue.900')
const selectedVarBorderColor = useColorModeValue('blue.200', 'blue.700')
// Load dataset variables on mount
useEffect(() => {
const loadDatasetVariables = async () => {
try {
setLoading(true)
const response = await readConfig('dataset-variables')
setDatasetVariables(response.data?.dataset_variables || {})
} catch (error) {
console.error('Error loading dataset variables:', error)
setDatasetVariables({})
} finally {
setLoading(false)
}
}
loadDatasetVariables()
}, [])
// Create flattened list of all variables with their metadata
const allVariables = useMemo(() => {
const variables = []
Object.entries(datasetVariables).forEach(([datasetId, datasetData]) => {
const datasetVariablesData = datasetData.variables || {}
Object.entries(datasetVariablesData).forEach(([variableName, variableConfig]) => {
variables.push({
name: variableName,
dataset: datasetId,
type: variableConfig.type,
area: variableConfig.area,
offset: variableConfig.offset,
db: variableConfig.db,
streaming: variableConfig.streaming,
address: `${variableConfig.area}${variableConfig.db ? variableConfig.db + '.' : ''}${variableConfig.offset}${variableConfig.bit !== undefined ? '.' + variableConfig.bit : ''}`
})
})
})
return variables
}, [datasetVariables])
// Filter variables based on search term and selected dataset
const filteredVariables = useMemo(() => {
let filtered = allVariables
// Filter by dataset
if (selectedDataset !== 'all') {
filtered = filtered.filter(variable => variable.dataset === selectedDataset)
}
// Filter by search term
if (searchTerm) {
const search = searchTerm.toLowerCase()
filtered = filtered.filter(variable =>
variable.name.toLowerCase().includes(search) ||
variable.dataset.toLowerCase().includes(search) ||
variable.type.toLowerCase().includes(search) ||
variable.address.toLowerCase().includes(search)
)
}
return filtered.sort((a, b) => {
// Sort by dataset first, then by variable name
if (a.dataset !== b.dataset) {
return a.dataset.localeCompare(b.dataset)
}
return a.name.localeCompare(b.name)
})
}, [allVariables, searchTerm, selectedDataset])
// Get unique dataset names for filter dropdown
const datasets = useMemo(() => {
const datasetNames = [...new Set(allVariables.map(v => v.dataset))]
return datasetNames.sort()
}, [allVariables])
// Get info for selected variable
const selectedVariable = useMemo(() => {
if (!value) return null
return allVariables.find(v => v.name === value)
}, [value, allVariables])
// Color schemes for different types
const typeColors = {
'real': 'blue',
'int': 'green',
'bool': 'purple',
'dint': 'green',
'word': 'orange',
'byte': 'orange',
'uint': 'green',
'udint': 'green',
'sint': 'green',
'usint': 'green'
}
if (loading) {
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<HStack spacing={2}>
<Spinner size="sm" />
<Text fontSize="sm" color="gray.500">Loading variables...</Text>
</HStack>
</FormControl>
)
}
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<VStack spacing={3} align="stretch">
{/* Search and Dataset Filter */}
<HStack spacing={2}>
<Box position="relative" flex="1">
<Input
placeholder="Search variables..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
pl={8}
/>
<Icon
as={SearchIcon}
position="absolute"
left={3}
top="50%"
transform="translateY(-50%)"
color="gray.400"
w={3}
h={3}
/>
</Box>
<Select
value={selectedDataset}
onChange={(e) => setSelectedDataset(e.target.value)}
width="200px"
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
>
<option value="all">All Datasets</option>
{datasets.map(dataset => (
<option key={dataset} value={dataset}>
📊 {dataset}
</option>
))}
</Select>
</HStack>
{/* Variable Selection */}
<Select
id={id}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
bg={bgColor}
>
<option value="">Select a variable...</option>
{filteredVariables.map((variable, index) => (
<option key={`${variable.dataset}_${variable.name}`} value={variable.name}>
📊 {variable.dataset} {variable.name} ({variable.type}) [{variable.address}]
</option>
))}
</Select>
{/* Selected Variable Info */}
{selectedVariable && (
<Box
p={3}
borderRadius="md"
bg={selectedVarBgColor}
border="1px"
borderColor={selectedVarBorderColor}
>
<VStack align="start" spacing={2}>
<HStack spacing={2} wrap="wrap">
<Badge colorScheme="blue" variant="solid">
📊 {selectedVariable.dataset}
</Badge>
<Badge colorScheme={typeColors[selectedVariable.type] || 'gray'}>
{selectedVariable.type.toUpperCase()}
</Badge>
<Badge colorScheme="gray" variant="outline">
{selectedVariable.address}
</Badge>
{selectedVariable.streaming && (
<Badge colorScheme="green">
📡 Streaming
</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.600">
PLC Address: {selectedVariable.area}{selectedVariable.db ? `${selectedVariable.db}.` : ''}{selectedVariable.offset}
{selectedVariable.streaming ? ' • Real-time streaming enabled' : ' • Static logging only'}
</Text>
</VStack>
</Box>
)}
{/* Results Summary */}
{(searchTerm || selectedDataset !== 'all') && (
<Text fontSize="xs" color="gray.500">
{filteredVariables.length === 0
? 'No variables found matching your criteria'
: `Showing ${filteredVariables.length} of ${allVariables.length} variables`
}
</Text>
)}
</VStack>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
export default VariableSelectorWidget

View File

@ -1,96 +1,147 @@
import React from 'react';
// Deprecated: Bootstrap widgets were used before migrating to Chakra UI theme
// Legacy Bootstrap widgets no longer used after migrating to Chakra UI
import React from 'react'
import {
FormControl, FormLabel, FormHelperText, Input, Textarea, Select, Checkbox,
NumberInput, NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper,
useColorModeValue
} from '@chakra-ui/react'
import {
PlcAreaWidget,
PlcDataTypeWidget,
PlcNumberWidget,
PlcStreamingWidget,
PlcVariableNameWidget
} from './PlcWidgets.jsx'
import VariableSelectorWidget from './VariableSelectorWidget.jsx'
export const TextWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
// Enhanced Chakra UI widgets for RJSF forms
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
type="number"
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
export const TextWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [] }) => {
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
export const TextareaWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
as="textarea"
rows={options?.rows || 3}
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
export const SelectWidget = ({ id, required, readonly, disabled, label, value, onChange, options, schema }) => {
const enumOptions = options?.enumOptions || [];
return (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Select
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Input
id={id}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
/>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [], schema }) => {
const min = schema?.minimum
const max = schema?.maximum
const step = schema?.multipleOf || 1
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<NumberInput
id={id}
value={value || ''}
onChange={(_, num) => onChange(isNaN(num) ? undefined : num)}
onBlur={onBlur && (() => onBlur(id, value))}
onFocus={onFocus && (() => onFocus(id, value))}
min={min}
max={max}
step={step}
precision={schema?.type === 'integer' ? 0 : 2}
>
{enumOptions.map((opt) => (
<option key={String(opt.value)} value={opt.value}>
<NumberInputField placeholder={placeholder} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
export const TextareaWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [], options }) => {
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Textarea
id={id}
placeholder={placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
rows={options?.rows || 3}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
/>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
export const SelectWidget = ({ id, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [], options }) => {
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
const enumOptions = options?.enumOptions || []
return (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
<Select
id={id}
value={value || ''}
onChange={(e) => onChange(e.target.value === '' ? options.emptyValue : e.target.value)}
onBlur={onBlur && ((e) => onBlur(id, e.target.value))}
onFocus={onFocus && ((e) => onFocus(id, e.target.value))}
borderColor={borderColor}
_focus={{ borderColor: focusBorderColor }}
>
{!required && <option value="">Select...</option>}
{enumOptions.map((opt, i) => (
<option key={i} value={opt.value}>
{opt.label}
</option>
))}
</BSForm.Select>
</BSForm.Group>
);
};
</Select>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
}
export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange }) => (
<BSForm.Group className="mb-3">
<BSForm.Check
type="checkbox"
export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange, rawErrors = [] }) => (
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
<Checkbox
id={id}
label={label}
checked={!!value}
required={required}
disabled={disabled || readonly}
isChecked={!!value}
onChange={(e) => onChange(e.target.checked)}
/>
</BSForm.Group>
);
colorScheme="blue"
>
{label}
</Checkbox>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
)}
</FormControl>
)
// Map keys must match RJSF default widget names to override them automatically by type
export const widgets = {
@ -99,4 +150,12 @@ export const widgets = {
SelectWidget,
CheckboxWidget,
TextareaWidget,
};
// Custom PLC widgets
PlcAreaWidget,
PlcDataTypeWidget,
PlcNumberWidget,
PlcStreamingWidget,
PlcVariableNameWidget,
// Plot variable widgets
VariableSelectorWidget
}

View File

@ -4,6 +4,7 @@ import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import { listSchemas, getSchema, readConfig, writeConfig } from '../services/api.js'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from '../components/rjsf/widgets.jsx'
function buildUiSchema(schema) {
if (!schema || typeof schema !== 'object') return undefined
@ -47,11 +48,8 @@ export default function ConfigPage() {
if (!schemas) return []
if (Array.isArray(schemas.schemas)) return schemas.schemas.map(s => s.id)
if (schemas.schemas && typeof schemas.schemas === 'object') return Object.keys(schemas.schemas)
const ids = []
if (schemas?.plc) ids.push('plc')
if (schemas?.datasets) ids.push('datasets')
if (schemas?.plots) ids.push('plots')
return ids
// Fallback: return empty array if no schemas detected
return []
}, [schemas])
const load = async (id) => {
@ -154,6 +152,7 @@ export default function ConfigPage() {
onChange={({ formData }) => setFormData(formData)}
uiSchema={uiSchema}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
>
<HStack spacing={2}>
<Button type="submit" isDisabled={loading}>💾 Save</Button>

View File

@ -4,6 +4,10 @@ import { Box, Container, Flex, Grid, GridItem, HStack, Heading, Text, Button, Ba
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from '../components/rjsf/widgets.jsx'
import DatasetCompleteManager from '../components/DatasetCompleteManager.jsx'
import PlotCompleteManager from '../components/PlotCompleteManager.jsx'
import PLCConfigManager from '../components/PLCConfigManager.jsx'
import {
getStatus,
getEvents,
@ -98,13 +102,24 @@ export default function DashboardPage() {
const available = useMemo(() => {
if (!schemas) return []
// Accept multiple shapes from API
if (Array.isArray(schemas.schemas)) return schemas.schemas.map(s => s.id || s)
if (schemas.schemas && typeof schemas.schemas === 'object') return Object.keys(schemas.schemas)
const ids = []
if (schemas?.plc) ids.push('plc')
if (schemas?.datasets) ids.push('datasets')
if (schemas?.plots) ids.push('plots')
return ids
const ids = Array.isArray(schemas.schemas)
? schemas.schemas.map(s => s.id || s)
: (schemas.schemas && typeof schemas.schemas === 'object')
? Object.keys(schemas.schemas)
: []
// Ensure PLC config appears before dataset sections on Dashboard
const preferredOrder = ['plc', 'dataset-definitions', 'dataset-variables']
const orderIndex = (id) => {
const idx = preferredOrder.indexOf(id)
return idx === -1 ? Number.MAX_SAFE_INTEGER : idx
}
return [...ids].sort((a, b) => {
const ai = orderIndex(a)
const bi = orderIndex(b)
if (ai !== bi) return ai - bi
// Keep stable order among non-preferred ids
return 0
})
}, [schemas])
const [currentSchemaId, setCurrentSchemaId] = useState('plc')
@ -213,7 +228,37 @@ export default function DashboardPage() {
{statusError && <Alert status="error" mb={3}><AlertIcon />{statusError}</Alert>}
{status && <StatusBar status={status} />}
{['plc', 'datasets', 'plots'].map((sectionId) => (
{/* Sección PLC Config */}
{available.includes('plc') && (
<PLCConfigManager />
)}
{/* Sección Datasets con Tablas */}
{(available.includes('dataset-definitions') || available.includes('dataset-variables')) && (
<Card mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
</Flex>
<DatasetCompleteManager />
</CardBody>
</Card>
)}
{/* Sección Plots con Tablas */}
{(available.includes('plot-definitions') || available.includes('plot-variables')) && (
<Card mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
</Flex>
<PlotCompleteManager />
</CardBody>
</Card>
)}
{/* Otras secciones que no son datasets ni plots */}
{available.filter(id => !['dataset-definitions', 'dataset-variables', 'plot-definitions', 'plot-variables', 'plc'].includes(id)).map((sectionId) => (
<Card key={sectionId} mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
@ -360,6 +405,7 @@ function SectionForm({ sectionId }) {
}}
uiSchema={localUi}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
>
<div />
</Form>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +0,0 @@
import React, { useEffect, useState } from 'react'
import { getStatus } from '../services/api.js'
function StatusItem({ label, value }) {
return (
<div className="col-md-6 col-lg-4">
<div className="card mb-3">
<div className="card-body">
<div className="text-muted small">{label}</div>
<div className="fw-semibold">{String(value)}</div>
</div>
</div>
</div>
)
}
export default function StatusPage() {
const [status, setStatus] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const load = async () => {
setLoading(true)
setError('')
try {
const data = await getStatus()
setStatus(data)
} catch (e) {
setError(e.message || 'Error fetching status')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
return (
<div className="container py-3">
<h2 className="h4 mb-3">Status</h2>
<div className="d-flex gap-2 mb-3">
<button className="btn btn-primary btn-sm" onClick={load} disabled={loading}>
{loading ? 'Cargando...' : 'Refrescar'}
</button>
<a className="btn btn-outline-secondary btn-sm" href="/api/status" target="_blank" rel="noreferrer">/api/status</a>
</div>
{error && (
<div className="alert alert-danger">{error}</div>
)}
{!error && loading && (
<div className="alert alert-info">Cargando estado...</div>
)}
{status && (
<div className="row">
{Object.entries(status).map(([k, v]) => (
<StatusItem key={k} label={k} value={typeof v === 'object' ? JSON.stringify(v) : v} />
))}
</div>
)}
</div>
)
}

55
main.py
View File

@ -1378,18 +1378,69 @@ def stop_udp_streaming():
return jsonify({"success": True, "message": "UDP streaming to PlotJuggler stopped"})
# 🔍 HEALTH CHECK ENDPOINT
@app.route("/api/health", methods=["GET"])
def health_check():
"""Simple health check endpoint"""
try:
streamer_status = "initialized" if streamer is not None else "not_initialized"
plot_manager_status = (
"available"
if (
streamer
and hasattr(streamer, "data_streamer")
and hasattr(streamer.data_streamer, "plot_manager")
)
else "not_available"
)
return jsonify(
{
"status": "ok",
"streamer": streamer_status,
"plot_manager": plot_manager_status,
"timestamp": time.time(),
}
)
except Exception as e:
return (
jsonify({"status": "error", "error": str(e), "timestamp": time.time()}),
500,
)
# 📈 PLOT MANAGER API ENDPOINTS
@app.route("/api/plots", methods=["GET"])
def get_plots():
"""Get all plot sessions status"""
print("🔍 DEBUG: /api/plots endpoint called")
error_response = check_streamer_initialized()
if error_response:
print("❌ DEBUG: Streamer not initialized")
return error_response
print("✅ DEBUG: Streamer is initialized")
try:
sessions = streamer.data_streamer.plot_manager.get_all_sessions_status()
return jsonify({"sessions": sessions})
print("🔍 DEBUG: Accessing streamer.data_streamer.plot_manager...")
plot_manager = streamer.data_streamer.plot_manager
print(f"✅ DEBUG: Plot manager obtained: {type(plot_manager)}")
print("🔍 DEBUG: Calling get_all_sessions_status()...")
sessions = plot_manager.get_all_sessions_status()
print(f"✅ DEBUG: Sessions obtained: {len(sessions)} sessions")
print(f"📊 DEBUG: Sessions data: {sessions}")
result = {"sessions": sessions}
print(f"✅ DEBUG: Returning result: {result}")
return jsonify(result)
except Exception as e:
print(f"💥 DEBUG: Exception in get_plots: {type(e).__name__}: {str(e)}")
import traceback
print(f"📋 DEBUG: Full traceback:")
traceback.print_exc()
return jsonify({"error": str(e)}), 500

View File

@ -1,154 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plc.schema.json",
"title": "PLC & UDP Configuration",
"description": "Schema to edit plc_config.json",
"type": "object",
"additionalProperties": false,
"properties": {
"plc_config": {
"type": "object",
"title": "PLC Configuration",
"additionalProperties": false,
"properties": {
"ip": {
"type": "string",
"title": "PLC IP",
"description": "IP address of the PLC (S7-31x)",
"format": "ipv4",
"pattern": "^.+$"
},
"rack": {
"type": "integer",
"title": "Rack",
"description": "Rack number (0-7)",
"minimum": 0,
"maximum": 7,
"default": 0
},
"slot": {
"type": "integer",
"title": "Slot",
"description": "Slot number (usually 2)",
"minimum": 0,
"maximum": 31,
"default": 2
}
},
"required": [
"ip",
"rack",
"slot"
]
},
"udp_config": {
"type": "object",
"title": "UDP Configuration",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"title": "UDP Host",
"pattern": "^.+$",
"default": "127.0.0.1"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 9870
}
},
"required": [
"host",
"port"
]
},
"sampling_interval": {
"type": "number",
"minimum": 0.01,
"maximum": 10,
"title": "Sampling Interval (s)",
"description": "Global sampling interval in seconds",
"default": 0.1
},
"csv_config": {
"type": "object",
"title": "CSV Recording",
"additionalProperties": false,
"properties": {
"records_directory": {
"type": "string",
"title": "Records Directory",
"default": "records"
},
"rotation_enabled": {
"type": "boolean",
"title": "Rotation",
"default": true,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"max_size_mb": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Size (MB)",
"default": 1000
},
"max_days": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Days",
"default": 30
},
"max_hours": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Hours",
"default": null
},
"cleanup_interval_hours": {
"type": "integer",
"minimum": 1,
"title": "Cleanup Interval (h)",
"default": 24
},
"last_cleanup": {
"type": [
"string",
"null"
],
"title": "Last Cleanup"
}
},
"required": [
"records_directory",
"rotation_enabled",
"cleanup_interval_hours"
]
}
},
"required": [
"plc_config",
"udp_config",
"sampling_interval",
"csv_config"
]
}

View File

@ -1,100 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
"plots": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Plot Name",
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
"minItems": 1
},
"time_window": {
"type": "integer",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
},
"y_min": {
"type": [
"number",
"null"
],
"title": "Y Min",
"description": "Leave empty for auto"
},
"y_max": {
"type": [
"number",
"null"
],
"title": "Y Max",
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [
"string",
"null"
],
"title": "Trigger Variable"
},
"trigger_enabled": {
"type": "boolean",
"title": "Enable Trigger",
"default": false
},
"trigger_on_true": {
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"session_id": {
"type": "string",
"title": "Session Id"
}
},
"required": [
"name",
"variables",
"time_window"
]
}
},
"session_counter": {
"type": "integer",
"minimum": 0,
"default": 0
},
"last_saved": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string",
"default": "1.0"
}
},
"required": [
"plots"
]
}

View File

@ -185,12 +185,13 @@ class PlotManager {
let chartConfig = this.createStreamingChartConfig(sessionId, config);
// Pre-poblar datasets en config antes de crear el Chart para evitar metas undefined
if (Array.isArray(config.variables) && config.variables.length > 0) {
const enabledVariables = this.getEnabledVariables(config.variables);
if (enabledVariables.length > 0) {
const datasets = [];
config.variables.forEach((variable, index) => {
const color = this.getColor(variable, index);
enabledVariables.forEach((variableInfo) => {
const color = variableInfo.color || this.getColor(variableInfo.name, datasets.length);
datasets.push({
label: variable,
label: variableInfo.name,
data: [],
borderColor: color,
backgroundColor: color + '20',
@ -475,10 +476,11 @@ class PlotManager {
const chart = sessionData.chart;
const datasets = [];
config.variables.forEach((variable, index) => {
const color = this.getColor(variable, index);
const enabledVariables = this.getEnabledVariables(config.variables);
enabledVariables.forEach((variableInfo) => {
const color = variableInfo.color || this.getColor(variableInfo.name, datasets.length);
const dataset = {
label: variable,
label: variableInfo.name,
data: [],
borderColor: color,
backgroundColor: color + '20',
@ -491,7 +493,7 @@ class PlotManager {
};
datasets.push(dataset);
sessionData.datasetIndex.set(variable, index);
sessionData.datasetIndex.set(variableInfo.name, datasets.length - 1);
});
chart.data.datasets = datasets;
@ -1201,14 +1203,13 @@ class PlotManager {
// Cargar variables seleccionadas
this.selectedVariables.clear();
if (config.variables) {
for (let i = 0; i < config.variables.length; i++) {
const variable = config.variables[i];
const color = this.getColor(variable, i);
this.selectedVariables.set(variable, {
color: color,
const enabledVariables = this.getEnabledVariables(config.variables);
enabledVariables.forEach((variableInfo) => {
this.selectedVariables.set(variableInfo.name, {
color: variableInfo.color,
dataset: 'Unknown' // Will be resolved when loading datasets
});
}
});
}
this.updateSelectedVariablesDisplay();
}
@ -1479,6 +1480,40 @@ class PlotManager {
return this.colors.find(color => !usedColors.has(color)) || this.colors[0];
}
getEnabledVariables(variables) {
if (Array.isArray(variables)) {
// Backward compatibility: convert old array format to new object format
return variables.map((variable, index) => ({
name: variable,
color: this.getColor(variable, index),
enabled: true
}));
} else if (variables && typeof variables === 'object') {
// Check if this is the new format with variable_name property
const firstVarConfig = Object.values(variables)[0];
if (firstVarConfig && 'variable_name' in firstVarConfig) {
// New format: variable_name as property
return Object.entries(variables)
.filter(([id, config]) => config.enabled !== false && config.variable_name)
.map(([id, config]) => ({
name: config.variable_name,
color: config.color || this.getColor(config.variable_name),
enabled: config.enabled !== false
}));
} else {
// Old object format: variable names as keys
return Object.entries(variables)
.filter(([name, config]) => config.enabled !== false)
.map(([name, config]) => ({
name: name,
color: config.color || this.getColor(name),
enabled: config.enabled !== false
}));
}
}
return [];
}
getColor(variable, index = null) {
if (index !== null) {
return this.colors[index % this.colors.length];
@ -1524,9 +1559,21 @@ class PlotManager {
return;
}
// Convert selectedVariables to new structure with variable_name property
const variablesConfig = {};
let varCounter = 1;
this.selectedVariables.forEach((info, variableName) => {
variablesConfig[`var_${varCounter}`] = {
variable_name: variableName,
color: info.color,
enabled: true
};
varCounter++;
});
const config = {
name: document.getElementById('plot-form-name').value || `Plot ${Date.now()}`,
variables: Array.from(this.selectedVariables.keys()),
variables: variablesConfig,
time_window: parseInt(document.getElementById('plot-form-time-window').value) || 60,
y_min: document.getElementById('plot-form-y-min').value || null,
y_max: document.getElementById('plot-form-y-max').value || null,
@ -1810,11 +1857,12 @@ class PlotManager {
return;
} else {
// Si no cambió el conjunto de variables, solo refrescar estilos
const newDatasets = config.variables.map((variable, index) => {
const color = this.getColor(variable, index);
const enabledVariables = this.getEnabledVariables(config.variables);
const newDatasets = enabledVariables.map((variableInfo, index) => {
const color = variableInfo.color || this.getColor(variableInfo.name, index);
const existing = chart.data.datasets[index];
if (existing) {
existing.label = variable;
existing.label = variableInfo.name;
existing.borderColor = color;
existing.backgroundColor = color + '20';
existing.borderWidth = 2;
@ -1826,7 +1874,7 @@ class PlotManager {
return existing;
}
return {
label: variable,
label: variableInfo.name,
data: [],
borderColor: color,
backgroundColor: color + '20',

View File

@ -8,5 +8,5 @@
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-10T01:45:12.574799"
"last_update": "2025-08-13T00:09:19.091418"
}