Refactor: Remove PlotRealtimeViewer and PlotTableManager components
- Deleted PlotRealtimeViewer.jsx and PlotTableManager.jsx components to streamline the codebase. - Removed associated imports and references in Dashboard.jsx. - Cleaned up unused TabCoordinationDemo component. - Updated main.py to serve SIDEL.png from the public folder. - Removed obsolete test scripts related to configuration reload, endpoint testing, header validation, and symbol loading.
This commit is contained in:
parent
a4f74b70ed
commit
3417056b06
|
@ -1,64 +0,0 @@
|
|||
# Multi-Browser Plot Fix - Resumen de Cambios
|
||||
|
||||
## Problema Original
|
||||
Cuando se abría el mismo plot en múltiples pestañas del mismo navegador, ambas instancias se bloqueaban y dejaban de funcionar. Funcionaba bien en navegadores diferentes pero no en pestañas del mismo navegador.
|
||||
|
||||
## Análisis del Problema
|
||||
El problema era que aunque el backend generaba session IDs únicos, el frontend seguía consultando con el `plot_id` original, causando que la segunda pestaña encontrara la sesión de la primera pestaña y asumiera que era suya.
|
||||
|
||||
## Solución Implementada
|
||||
|
||||
### 1. Backend Changes (core/plot_manager.py)
|
||||
- **Session IDs únicos mejorados**: Ahora incluyen `browser_tab_id` en el formato: `{plot_id}_{browser_tab_id}_{timestamp}_{counter}`
|
||||
- **Búsqueda por plot_id**: El método `get_session_config` ahora puede buscar por session_id único O por plot_id (backward compatibility)
|
||||
- **Limpieza automática**: Sesiones inactivas se limpian automáticamente cada 5 minutos
|
||||
|
||||
### 2. Frontend Changes (PlotRealtimeSession.jsx)
|
||||
- **Browser Tab ID único**: Cada instancia genera un ID único: `tab_{timestamp}_{random}`
|
||||
- **Session ID management**: Se mantiene el `actualSessionId` devuelto por el backend
|
||||
- **No auto-refresh inicial**: Se eliminó el auto-refresh al cargar para evitar conflictos
|
||||
- **Identificador visual**: Se muestra el Tab ID y Session ID para debugging
|
||||
|
||||
### 3. API Changes (main.py)
|
||||
- **Soporte para browser_tab_id**: El endpoint de creación acepta `browser_tab_id`
|
||||
- **Nuevos endpoints**:
|
||||
- `/api/plots/sessions/{plot_id}` - Lista sesiones de un plot
|
||||
- `/api/plots/cleanup` - Limpieza manual de sesiones
|
||||
|
||||
## Flujo de Trabajo Actualizado
|
||||
|
||||
### Primera Pestaña:
|
||||
1. Genera `browserTabId` único
|
||||
2. Crea sesión con `browser_tab_id` incluido
|
||||
3. Recibe `actualSessionId` único del backend
|
||||
4. Usa `actualSessionId` para todas las operaciones
|
||||
|
||||
### Segunda Pestaña:
|
||||
1. Genera su propio `browserTabId` único
|
||||
2. No busca sesiones existentes al inicio
|
||||
3. Crea su propia sesión independiente
|
||||
4. Recibe su propio `actualSessionId` único
|
||||
5. Opera completamente independiente de la primera pestaña
|
||||
|
||||
## Beneficios
|
||||
- ✅ Múltiples pestañas del mismo navegador funcionan independientemente
|
||||
- ✅ Múltiples navegadores siguen funcionando
|
||||
- ✅ No hay conflictos de estado entre instancias
|
||||
- ✅ Limpieza automática previene acumulación de sesiones
|
||||
- ✅ Debugging mejorado con IDs visibles
|
||||
- ✅ Backward compatibility mantenida
|
||||
|
||||
## Testing
|
||||
Para probar:
|
||||
1. Abrir plot en primera pestaña
|
||||
2. Hacer clic en "Start"
|
||||
3. Abrir segunda pestaña del mismo navegador
|
||||
4. Abrir el mismo plot
|
||||
5. Hacer clic en "Start" en segunda pestaña
|
||||
6. Ambos plots deben funcionar independientemente
|
||||
|
||||
## Archivos Modificados
|
||||
- `core/plot_manager.py` - Session management mejorado
|
||||
- `frontend/src/components/PlotRealtimeSession.jsx` - Tab ID y session management
|
||||
- `main.py` - API endpoints actualizados
|
||||
- `MULTI_BROWSER_SUPPORT.md` - Documentación
|
|
@ -1,75 +0,0 @@
|
|||
# Multi-Browser Plot Support
|
||||
|
||||
## Problema Resuelto
|
||||
Anteriormente no era posible tener el mismo plot activo en múltiples navegadores simultáneamente. Cuando se intentaba esto, ambas instancias se bloqueaban y dejaban de funcionar.
|
||||
|
||||
## Solución Implementada
|
||||
|
||||
### 1. Session IDs Únicos
|
||||
- **Antes**: El sistema usaba `plotDefinition.id` directamente como `session_id`
|
||||
- **Ahora**: Se generan session IDs únicos con formato: `{plot_id}_{timestamp}_{counter}`
|
||||
|
||||
### 2. Gestión de Sesiones Múltiples
|
||||
- Cada navegador/pestaña puede crear su propia sesión independiente
|
||||
- Las sesiones se distinguen por timestamps únicos
|
||||
- Se incluye limpieza automática de sesiones inactivas
|
||||
|
||||
### 3. API Mejorada
|
||||
|
||||
#### Nuevo Parámetro en POST /api/plots
|
||||
```json
|
||||
{
|
||||
"id": "plot_1",
|
||||
"name": "Mi Plot",
|
||||
"variables": ["var1", "var2"],
|
||||
"allow_multiple": true // Permite múltiples sesiones (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
#### Nuevos Endpoints
|
||||
- `GET /api/plots/sessions/{plot_id}` - Lista todas las sesiones de un plot
|
||||
- `POST /api/plots/cleanup` - Limpieza manual de sesiones inactivas
|
||||
|
||||
### 4. Frontend Actualizado
|
||||
- El componente `PlotRealtimeSession` ahora maneja session IDs dinámicos
|
||||
- Se muestra información del session ID real cuando es diferente del plot ID
|
||||
- Mejor gestión de estado para sesiones independientes
|
||||
|
||||
### 5. Limpieza Automática
|
||||
- Las sesiones inactivas se limpian automáticamente cada 5 minutos
|
||||
- Sesiones mayores a 1 hora sin actividad son eliminadas por defecto
|
||||
- Evita acumulación de sesiones obsoletas
|
||||
|
||||
## Uso
|
||||
|
||||
### Para Permitir Múltiples Instancias (Comportamiento por Defecto)
|
||||
```javascript
|
||||
await api.createPlot({
|
||||
id: "plot_1",
|
||||
name: "Mi Plot",
|
||||
variables: ["var1", "var2"],
|
||||
allow_multiple: true // Permite múltiples navegadores
|
||||
})
|
||||
```
|
||||
|
||||
### Para Reutilizar Sesión Existente
|
||||
```javascript
|
||||
await api.createPlot({
|
||||
id: "plot_1",
|
||||
name: "Mi Plot",
|
||||
variables: ["var1", "var2"],
|
||||
allow_multiple: false // Reutiliza sesión activa si existe
|
||||
})
|
||||
```
|
||||
|
||||
## Beneficios
|
||||
1. **Múltiples Navegadores**: Cada usuario puede tener su propia instancia del mismo plot
|
||||
2. **Sin Conflictos**: Las sesiones no se interfieren entre sí
|
||||
3. **Limpieza Automática**: No hay acumulación de sesiones obsoletas
|
||||
4. **Compatibilidad**: Mantiene compatibilidad con código existente
|
||||
5. **Flexibilidad**: Se puede elegir entre múltiples sesiones o reutilización
|
||||
|
||||
## Consideraciones
|
||||
- Cada sesión adicional consume memoria
|
||||
- La limpieza automática evita acumulación excesiva
|
||||
- El frontend mantiene referencia al session ID real para comunicación correcta con el backend
|
107
OFFLINE_USAGE.md
107
OFFLINE_USAGE.md
|
@ -1,107 +0,0 @@
|
|||
# Offline Usage Configuration
|
||||
|
||||
## Overview
|
||||
The application has been configured to work completely offline without any CDN dependencies.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Chart.js Libraries Migration
|
||||
**Before (CDN Dependencies):**
|
||||
- Chart.js loaded from `https://cdn.jsdelivr.net/npm/chart.js@3.9.1`
|
||||
- Luxon from `https://cdn.jsdelivr.net/npm/luxon@2`
|
||||
- Chart.js adapter from `https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1`
|
||||
- Zoom plugin from `https://unpkg.com/chartjs-plugin-zoom@1.2.1`
|
||||
- Streaming plugin from `https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0`
|
||||
|
||||
**After (NPM Dependencies):**
|
||||
All Chart.js libraries are now installed as npm packages and bundled with the application:
|
||||
```json
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"chartjs-plugin-streaming": "^2.0.0",
|
||||
"chartjs-plugin-zoom": "^1.2.1",
|
||||
"luxon": "^2.5.2"
|
||||
```
|
||||
|
||||
### 2. Chart.js Setup Module
|
||||
Created `frontend/src/utils/chartSetup.js` that:
|
||||
- Imports all Chart.js components as ES modules
|
||||
- Registers all required plugins (zoom, streaming, time scales)
|
||||
- Makes Chart.js available globally for existing components
|
||||
- Provides console confirmation of successful setup
|
||||
|
||||
### 3. Application Entry Point
|
||||
Modified `frontend/src/main.jsx` to import the Chart.js setup before rendering the application.
|
||||
|
||||
### 4. Updated HTML Template
|
||||
Removed all CDN script tags from `frontend/index.html`.
|
||||
|
||||
## Verification
|
||||
|
||||
### Build Verification
|
||||
The application builds successfully without any external dependencies:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Development Server
|
||||
The development server runs without internet connection:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Runtime Verification
|
||||
- No network requests to external CDNs
|
||||
- All Chart.js functionality preserved (zooming, streaming, real-time plots)
|
||||
- Completely self-contained in bundled JavaScript
|
||||
|
||||
## Backend Offline Compliance
|
||||
|
||||
The Python backend already uses only local dependencies:
|
||||
- Flask for web server
|
||||
- python-snap7 for PLC communication
|
||||
- Local file-based configuration
|
||||
- No external API calls or services
|
||||
|
||||
## Deployment for Offline Use
|
||||
|
||||
### Frontend Production Build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
The `dist/` folder contains all necessary files with no external dependencies.
|
||||
|
||||
### Complete Offline Package
|
||||
The entire application (backend + frontend) can be deployed on systems without internet access:
|
||||
|
||||
1. **Python Requirements**: Install from `requirements.txt`
|
||||
2. **Frontend**: Use built files from `dist/` folder
|
||||
3. **PLC Communication**: Requires `snap7.dll` in system PATH
|
||||
4. **Configuration**: All JSON-based, stored locally
|
||||
|
||||
## Chart.js Feature Compatibility
|
||||
|
||||
All existing Chart.js features remain functional:
|
||||
- ✅ Real-time streaming plots
|
||||
- ✅ Zoom and pan functionality
|
||||
- ✅ Time-based X-axis with Luxon adapter
|
||||
- ✅ Multiple dataset support
|
||||
- ✅ Dynamic color assignment
|
||||
- ✅ Plot session management
|
||||
- ✅ CSV data export integration
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Global vs ES Module Access
|
||||
The setup maintains backward compatibility by making Chart.js available both ways:
|
||||
- **Global**: `window.Chart` (for existing components)
|
||||
- **ES Module**: `import ChartJS from './utils/chartSetup.js'` (for new components)
|
||||
|
||||
### Bundle Size Impact
|
||||
The Chart.js libraries add approximately ~400KB to the bundle (gzipped), which is acceptable for offline industrial applications.
|
||||
|
||||
### Browser Compatibility
|
||||
All dependencies support modern browsers without requiring polyfills for the target deployment environment.
|
|
@ -8145,8 +8145,15 @@
|
|||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-15T22:47:53.048381",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-15T21:25:51.900870",
|
||||
"total_entries": 676
|
||||
"last_updated": "2025-08-15T22:47:53.048381",
|
||||
"total_entries": 677
|
||||
}
|
|
@ -1,294 +0,0 @@
|
|||
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, activateDataset, deactivateDataset } from '../services/api.js'
|
||||
import { useCoordinatedSSE } from '../hooks/useCoordinatedConnection'
|
||||
|
||||
/**
|
||||
* 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({ status }) {
|
||||
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')
|
||||
const [liveValues, setLiveValues] = useState({})
|
||||
|
||||
// Usar SSE coordinado para valores en vivo del dataset seleccionado
|
||||
const plcConnected = !!status?.plc_connected
|
||||
const sseUrl = selectedDatasetId && plcConnected ?
|
||||
`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0` :
|
||||
null
|
||||
|
||||
const { data: liveData } = useCoordinatedSSE(
|
||||
`dataset_variables_${selectedDatasetId}`,
|
||||
sseUrl,
|
||||
[selectedDatasetId, plcConnected]
|
||||
)
|
||||
|
||||
// Procesar datos SSE recibidos
|
||||
useEffect(() => {
|
||||
if (!liveData) {
|
||||
setLiveValues({})
|
||||
return
|
||||
}
|
||||
|
||||
if (liveData?.type === 'values' && liveData.values) {
|
||||
setLiveValues(liveData.values || {})
|
||||
} else if (liveData?.type === 'no_cache' || liveData?.type === 'dataset_inactive' || liveData?.type === 'plc_disconnected') {
|
||||
setLiveValues({})
|
||||
}
|
||||
}, [liveData])
|
||||
|
||||
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
|
||||
|
||||
// FormTable requiere un schema con additionalProperties en la raíz
|
||||
setVariableSchema(variableSchemaPath ? { additionalProperties: variableSchemaPath } : null)
|
||||
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 {
|
||||
// Detectar cambios en "enabled" para llamar a endpoints de activación, evitando estados inconsistentes
|
||||
const prev = fullData.datasets || {}
|
||||
const changedIds = []
|
||||
for (const [id, cfg] of Object.entries(newDatasets)) {
|
||||
const before = prev[id]?.enabled === true
|
||||
const after = cfg?.enabled === true
|
||||
if (before !== after) changedIds.push({ id, after })
|
||||
}
|
||||
|
||||
// Persistir datasets primero
|
||||
const newFullData = { datasets: newDatasets }
|
||||
await saveFullData(newFullData)
|
||||
|
||||
// Aplicar activación/desactivación en backend para arrancar/parar hilos
|
||||
await Promise.allSettled(changedIds.map(({ id, after }) => after ? activateDataset(id) : deactivateDataset(id)))
|
||||
|
||||
// Refrescar selección y datos locales tras cambios
|
||||
setFullData(newFullData)
|
||||
if (selectedDatasetId && !newDatasets[selectedDatasetId]) {
|
||||
const ids = Object.keys(newDatasets)
|
||||
setSelectedDatasetId(ids[0] || '')
|
||||
}
|
||||
setMessage('Datasets saved and activation applied')
|
||||
setTimeout(() => setMessage(''), 2000)
|
||||
} catch (error) {
|
||||
console.error('Error saving datasets:', error)
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
liveValues={liveValues}
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Variable schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,426 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Text,
|
||||
Select,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Spinner
|
||||
} from '@chakra-ui/react'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { allWidgets } from './widgets/AllWidgets.jsx'
|
||||
|
||||
/**
|
||||
* DatasetVariablesRJSF - Maneja variables de dataset usando RJSF Type 3 pattern
|
||||
*/
|
||||
export default function DatasetVariablesRJSF({
|
||||
datasets = {},
|
||||
selectedDatasetId,
|
||||
onSelectDataset,
|
||||
onVariablesUpdate
|
||||
}) {
|
||||
const [schema, setSchema] = useState(null)
|
||||
const [uiSchema, setUiSchema] = useState(null)
|
||||
const [variables, setVariables] = useState({ variables: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Load schema and data
|
||||
useEffect(() => {
|
||||
loadSchemaAndData()
|
||||
}, [])
|
||||
|
||||
const loadSchemaAndData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load schema
|
||||
const schemaResp = await fetch('/api/config/schema/dataset-variables')
|
||||
const schemaData = await schemaResp.json()
|
||||
|
||||
if (schemaData.success) {
|
||||
setSchema(schemaData.schema)
|
||||
setUiSchema(schemaData.ui_schema || {})
|
||||
}
|
||||
|
||||
// Load data
|
||||
const dataResp = await fetch('/api/config/dataset-variables')
|
||||
const data = await dataResp.json()
|
||||
|
||||
if (data.success) {
|
||||
setVariables(data.data || { variables: [] })
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading schema/data:', error)
|
||||
setMessage(`Error loading: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveVariables = async (formData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/config/dataset-variables', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setVariables(formData)
|
||||
setMessage('Variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
|
||||
if (onVariablesUpdate) {
|
||||
onVariablesUpdate(formData)
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to save variables')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving variables:', error)
|
||||
setMessage(`Error saving: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get available datasets for selector
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<Spinner />
|
||||
<Text>Loading dataset variables configuration...</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Failed to load schema for dataset variables
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Configure PLC variables for each dataset - use symbols or manual configuration
|
||||
</Text>
|
||||
|
||||
{datasetOptions.length > 0 && (
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Reference Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId || ''}
|
||||
onChange={(e) => onSelectDataset && onSelectDataset(e.target.value)}
|
||||
placeholder="Select dataset for reference"
|
||||
width="250px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'} mb={4}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={variables}
|
||||
validator={validator}
|
||||
onChange={({ formData }) => setVariables(formData)}
|
||||
onSubmit={({ formData }) => saveVariables(formData)}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={allWidgets}
|
||||
showErrorList={false}
|
||||
disabled={saving}
|
||||
>
|
||||
<HStack mt={4}>
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Variables'}
|
||||
</button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -1,355 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
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 { allWidgets } from './widgets/AllWidgets.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,
|
||||
liveValues = null
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [addingNew, setAddingNew] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [editingFormData, setEditingFormData] = useState(null)
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
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={allWidgets}
|
||||
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>
|
||||
{data[key] && typeof data[key].enabled === 'boolean' && (
|
||||
<Badge colorScheme={data[key].enabled ? 'green' : 'red'} size="sm">
|
||||
{data[key].enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
{editingKey === key && (
|
||||
<Badge colorScheme="orange" size="sm">Editing</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
{editingKey === key ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingKey(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => { setEditingFormData({ ...(data[key] || {}) }); setEditingKey(key) }}
|
||||
/>
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(key)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{liveValues && liveValues[key] !== undefined && (
|
||||
<Box mb={2}>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Live value: <Text as="span" fontWeight="semibold">{String(liveValues[key])}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={editingKey === key ? (editingFormData || {}) : (data[key] || {})}
|
||||
validator={validator}
|
||||
onChange={editingKey === key ? ({ formData }) => setEditingFormData(formData) : () => { }}
|
||||
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={allWidgets}
|
||||
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); setEditingFormData(null) }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,402 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,884 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Grid,
|
||||
Flex,
|
||||
Spacer,
|
||||
HStack,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Heading,
|
||||
Badge,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Spinner,
|
||||
Select,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Alert,
|
||||
AlertIcon
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import allWidgets from './widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate'
|
||||
import PlotRealtimeSession from './PlotRealtimeSession'
|
||||
import { useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// Confirmation Dialog Component for deletion operations
|
||||
function ConfirmationDialog({ isOpen, onClose, onConfirm, title, message, itemName, confirmButtonText = "Delete" }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">⚠️</Box>
|
||||
{title}
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text>
|
||||
{message}
|
||||
</Text>
|
||||
{itemName && (
|
||||
<Box p={3} bg="gray.50" borderRadius="md" borderLeft="4px solid" borderColor="red.400">
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
Item to delete: "{itemName}"
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
This action cannot be undone. Make sure you want to proceed.
|
||||
</Text>
|
||||
</Alert>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" mr={3} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={onConfirm}>
|
||||
🗑️ {confirmButtonText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible Plot Items Form - Each item in the array is individually collapsible
|
||||
function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon, getItemLabel, isExpanded, onToggleExpansion }) {
|
||||
const [formData, setFormData] = useState(data)
|
||||
const [expandedItems, setExpandedItems] = useState(new Set())
|
||||
const [confirmDelete, setConfirmDelete] = useState({ isOpen: false, index: null, itemName: '' })
|
||||
|
||||
useEffect(() => {
|
||||
// Solo actualizar formData si data realmente cambió en contenido
|
||||
if (JSON.stringify(data) !== JSON.stringify(formData)) {
|
||||
setFormData(data)
|
||||
}
|
||||
}, [data]) // Removed formData from dependencies to avoid infinite loop
|
||||
|
||||
if (!schema || !formData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={4}>
|
||||
<Spinner size="sm" mr={2} />
|
||||
<Text>Loading {title}...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const arrayKey = Object.keys(formData)[0]
|
||||
const items = formData[arrayKey] || []
|
||||
|
||||
const toggleItemExpansion = (index) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index)
|
||||
} else {
|
||||
newExpanded.add(index)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const updateItem = (index, newItemData) => {
|
||||
const newItems = [...items]
|
||||
newItems[index] = newItemData
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
const newItem = {}
|
||||
const newItems = [...items, newItem]
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
setExpandedItems(new Set([...expandedItems, items.length]))
|
||||
}
|
||||
|
||||
const removeItem = (index) => {
|
||||
const newItems = items.filter((_, i) => i !== index)
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
// Update expanded items indices
|
||||
const newExpanded = new Set()
|
||||
expandedItems.forEach(i => {
|
||||
if (i < index) newExpanded.add(i)
|
||||
else if (i > index) newExpanded.add(i - 1)
|
||||
})
|
||||
setExpandedItems(newExpanded)
|
||||
// Close confirmation dialog
|
||||
setConfirmDelete({ isOpen: false, index: null, itemName: '' })
|
||||
}
|
||||
|
||||
const handleDeleteClick = (index) => {
|
||||
const item = items[index]
|
||||
const itemName = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
setConfirmDelete({
|
||||
isOpen: true,
|
||||
index,
|
||||
itemName
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (confirmDelete.index !== null) {
|
||||
removeItem(confirmDelete.index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmDelete({ isOpen: false, index: null, itemName: '' })
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
try {
|
||||
await onSave(formData)
|
||||
// No hacer nada con la expansión aquí - será manejado por el componente padre
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get item schema from the array schema
|
||||
const itemSchema = schema?.properties?.[arrayKey]?.items || {}
|
||||
|
||||
// Get item UI schema - extract from the nested structure
|
||||
const arrayUiSchema = uiSchema?.[arrayKey] || {}
|
||||
const itemUiSchema = arrayUiSchema.items || {}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<Heading size="md">{icon} {title}</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
{items.length} item{items.length !== 1 ? 's' : ''} configured
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack spacing={2}>
|
||||
{/* Si se proporciona toggle de expansión externa, agregar botón de colapso */}
|
||||
{onToggleExpansion && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onToggleExpansion}
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" colorScheme="green" onClick={addItem}>
|
||||
➕ Add Item
|
||||
</Button>
|
||||
<Button size="sm" colorScheme="red" onClick={saveChanges}>
|
||||
💾 Save All
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* Usar Collapse si se proporciona estado de expansión externo */}
|
||||
{onToggleExpansion ? (
|
||||
<Collapse in={isExpanded}>
|
||||
<CardBody>
|
||||
{items.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500" mb={4}>
|
||||
No items configured yet
|
||||
</Text>
|
||||
<Button colorScheme="green" onClick={addItem}>
|
||||
➕ Add First Item
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{items.map((item, index) => {
|
||||
const isItemExpanded = expandedItems.has(index)
|
||||
const itemLabel = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
|
||||
return (
|
||||
<Card key={index} variant="outline" size="sm">
|
||||
<CardHeader py={2}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => toggleItemExpansion(index)}
|
||||
rightIcon={isItemExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{itemLabel}
|
||||
</Button>
|
||||
<Badge colorScheme="green" size="sm">#{index + 1}</Badge>
|
||||
</HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteClick(index)}
|
||||
title="Delete this item"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isItemExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
) : (
|
||||
<CardBody>
|
||||
{items.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500" mb={4}>
|
||||
No items configured yet
|
||||
</Text>
|
||||
<Button colorScheme="green" onClick={addItem}>
|
||||
➕ Add First Item
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{items.map((item, index) => {
|
||||
const isItemExpanded = expandedItems.has(index)
|
||||
const itemLabel = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
|
||||
return (
|
||||
<Card key={index} variant="outline" size="sm">
|
||||
<CardHeader py={2}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => toggleItemExpansion(index)}
|
||||
rightIcon={isItemExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{itemLabel}
|
||||
</Button>
|
||||
<Badge colorScheme="green" size="sm">#{index + 1}</Badge>
|
||||
</HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteClick(index)}
|
||||
title="Delete this item"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isItemExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog for Deletion */}
|
||||
<ConfirmationDialog
|
||||
isOpen={confirmDelete.isOpen}
|
||||
onClose={handleCancelDelete}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title={`Delete ${title.replace(/📊|📋|⚙️|📈|🗂️/g, '').trim()} Item`}
|
||||
message={`Are you sure you want to delete this ${title.toLowerCase().replace(/📊|📋|⚙️|📈|🗂️/g, '').trim()} item?`}
|
||||
itemName={confirmDelete.itemName}
|
||||
confirmButtonText="Delete Item"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible Plot Component
|
||||
function CollapsiblePlotChart({ plotDefinition, plotVariables, onConfigUpdate, onReloadConfig, onRemove, isExpanded, onToggleExpansion }) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {plotDefinition.name || plotDefinition.id}</Heading>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{plotDefinition.time_window}s window • {plotVariables?.length || 0} variables
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onToggleExpansion(plotDefinition.id)}
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} Chart
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<PlotRealtimeSession
|
||||
plotDefinition={plotDefinition}
|
||||
plotVariables={plotVariables}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
onReloadConfig={onReloadConfig}
|
||||
onRemove={onRemove}
|
||||
isCollapsed={true}
|
||||
/>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple Plot Manager Component
|
||||
export default function PlotManager() {
|
||||
const { triggerVariableRefresh } = useVariableContext()
|
||||
const [plotsConfig, setPlotsConfig] = useState(null)
|
||||
const [plotVariablesConfig, setPlotVariablesConfig] = useState(null)
|
||||
const [plotsSchemaData, setPlotsSchemaData] = useState(null)
|
||||
const [plotVariablesSchemaData, setPlotVariablesSchemaData] = useState(null)
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Estado para preservar qué plots están expandidos/colapsados
|
||||
const [expandedPlots, setExpandedPlots] = useState(new Set())
|
||||
// Estado para preservar si la configuración de plot definitions está expandida
|
||||
const [configExpanded, setConfigExpanded] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
const loadPlotData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [plotsData, plotVariablesData, plotsSchemaResponse, plotVariablesSchemaResponse] = await Promise.all([
|
||||
api.readConfig('plot-definitions'),
|
||||
api.readConfig('plot-variables'),
|
||||
api.getSchema('plot-definitions'),
|
||||
api.getSchema('plot-variables')
|
||||
])
|
||||
|
||||
setPlotsConfig(plotsData)
|
||||
setPlotVariablesConfig(plotVariablesData)
|
||||
setPlotsSchemaData(plotsSchemaResponse)
|
||||
setPlotVariablesSchemaData(plotVariablesSchemaResponse)
|
||||
|
||||
// Auto-select first plot if none selected
|
||||
if (!selectedPlotId && plotsData?.plots?.length > 0) {
|
||||
setSelectedPlotId(plotsData.plots[0].id)
|
||||
}
|
||||
|
||||
console.log('✅ Plot data loaded:', {
|
||||
plots: plotsData?.plots?.length || 0,
|
||||
plotVariables: plotVariablesData?.variables?.length || 0
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load plot configurations',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedPlotId, toast])
|
||||
|
||||
// Función para actualizar configuración de un plot específico sin recargar todo
|
||||
const updatePlotConfig = async (plotId, newConfig) => {
|
||||
try {
|
||||
// Actualizar solo el plot específico en la configuración local
|
||||
const updatedPlots = plotsConfig?.plots?.map(plot =>
|
||||
plot.id === plotId ? { ...plot, ...newConfig } : plot
|
||||
) || []
|
||||
|
||||
const updatedConfig = {
|
||||
...plotsConfig,
|
||||
plots: updatedPlots
|
||||
}
|
||||
|
||||
// Guardar en el backend
|
||||
await api.writeConfig('plot-definitions', updatedConfig)
|
||||
|
||||
// Actualizar estado local
|
||||
setPlotsConfig(updatedConfig)
|
||||
|
||||
console.log(`✅ Plot ${plotId} configuration updated locally`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update plot ${plotId} config:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotsConfig = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('plot-definitions', formData)
|
||||
setPlotsConfig(formData)
|
||||
// Mantener la configuración expandida después de Apply
|
||||
setConfigExpanded(true)
|
||||
toast({
|
||||
title: '✅ Plot definitions saved',
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
triggerVariableRefresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot definitions',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('plot-variables', formData)
|
||||
setPlotVariablesConfig(formData)
|
||||
toast({
|
||||
title: '✅ Plot variables saved',
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot variables',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get plot definitions from config
|
||||
const getPlotDefinitions = () => {
|
||||
return plotsConfig?.plots || []
|
||||
}
|
||||
|
||||
// Helper to get dataset IDs for dropdown
|
||||
const getDatasetIds = () => {
|
||||
// This would normally come from dataset definitions
|
||||
// For now, return some placeholder values
|
||||
return ['DAR', 'Fast', 'Slow'] // TODO: Get from actual dataset definitions
|
||||
}
|
||||
|
||||
// Helper to get plot variables for any plot ID
|
||||
const getPlotVariables = useCallback((plotId) => {
|
||||
if (!plotId || !plotVariablesConfig?.variables) return []
|
||||
|
||||
const plotVars = plotVariablesConfig.variables.find(
|
||||
item => item.plot_id === plotId
|
||||
)
|
||||
// Return the variables array directly, not the wrapper object
|
||||
return plotVars?.variables || []
|
||||
}, [plotVariablesConfig])
|
||||
|
||||
// Functions to handle plot expansion state
|
||||
const togglePlotExpansion = (plotId) => {
|
||||
const newExpanded = new Set(expandedPlots)
|
||||
if (newExpanded.has(plotId)) {
|
||||
newExpanded.delete(plotId)
|
||||
} else {
|
||||
newExpanded.add(plotId)
|
||||
}
|
||||
setExpandedPlots(newExpanded)
|
||||
}
|
||||
|
||||
const isPlotExpanded = (plotId) => {
|
||||
return expandedPlots.has(plotId)
|
||||
}
|
||||
|
||||
// Function to handle configuration expansion toggle
|
||||
const toggleConfigExpansion = () => {
|
||||
setConfigExpanded(!configExpanded)
|
||||
}
|
||||
|
||||
// Helper functions for Type 3 form pattern (Plot Variables)
|
||||
const getSelectedPlotVariables = () => {
|
||||
if (!selectedPlotId || !plotVariablesConfig?.variables) return { variables: [] }
|
||||
|
||||
const plotVars = plotVariablesConfig.variables.find(
|
||||
item => item.plot_id === selectedPlotId
|
||||
)
|
||||
return plotVars || { plot_id: selectedPlotId, variables: [] }
|
||||
}
|
||||
|
||||
const updateSelectedPlotVariables = (formData) => {
|
||||
if (!plotVariablesConfig?.variables) {
|
||||
// Initialize plotVariablesConfig if it doesn't exist
|
||||
const newConfig = {
|
||||
variables: [{ plot_id: selectedPlotId, ...formData }]
|
||||
}
|
||||
setPlotVariablesConfig(newConfig)
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const existingIndex = plotVariablesConfig.variables.findIndex(
|
||||
item => item.plot_id === selectedPlotId
|
||||
)
|
||||
|
||||
const updatedVars = [...plotVariablesConfig.variables]
|
||||
const newVarData = { plot_id: selectedPlotId, ...formData }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
updatedVars[existingIndex] = newVarData
|
||||
} else {
|
||||
updatedVars.push(newVarData)
|
||||
}
|
||||
|
||||
const updatedConfig = {
|
||||
...plotVariablesConfig,
|
||||
variables: updatedVars
|
||||
}
|
||||
|
||||
setPlotVariablesConfig(updatedConfig)
|
||||
return updatedConfig
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPlotData()
|
||||
}, [loadPlotData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner mr={3} />
|
||||
<Text>Loading plot configurations...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📈 Plot Manager</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadPlotData}>
|
||||
🔄 Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Real-time Charts Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔴 Real-time Plot Sessions</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Live charts for configured plot sessions - click to expand/collapse
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{getPlotDefinitions().length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center" py={8}>
|
||||
No plot sessions configured. Create plot definitions below to get started.
|
||||
</Text>
|
||||
) : (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{getPlotDefinitions().map((plotDef) => (
|
||||
<CollapsiblePlotChart
|
||||
key={plotDef.id}
|
||||
plotDefinition={plotDef}
|
||||
plotVariables={getPlotVariables(plotDef.id)}
|
||||
onConfigUpdate={updatePlotConfig}
|
||||
onReloadConfig={loadPlotData}
|
||||
onRemove={() => {}}
|
||||
isExpanded={isPlotExpanded(plotDef.id)}
|
||||
onToggleExpansion={togglePlotExpansion}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Plot Definitions - Collapsible */}
|
||||
<CollapsiblePlotItemsForm
|
||||
data={plotsConfig}
|
||||
schema={plotsSchemaData?.schema}
|
||||
uiSchema={plotsSchemaData?.uiSchema}
|
||||
onSave={savePlotsConfig}
|
||||
title="Plot Definitions"
|
||||
icon="📋"
|
||||
getItemLabel={(item) => `${item.name || item.id} (${item.time_window || 60}s)`}
|
||||
isExpanded={configExpanded}
|
||||
onToggleExpansion={toggleConfigExpansion}
|
||||
/>
|
||||
|
||||
{/* Plot Variables Configuration - Type 3 Form Pattern */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">⚙️ Plot Variables Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Select a plot, then configure which variables are displayed in that plot session
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* Step 1: Plot Selector (Combo) */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
🎯 Select Plot Session
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Choose a plot to configure..."
|
||||
size="md"
|
||||
>
|
||||
{getPlotDefinitions().map(plot => (
|
||||
<option key={plot.id} value={plot.id}>
|
||||
📈 {plot.name} ({plot.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{getPlotDefinitions().length === 0 && (
|
||||
<Text fontSize="sm" color="orange.500" mt={2}>
|
||||
⚠️ No plots available. Configure plot definitions first above.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Variables Configuration Form */}
|
||||
{selectedPlotId && (
|
||||
<Box>
|
||||
<Divider mb={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
⚙️ Configure Variables for Plot "{selectedPlotId}"
|
||||
</Text>
|
||||
|
||||
{/* Simplified schema for selected plot variables */}
|
||||
{(() => {
|
||||
const selectedPlotVars = getSelectedPlotVariables()
|
||||
|
||||
// Schema for this plot's variables - match the real schema
|
||||
const singlePlotSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
variables: {
|
||||
type: "array",
|
||||
title: "Variables",
|
||||
description: `Variables to display in plot ${selectedPlotId}`,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
variable_name: {
|
||||
type: "string",
|
||||
title: "Variable Name",
|
||||
description: "Name of the variable from the dataset"
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
title: "Display Label",
|
||||
description: "Label shown in the plot legend"
|
||||
},
|
||||
color: {
|
||||
type: "string",
|
||||
title: "Plot Color",
|
||||
pattern: "^#[0-9A-Fa-f]{6}$",
|
||||
default: "#3498db"
|
||||
},
|
||||
line_width: {
|
||||
type: "number",
|
||||
title: "Line Width",
|
||||
default: 2,
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
y_axis: {
|
||||
type: "string",
|
||||
title: "Y-Axis",
|
||||
enum: ["left", "right"],
|
||||
default: "left"
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
title: "Show in Plot",
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI Schema for layout
|
||||
const singlePlotUiSchema = {
|
||||
"ui:layout": [[
|
||||
{ "name": "variables", "width": 12 }
|
||||
]],
|
||||
variables: {
|
||||
items: {
|
||||
"ui:layout": [[
|
||||
{ "name": "variable_name", "width": 4 },
|
||||
{ "name": "label", "width": 3 },
|
||||
{ "name": "color", "width": 2 },
|
||||
{ "name": "line_width", "width": 1 },
|
||||
{ "name": "y_axis", "width": 1 },
|
||||
{ "name": "enabled", "width": 1 }
|
||||
]],
|
||||
variable_name: {
|
||||
"ui:widget": "variableSelector",
|
||||
"ui:placeholder": "Search and select variable from datasets...",
|
||||
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
},
|
||||
label: {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Chart legend label...",
|
||||
"ui:help": "📊 Label shown in the plot legend for this variable"
|
||||
},
|
||||
color: {
|
||||
"ui:widget": "color",
|
||||
"ui:help": "🎨 Select the color for this variable in the plot",
|
||||
"ui:placeholder": "#3498db"
|
||||
},
|
||||
line_width: {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "📏 Thickness of the line in the plot (1-10)",
|
||||
"ui:options": { "step": 1, "min": 1, "max": 10 }
|
||||
},
|
||||
y_axis: {
|
||||
"ui:widget": "select",
|
||||
"ui:help": "📈 Which Y-axis to use for this variable"
|
||||
},
|
||||
enabled: {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "✅ Whether to show this variable in the plot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={singlePlotSchema}
|
||||
uiSchema={singlePlotUiSchema}
|
||||
formData={selectedPlotVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
const updatedConfig = updateSelectedPlotVariables(formData)
|
||||
savePlotVariables(updatedConfig).then(() => {
|
||||
// Additional trigger after successful save
|
||||
triggerVariableRefresh?.()
|
||||
})
|
||||
}}
|
||||
onChange={({ formData }) => updateSelectedPlotVariables(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button type="submit" colorScheme="red">
|
||||
💾 Save Variables for {selectedPlotId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadPlotData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!selectedPlotId && getPlotDefinitions().length > 0 && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">
|
||||
👆 Select a plot above to configure its variables
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,284 +0,0 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
Divider,
|
||||
Spacer,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon, SettingsIcon, DeleteIcon, ViewIcon } from '@chakra-ui/icons'
|
||||
import ChartjsPlot from './ChartjsPlot.jsx'
|
||||
import { useCoordinatedPolling } from '../hooks/useCoordinatedConnection'
|
||||
|
||||
export default function PlotRealtimeViewer() {
|
||||
const [sessions, setSessions] = useState(new Map())
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Usar polling coordinado para sesiones
|
||||
const { data: sessionsData, isLeader, isConnected } = useCoordinatedPolling(
|
||||
'plot_sessions',
|
||||
async () => {
|
||||
const res = await fetch('/api/plots')
|
||||
return res.json()
|
||||
},
|
||||
5000 // 5 segundos
|
||||
)
|
||||
|
||||
// Procesar datos de sesiones cuando llegan
|
||||
useEffect(() => {
|
||||
if (sessionsData && sessionsData.sessions) {
|
||||
setSessions(prev => {
|
||||
const next = new Map(prev)
|
||||
const incomingIds = new Set()
|
||||
for (const s of sessionsData.sessions) {
|
||||
incomingIds.add(s.session_id)
|
||||
const existing = next.get(s.session_id)
|
||||
if (existing) {
|
||||
// Mutate existing object to preserve reference
|
||||
existing.name = s.name
|
||||
existing.is_active = s.is_active
|
||||
existing.is_paused = s.is_paused
|
||||
existing.variables_count = s.variables_count
|
||||
} else {
|
||||
next.set(s.session_id, { ...s })
|
||||
}
|
||||
}
|
||||
// Remove sessions not present anymore
|
||||
for (const id of Array.from(next.keys())) {
|
||||
if (!incomingIds.has(id)) next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSessions(new Map())
|
||||
}
|
||||
}, [sessionsData])
|
||||
|
||||
const refreshSession = async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/plots/${sessionId}/config`)
|
||||
const data = await res.json()
|
||||
if (data && data.success && data.config) {
|
||||
setSessions(prev => {
|
||||
const n = new Map(prev)
|
||||
const existing = n.get(sessionId)
|
||||
const varsCount = Array.isArray(data.config.variables)
|
||||
? data.config.variables.length
|
||||
: (data.config.variables ? Object.keys(data.config.variables).length : (existing?.variables_count || 0))
|
||||
if (existing) {
|
||||
existing.name = data.config.name
|
||||
existing.is_active = data.config.is_active
|
||||
existing.is_paused = data.config.is_paused
|
||||
existing.variables_count = varsCount
|
||||
} else {
|
||||
n.set(sessionId, {
|
||||
session_id: sessionId,
|
||||
name: data.config.name,
|
||||
is_active: data.config.is_active,
|
||||
is_paused: data.config.is_paused,
|
||||
variables_count: varsCount,
|
||||
})
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const controlSession = async (sessionId, action) => {
|
||||
try {
|
||||
await fetch(`/api/plots/${sessionId}/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
await refreshSession(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const sessionsList = useMemo(() => Array.from(sessions.values()), [sessions])
|
||||
|
||||
if (!isConnected && sessionsList.length === 0) {
|
||||
return <Text color={muted}>Cargando sesiones de plots…</Text>
|
||||
}
|
||||
|
||||
if (sessionsList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text color={muted}>No hay sesiones de plot. Cree o edite plots en la sección superior.</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{sessionsList.map((session) => (
|
||||
<PlotRealtimeCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onControl={controlSession}
|
||||
onRefresh={refreshSession}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
function PlotRealtimeCard({ session, onControl, onRefresh }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const chartControlsRef = useRef(null)
|
||||
const { isOpen: isFullscreen, onOpen: openFullscreen, onClose: closeFullscreen } = useDisclosure()
|
||||
|
||||
const handleChartReady = (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
}
|
||||
|
||||
const enhancedSession = {
|
||||
...session,
|
||||
onChartReady: handleChartReady,
|
||||
isFullscreen: isFullscreen,
|
||||
}
|
||||
|
||||
const handleControlClick = async (action) => {
|
||||
if (chartControlsRef.current) {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
case 'start':
|
||||
case 'resume':
|
||||
chartControlsRef.current.resumeStreaming()
|
||||
break
|
||||
case 'clear':
|
||||
chartControlsRef.current.clearChart()
|
||||
break
|
||||
case 'stop':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
}
|
||||
}
|
||||
// No esperar a que el backend responda para aplicar efecto local
|
||||
onControl(session.session_id, action)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<FlexHeader
|
||||
session={session}
|
||||
muted={muted}
|
||||
onRefresh={() => onRefresh(session.session_id)}
|
||||
onFullscreen={openFullscreen}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ChartjsPlot session={enhancedSession} height="360px" />
|
||||
<HStack mt={3} spacing={2}>
|
||||
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green">▶️ Start</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow">⏸️ Pause</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑️ Clear</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red">⏹️ Stop</Button>
|
||||
<Spacer />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openFullscreen}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
>
|
||||
🔍 Fullscreen
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
<Modal isOpen={isFullscreen} onClose={closeFullscreen} size="full">
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg={cardBg} m={0} borderRadius={0}>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Text>📈 {session.name || session.session_id} - Fullscreen Mode</Text>
|
||||
<Spacer />
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Zoom: Drag to select area | Pan: Shift + Drag | Double-click to reset
|
||||
</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton size="lg" />
|
||||
<ModalBody p={4}>
|
||||
<VStack spacing={4} h="full">
|
||||
<ChartjsPlot session={enhancedSession} height="calc(100vh - 120px)" />
|
||||
<HStack spacing={2}>
|
||||
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green">▶️ Start</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow">⏸️ Pause</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑️ Clear</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red">⏹️ Stop</Button>
|
||||
{chartControlsRef.current && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => chartControlsRef.current.resetZoom?.()}
|
||||
variant="outline"
|
||||
>
|
||||
🔄 Reset Zoom
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FlexHeader({ session, muted, onRefresh, onFullscreen }) {
|
||||
return (
|
||||
<HStack align="center">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {session.name || session.session_id}</Heading>
|
||||
<Text fontSize="sm" color={muted} mt={1}>
|
||||
Variables: {session.variables_count || 0} | Status: <strong>{session.is_active ? (session.is_paused ? 'Paused' : 'Active') : 'Stopped'}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onFullscreen}
|
||||
colorScheme="blue"
|
||||
>
|
||||
🔍 Fullscreen
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Refresh status"
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Box, Text, Badge, VStack, HStack, useColorModeValue } from '@chakra-ui/react'
|
||||
import { getTabCoordinator } from '../utils/TabCoordinator'
|
||||
|
||||
/**
|
||||
* TabCoordinationDemo - Componente de demostración para mostrar el estado de coordinación
|
||||
*/
|
||||
export default function TabCoordinationDemo() {
|
||||
const [coordinator, setCoordinator] = React.useState(null)
|
||||
const [isLeader, setIsLeader] = React.useState(false)
|
||||
const [tabInfo, setTabInfo] = React.useState({})
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.800')
|
||||
|
||||
React.useEffect(() => {
|
||||
const coord = getTabCoordinator()
|
||||
setCoordinator(coord)
|
||||
setIsLeader(coord.getIsLeader())
|
||||
setTabInfo({
|
||||
tabId: coord.tabId,
|
||||
isLeader: coord.getIsLeader()
|
||||
})
|
||||
|
||||
// Subscribirse a cambios de liderazgo
|
||||
const unsubscribe = coord.subscribe('demo', ({ type, data }) => {
|
||||
if (type === 'leadership_change') {
|
||||
setIsLeader(data.isLeader)
|
||||
setTabInfo(prev => ({
|
||||
...prev,
|
||||
isLeader: data.isLeader
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
if (!coordinator) {
|
||||
return <Text>Loading coordinator...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p={3} bg={bgColor} borderRadius="md" border="1px" borderColor="gray.200">
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold">🔗 Tab Coordination Status</Text>
|
||||
<Badge colorScheme={isLeader ? 'green' : 'blue'}>
|
||||
{isLeader ? '👑 Leader' : '👥 Follower'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Tab ID: <code>{tabInfo.tabId}</code>
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Role: {isLeader ? 'Making real connections to backend' : 'Receiving data from leader tab'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Only the leader tab creates actual HTTP connections. Other tabs receive data via BroadcastChannel.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -55,7 +55,7 @@ import {
|
|||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import PlotManager from '../components/PlotManagerSimple'
|
||||
import PlotManager from '../components/PlotManager'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import CsvFileBrowser from '../components/CsvFileBrowser'
|
||||
|
|
10
main.py
10
main.py
|
@ -115,6 +115,16 @@ def serve_public_record_png():
|
|||
return Response("record.png not found", status=404, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route("/SIDEL.png")
|
||||
def serve_public_sidel_png():
|
||||
"""Serve /SIDEL.png from the React public folder."""
|
||||
public_dir = project_path("frontend", "public")
|
||||
sidel_file = os.path.join(public_dir, "SIDEL.png")
|
||||
if os.path.exists(sidel_file):
|
||||
return send_from_directory(public_dir, "SIDEL.png")
|
||||
return Response("SIDEL.png not found", status=404, mimetype="text/plain")
|
||||
|
||||
|
||||
# ==============================
|
||||
# Frontend (React SPA)
|
||||
# ==============================
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
"""
|
||||
Test script to validate automatic configuration reloading
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "http://localhost:5000"
|
||||
TEST_DATASET_ID = "TestReload"
|
||||
|
||||
|
||||
def test_config_reload():
|
||||
"""Test that backend automatically reloads configuration when datasets are updated"""
|
||||
|
||||
print("🧪 Testing automatic configuration reload...")
|
||||
|
||||
try:
|
||||
# Step 1: Get current dataset definitions
|
||||
print("📖 Reading current dataset definitions...")
|
||||
response = requests.get(f"{BASE_URL}/api/config/dataset-definitions")
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to read dataset definitions: {response.status_code}")
|
||||
return False
|
||||
|
||||
current_config = response.json()
|
||||
datasets = current_config.get("data", {}).get("datasets", [])
|
||||
print(f"Current datasets: {[d.get('id') for d in datasets]}")
|
||||
|
||||
# Step 2: Add a test dataset
|
||||
print(f"➕ Adding test dataset: {TEST_DATASET_ID}")
|
||||
test_dataset = {
|
||||
"id": TEST_DATASET_ID,
|
||||
"name": "Test Reload Dataset",
|
||||
"prefix": "test_reload",
|
||||
"sampling_interval": 1.0,
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
# Add to datasets list
|
||||
new_datasets = [
|
||||
d for d in datasets if d.get("id") != TEST_DATASET_ID
|
||||
] # Remove if exists
|
||||
new_datasets.append(test_dataset)
|
||||
|
||||
new_config = {
|
||||
"datasets": new_datasets,
|
||||
"version": "1.0",
|
||||
"last_update": f"{time.time()}",
|
||||
}
|
||||
|
||||
# Save configuration
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/api/config/dataset-definitions",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=new_config,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to save dataset definitions: {response.status_code}")
|
||||
return False
|
||||
|
||||
print("✅ Dataset definitions saved")
|
||||
|
||||
# Step 3: Check if backend has reloaded the configuration
|
||||
print("🔍 Checking if backend reloaded configuration...")
|
||||
time.sleep(1) # Give backend a moment to reload
|
||||
|
||||
# Get status from backend
|
||||
response = requests.get(f"{BASE_URL}/api/status")
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to get status: {response.status_code}")
|
||||
return False
|
||||
|
||||
status = response.json()
|
||||
backend_datasets = status.get("datasets", {})
|
||||
|
||||
if TEST_DATASET_ID in backend_datasets:
|
||||
print(f"✅ Backend successfully loaded new dataset: {TEST_DATASET_ID}")
|
||||
print(f"Dataset details: {backend_datasets[TEST_DATASET_ID]}")
|
||||
|
||||
# Step 4: Clean up - remove test dataset
|
||||
print("🧹 Cleaning up test dataset...")
|
||||
cleanup_datasets = [
|
||||
d for d in new_datasets if d.get("id") != TEST_DATASET_ID
|
||||
]
|
||||
cleanup_config = {
|
||||
"datasets": cleanup_datasets,
|
||||
"version": "1.0",
|
||||
"last_update": f"{time.time()}",
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/api/config/dataset-definitions",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=cleanup_config,
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
print("✅ Test dataset cleaned up")
|
||||
else:
|
||||
print(
|
||||
f"⚠️ Warning: Failed to clean up test dataset: {response.status_code}"
|
||||
)
|
||||
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
f"❌ Backend did not reload configuration. Available datasets: {list(backend_datasets.keys())}"
|
||||
)
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(
|
||||
"❌ Could not connect to backend. Make sure the Flask server is running on http://localhost:5000"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_config_reload()
|
||||
if success:
|
||||
print("\n🎉 Configuration reload test PASSED!")
|
||||
else:
|
||||
print("\n💥 Configuration reload test FAILED!")
|
|
@ -1,23 +0,0 @@
|
|||
import requests
|
||||
import json
|
||||
|
||||
# Test the symbols load endpoint
|
||||
url = "http://localhost:5050/api/symbols/load"
|
||||
data = {
|
||||
"asc_file_path": "D:/Proyectos/Scripts/Siemens/S7_snap7_Stremer_n_Log/config/data/test_symbols.asc"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=data)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
print(f"Response Headers: {response.headers}")
|
||||
print(f"Response Text: {response.text}")
|
||||
|
||||
if response.headers.get("content-type", "").startswith("application/json"):
|
||||
result = response.json()
|
||||
print(f"JSON Response: {json.dumps(result, indent=2)}")
|
||||
else:
|
||||
print("Response is not JSON, probably HTML error page")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
|
@ -1,105 +0,0 @@
|
|||
"""
|
||||
Test script for CSV header validation functionality
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_header_validation():
|
||||
"""Test the header validation logic without full system dependencies"""
|
||||
|
||||
# Create a temporary test directory
|
||||
test_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
# Test 1: Create a CSV file with old headers
|
||||
old_csv_path = os.path.join(test_dir, "test_data_14.csv")
|
||||
old_headers = ["timestamp", "var1", "var2"]
|
||||
|
||||
with open(old_csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(old_headers)
|
||||
writer.writerow(["2025-08-14 12:00:00", "100", "200"])
|
||||
writer.writerow(["2025-08-14 12:00:01", "101", "201"])
|
||||
|
||||
print(f"✅ Created test CSV file: {old_csv_path}")
|
||||
|
||||
# Test 2: Read headers function
|
||||
def read_csv_headers(file_path):
|
||||
try:
|
||||
with open(file_path, "r", newline="", encoding="utf-8") as file:
|
||||
reader = csv.reader(file)
|
||||
headers = next(reader, [])
|
||||
return headers
|
||||
except (IOError, StopIteration) as e:
|
||||
print(f"Could not read headers from {file_path}: {e}")
|
||||
return []
|
||||
|
||||
# Test 3: Compare headers function
|
||||
def compare_headers(existing_headers, new_headers):
|
||||
return existing_headers == new_headers
|
||||
|
||||
# Test 4: Rename file function
|
||||
def rename_csv_file_with_timestamp(original_path, prefix):
|
||||
directory = os.path.dirname(original_path)
|
||||
timestamp = datetime.now().strftime("%H_%M_%S")
|
||||
new_filename = f"{prefix}_to_{timestamp}.csv"
|
||||
new_path = os.path.join(directory, new_filename)
|
||||
|
||||
# Ensure the new filename is unique
|
||||
counter = 1
|
||||
while os.path.exists(new_path):
|
||||
new_filename = f"{prefix}_to_{timestamp}_{counter}.csv"
|
||||
new_path = os.path.join(directory, new_filename)
|
||||
counter += 1
|
||||
|
||||
shutil.move(original_path, new_path)
|
||||
return new_path
|
||||
|
||||
# Test the functions
|
||||
existing_headers = read_csv_headers(old_csv_path)
|
||||
new_headers = ["timestamp", "var1", "var2", "var3"] # Different headers
|
||||
|
||||
print(f"Existing headers: {existing_headers}")
|
||||
print(f"New headers: {new_headers}")
|
||||
print(f"Headers match: {compare_headers(existing_headers, new_headers)}")
|
||||
|
||||
# Test header mismatch scenario
|
||||
if not compare_headers(existing_headers, new_headers):
|
||||
print("❌ Header mismatch detected! Renaming file...")
|
||||
renamed_path = rename_csv_file_with_timestamp(old_csv_path, "test_data")
|
||||
print(f"✅ File renamed to: {os.path.basename(renamed_path)}")
|
||||
|
||||
# Create new file with correct headers
|
||||
new_csv_path = old_csv_path # Same original path
|
||||
with open(new_csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(new_headers)
|
||||
writer.writerow(["2025-08-14 12:00:02", "102", "202", "302"])
|
||||
|
||||
print(
|
||||
f"✅ Created new CSV file with correct headers: {os.path.basename(new_csv_path)}"
|
||||
)
|
||||
|
||||
# Verify the files
|
||||
print(f"\nFiles in test directory:")
|
||||
for file in os.listdir(test_dir):
|
||||
if file.endswith(".csv"):
|
||||
file_path = os.path.join(test_dir, file)
|
||||
headers = read_csv_headers(file_path)
|
||||
print(f" {file}: {headers}")
|
||||
|
||||
print("\n✅ All tests passed!")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
shutil.rmtree(test_dir)
|
||||
print(f"🧹 Cleaned up test directory: {test_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_header_validation()
|
Binary file not shown.
|
@ -1,67 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for symbol loader functionality.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils.symbol_loader import SymbolLoader
|
||||
|
||||
|
||||
def test_symbol_loader():
|
||||
"""Test the symbol loader with the sample ASC file."""
|
||||
|
||||
# Create a simple logger for testing
|
||||
class SimpleLogger:
|
||||
def info(self, msg):
|
||||
print(f"INFO: {msg}")
|
||||
|
||||
def warning(self, msg):
|
||||
print(f"WARNING: {msg}")
|
||||
|
||||
def error(self, msg):
|
||||
print(f"ERROR: {msg}")
|
||||
|
||||
logger = SimpleLogger()
|
||||
loader = SymbolLoader(logger)
|
||||
|
||||
# Test file paths
|
||||
test_asc_file = "config/data/test_symbols.asc"
|
||||
output_json_file = "config/data/test_output.json"
|
||||
|
||||
try:
|
||||
print(f"Testing symbol loading from: {test_asc_file}")
|
||||
|
||||
if not os.path.exists(test_asc_file):
|
||||
print(f"ERROR: Test file not found: {test_asc_file}")
|
||||
return False
|
||||
|
||||
# Load symbols
|
||||
symbols_count = loader.load_asc_and_save_json(test_asc_file, output_json_file)
|
||||
|
||||
print(f"SUCCESS: Loaded {symbols_count} symbols")
|
||||
print(f"Output saved to: {output_json_file}")
|
||||
|
||||
# Verify output file
|
||||
if os.path.exists(output_json_file):
|
||||
with open(output_json_file, "r", encoding="utf-8") as f:
|
||||
import json
|
||||
|
||||
data = json.load(f)
|
||||
print(f"JSON contains {len(data.get('symbols', []))} symbols")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_symbol_loader()
|
Loading…
Reference in New Issue