Compare commits
7 Commits
09eccf5c0b
...
04f5122cc6
Author | SHA1 | Date |
---|---|---|
|
04f5122cc6 | |
|
d31f0819e2 | |
|
bacc9933b3 | |
|
500b68c4d5 | |
|
745af5fa1f | |
|
724af8afdf | |
|
4d4df0830b |
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeBackground": "#470606",
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -29,7 +29,10 @@
|
|||
"default": null,
|
||||
"minimum": 1,
|
||||
"title": "Max Hours",
|
||||
"type": "integer"
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"max_size_mb": {
|
||||
"default": 1000,
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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
55
main.py
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -8,5 +8,5 @@
|
|||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-10T01:45:12.574799"
|
||||
"last_update": "2025-08-13T00:09:19.091418"
|
||||
}
|
Loading…
Reference in New Issue