Compare commits
No commits in common. "09263d39f85decb2c709b11cd84d532ce15966a4" and "04f5122cc63e1f98c100169cc6a8425ac98458a3" have entirely different histories.
09263d39f8
...
04f5122cc6
|
@ -1,3 +0,0 @@
|
|||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
*.csv
|
||||
|
|
@ -1,28 +1,3 @@
|
|||
2025-08-13
|
||||
|
||||
- Solicitud (resumen): Integrar visualización de plots en tiempo real directamente en `Dashboard.jsx`, debajo de la gestión de plots, para tener en un solo lugar la configuración, creación/modificación de variables, creación/modificación de plots y visualización.
|
||||
|
||||
- Decisiones y cambios:
|
||||
- Se creó el componente `frontend/src/components/PlotRealtimeViewer.jsx` que lista todas las sesiones de plots del backend (`/api/plots`) y renderiza un `ChartjsPlot` por sesión, con controles locales y de backend (start/pause/stop/clear y refresh).
|
||||
- Se integró en `frontend/src/pages/Dashboard.jsx` debajo de `PlotCompleteManager`, con el título “Real-time plots”, cumpliendo la visualización embebida dentro del dashboard.
|
||||
- `PlotRealtimeViewer` refresca estados periódicamente y usa `/api/plots/{session_id}/config` para sincronizar estado/pausa. `ChartjsPlot` ya gestiona la carga de config y streaming desde `/api/plots/{session_id}/data`.
|
||||
|
||||
- Notas técnicas relevantes:
|
||||
- No se tocó el backend; se reutilizan los endpoints existentes (`/api/plots`, `/api/plots/{id}/config`, `/api/plots/{id}/control`, `/api/plots/{id}/data`).
|
||||
- La gestión de definiciones/variables de plots sigue en `PlotCompleteManager`; al guardar, el backend recarga las sesiones. `PlotRealtimeViewer` refresca para reflejar los cambios.
|
||||
|
||||
- Fix: error Chart.js "Cannot read properties of undefined (reading 'handleEvent')".
|
||||
- `frontend/src/components/ChartjsPlot.jsx` ahora:
|
||||
- Solo aplica opciones de zoom si el plugin está registrado; intenta registrar con varias claves globales si existe.
|
||||
- Pausa el scale realtime y limpia intervalos antes de destruir el chart, para evitar ciclos RAF del plugin streaming tras unmount.
|
||||
- En modo fallback (scale `time`), desactiva explícitamente el plugin realtime en `options.plugins.realtime = false`.
|
||||
|
||||
- Fix control de tiempo real en Dashboard (gráficos siempre activos / botones sin efecto):
|
||||
- Problema: los charts iniciaban activos y los botones Start/Pause no surtían efecto (solo Clear funcionaba).
|
||||
- Causas: desincronización entre `pause` del scale realtime y la ingesta local; el efecto que escucha `session.is_active/is_paused` revertía de inmediato las acciones locales.
|
||||
- Cambios: en `frontend/src/components/ChartjsPlot.jsx` se sincronizan `ingestPaused` e `isPaused` con el estado inicial del chart (`pause: !session.is_active || session.is_paused`). Se añade ventana de override temporal (`userOverrideUntil` 3s) tras Start/Pause para evitar que el efecto de sincronización con backend revierta inmediatamente la acción del usuario.
|
||||
- Impacto: los gráficos ahora respetan el estado inicial detenido cuando corresponde; los botones Start/Pause/Stop funcionan sin reanudaciones/pausas espurias.
|
||||
|
||||
# Memoria de Evolución del Proyecto
|
||||
|
||||
## Sistema de Tablas Editables para Datasets y Plots (10/08/2025)
|
||||
|
@ -676,20 +651,3 @@ ChartjsPlot render → Chart.js integration → streaming setup
|
|||
- 🐛 **Error isolation**: Separación clara entre errores de red, backend y frontend
|
||||
- 📋 **User feedback**: Notificaciones claras de problemas y estado del sistema
|
||||
- ⚡ **Graceful handling**: Sistema continúa funcionando aunque algunos servicios fallen
|
||||
|
||||
## 2025-08-13 (tarde)
|
||||
|
||||
- Solicitud (resumen): "Los datasets que leen @dataset_definitions.json no están mostrando el valor real de Dataset Enabled en @Dashboard.jsx".
|
||||
|
||||
- Decisiones y cambios:
|
||||
- Se añadió una insignia de estado en `frontend/src/components/FormTable.jsx` que muestra "Enabled/Disabled" en cada tarjeta de item cuando la data tiene `enabled: boolean`. Esto asegura que, incluso en modo readonly, el estado actual del dataset se vea claramente conforme al JSON `config/data/dataset_definitions.json`.
|
||||
|
||||
- Conocimientos clave:
|
||||
- La UI de datasets usa `FormTable` con `schema.additionalProperties` y `uiSchema` específicos; la propiedad `enabled` se edita con widget checkbox según `dataset-definitions.uischema.json`.
|
||||
- El backend calcula `active_datasets` automáticamente a partir de `datasets[].enabled` y persiste en `dataset_definitions.json` sin campos estáticos, por lo que `enabled` es la fuente de verdad.
|
||||
- Mostrar la insignia evita confusión cuando el formulario está en modo solo lectura o cuando hay latencia entre guardado y recarga.
|
||||
|
||||
- Fix adicional: valores que se reseteaban al cambiar de campo en edición (RJSF)
|
||||
- Causa: El `Form` usaba `formData={data[key]}` y no controlábamos `onChange` en modo edición, por lo que cualquier re-render restauraba el valor original.
|
||||
- Solución: `FormTable.jsx` ahora usa un estado local `editingFormData` cuando `editingKey === key`. Se inicializa al pulsar Edit, `onChange` actualiza `editingFormData`, y `formData` del `Form` se alimenta de ese estado hasta guardar o cancelar.
|
||||
- Impacto: Al editar un item, los cambios entre campos se mantienen correctamente hasta pulsar Save.
|
||||
|
|
|
@ -1,236 +0,0 @@
|
|||
# PLC S7-315 Streamer & Logger - AI Coding Guide
|
||||
|
||||
## Workingflow
|
||||
|
||||
I m usign npm run dev so there is no need to build react
|
||||
Also restart flask every time there is any modification to reset the application
|
||||
So for testing the app now is running on http://localhost:5173/app with vite doing proxy
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a **dual-stack industrial automation system** for Siemens S7-315 PLCs combining Python backend orchestration with React frontend controls:
|
||||
|
||||
- **Backend**: Flask app (`main.py`) orchestrating core modules via `PLCDataStreamer`
|
||||
- **Frontend**: Vite + React + Chakra UI + RJSF for dynamic configuration forms
|
||||
- **Data Flow**: PLC → snap7 → CSV recording + UDP streaming → PlotJuggler visualization
|
||||
- **Configuration**: JSON Schema-driven with validation for PLC variables and plot definitions
|
||||
|
||||
## Core Architecture Principles
|
||||
|
||||
### 1. Orchestrator Pattern
|
||||
`core/plc_data_streamer.py` is the main coordinator that initializes and manages:
|
||||
- `ConfigManager`: JSON configuration persistence with schema validation
|
||||
- `PLCClient`: Siemens snap7 communication
|
||||
- `DataStreamer`: Independent CSV recording + UDP streaming threads
|
||||
- `EventLogger`: Persistent application event logging
|
||||
- `InstanceManager`: Single-instance control with auto-recovery
|
||||
|
||||
### 2. Independent Data Streams
|
||||
**Critical**: CSV recording and UDP streaming are **separate concerns**:
|
||||
- CSV recording: Always active when PLC connected (automatic)
|
||||
- UDP streaming: Manual control for PlotJuggler visualization
|
||||
- Each dataset thread handles both, but UDP transmission is independently controlled
|
||||
|
||||
### 3. Schema-Driven Configuration with RJSF
|
||||
All configuration uses JSON Schema validation with React JSON Schema Forms (RJSF):
|
||||
- **Frontend-First Validation**: RJSF handles all form generation and validation
|
||||
- **Backend API Simplification**: Flask provides simple CRUD operations for JSON files
|
||||
- **Array-Based Data Structure**: All configurations use array format for RJSF compatibility
|
||||
- **Three Form Types**:
|
||||
- Type 1: Single object forms (PLC config)
|
||||
- Type 2: Array management forms (dataset definitions, plot definitions)
|
||||
- Type 3: Filtered array forms with combo selectors (variables linked to datasets/plots)
|
||||
|
||||
### 4. JSON Configuration Structure
|
||||
**CRITICAL**: All JSON files use array-based structures for RJSF compatibility:
|
||||
- `plc_config.json`: Single object with `udp_config` containing `sampling_interval`
|
||||
- `dataset_definitions.json`: `{"datasets": [{"id": "DAR", "name": "...", ...}]}`
|
||||
- `dataset_variables.json`: `{"variables": [{"dataset_id": "DAR", "variables": [...]}]}`
|
||||
- `plot_definitions.json`: `{"plots": [{"id": "plot1", "name": "...", ...}]}`
|
||||
- `plot_variables.json`: `{"plot_variables": [{"plot_id": "plot1", "variables": [...]}]}`
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
# Setup Python environment (REQUIRED before any Python work)
|
||||
conda create -n plc_streamer python=3.10
|
||||
conda activate plc_streamer
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run backend server
|
||||
python main.py
|
||||
# Access at http://localhost:5000
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Development server at http://localhost:5173
|
||||
npm run build # Production build to dist/
|
||||
```
|
||||
|
||||
### Key External Dependencies
|
||||
- **snap7.dll**: Must be in system PATH or project root for PLC communication
|
||||
- **PlotJuggler**: Configured to receive UDP JSON at 127.0.0.1:9870
|
||||
|
||||
## Critical File Patterns
|
||||
|
||||
### Configuration Management
|
||||
- `config/data/*.json`: Runtime configuration files
|
||||
- `config/schema/*.json`: JSON Schema definitions
|
||||
- `ConfigSchemaManager` in `core/schema_manager.py`: Centralized schema loading and validation
|
||||
|
||||
### Frontend Components
|
||||
- `pages/DashboardNew.jsx`: Main control interface with tabbed layout
|
||||
- `components/EditableTable.jsx`: Reusable schema-to-table converter
|
||||
- `components/PlotManager.jsx`: Real-time Chart.js plotting with streaming
|
||||
- **Pattern**: RJSF forms for configuration, custom tables for data management
|
||||
|
||||
### API Endpoints Structure
|
||||
Flask routes in `main.py` follow simplified REST patterns with unified JSON handling:
|
||||
- `/api/config/*`: Unified configuration CRUD operations for all JSON files
|
||||
- `/api/plc/*`: PLC connection and status
|
||||
- `/api/streaming/*`: Data streaming controls
|
||||
- `/api/plots/*`: Plot session management
|
||||
- **API Philosophy**: Backend provides simple file I/O, frontend handles all validation via RJSF
|
||||
|
||||
## Important Conventions
|
||||
|
||||
### 1. Error Handling & Logging
|
||||
- All core classes require logger injection: `__init__(self, logger)`
|
||||
- Use `self.event_logger.log_event()` for persistent events
|
||||
- PyInstaller compatibility: Use `resource_path()` for file access
|
||||
|
||||
### 2. React Component Patterns
|
||||
- **FormTable.jsx**: Single-row forms per item using RJSF schemas
|
||||
- **DatasetFormManager/PlotFormManager**: Master-detail table management
|
||||
- Chakra UI components with consistent styling via `theme.js`
|
||||
- **RJSF Integration**: All forms auto-generated from JSON Schema, no hardcoded form fields
|
||||
|
||||
### 3. Thread Safety
|
||||
- Data streaming uses thread-safe collections and proper cleanup
|
||||
- Instance management prevents multiple app instances
|
||||
- Background threads properly handle graceful shutdown
|
||||
|
||||
### 4. Schema Evolution
|
||||
Follow existing patterns in `config/schema/` - all forms are auto-generated from JSON Schema + UI Schema combinations. Never hardcode form fields.
|
||||
- **Array-First Design**: All multi-item configurations use array structures for RJSF type 2 forms
|
||||
- **Unified Validation**: JSON Schema validation both client-side (RJSF) and server-side (jsonschema library)
|
||||
- **Schema-UI Separation**: Data schemas in `/config/schema/`, UI schemas in `/config/schema/ui/`
|
||||
|
||||
### 5. Development Context
|
||||
- Use `.doc/MemoriaDeEvolucion.md` for understanding recent changes and decisions
|
||||
- Comments and variables must be in English per project conventions
|
||||
- No standalone markdown files unless specifically requested
|
||||
|
||||
## Integration Points
|
||||
|
||||
### PLC Communication
|
||||
- Data blocks accessed via `DB{number}.{offset}` addressing
|
||||
- Supported types: REAL, INT, DINT, BOOL
|
||||
- Connection state managed through `PLCClient` with automatic reconnection
|
||||
|
||||
### Real-time Visualization
|
||||
- Chart.js with chartjs-plugin-streaming for real-time plots
|
||||
- Automatic data ingestion from `/api/plots/{session_id}/data`
|
||||
- Plot session lifecycle managed through backend API
|
||||
|
||||
### CSV Data Export
|
||||
- Automatic file rotation by size/time in `records/` directory
|
||||
- Thread-safe CSV writing with proper cleanup
|
||||
- Configurable retention policies for long-term storage
|
||||
|
||||
|
||||
### Notes
|
||||
Always write software variables and comments in English
|
||||
The development is focused on Windows and after testing must work without CDN completely offline.
|
||||
|
||||
## RJSF Configuration Management
|
||||
|
||||
### Form Type Architecture
|
||||
The system implements three distinct RJSF form patterns:
|
||||
|
||||
**Type 1: Single Object Forms**
|
||||
- Used for: PLC configuration (`plc_config.json`)
|
||||
- Structure: Single JSON object with nested properties
|
||||
- RJSF Pattern: Direct object form rendering
|
||||
- Example: Connection settings, UDP configuration with `sampling_interval`
|
||||
|
||||
**Type 2: Array Management Forms**
|
||||
- Used for: Dataset definitions (`dataset_definitions.json`), Plot definitions (`plot_definitions.json`)
|
||||
- Structure: `{"datasets": [...]}` or `{"plots": [...]}`
|
||||
- RJSF Pattern: Array form with add/remove/edit capabilities
|
||||
- Critical: Root must be array wrapper for RJSF compatibility
|
||||
|
||||
**Type 3: Filtered Array Forms with Combo Selectors**
|
||||
- Used for: Variables linked to datasets/plots (`dataset_variables.json`, `plot_variables.json`)
|
||||
- Structure: Array with foreign key references (`dataset_id`, `plot_id`)
|
||||
- RJSF Pattern: Filtered forms based on selected dataset/plot
|
||||
- Workflow: Select parent → Edit associated variables
|
||||
- **Implementation**: Combo selector + dynamic schema generation for selected item
|
||||
- **Key Functions**: `getSelectedDatasetVariables()`, `updateSelectedDatasetVariables()`
|
||||
|
||||
### RJSF Best Practices and Common Pitfalls
|
||||
**Critical Widget Guidelines**:
|
||||
- **Arrays**: Never specify `"ui:widget": "array"` - arrays use built-in ArrayField component
|
||||
- **Valid Widgets**: text, textarea, select, checkbox, updown, variableSelector
|
||||
- **Widget Registry**: All widgets must be registered in `AllWidgets.jsx`
|
||||
- **Custom Widgets**: Use specific widget names, avoid generic type names
|
||||
|
||||
**Schema Structure Rules**:
|
||||
- **Array Items**: Always include `title` property for array item schemas
|
||||
- **UI Layout**: Use `"ui:layout"` for grid-based field arrangement
|
||||
- **Field Templates**: Leverage `LayoutObjectFieldTemplate` for responsive layouts
|
||||
- **Error Handling**: RJSF errors often indicate missing widgets or malformed schemas
|
||||
|
||||
### Type 3 Form Implementation Pattern
|
||||
```javascript
|
||||
// Step 1: Parent Selector (Combo)
|
||||
const [selectedItemId, setSelectedItemId] = useState('')
|
||||
|
||||
// Step 2: Filtered Data Helper
|
||||
const getSelectedItemData = () => {
|
||||
return allData.find(item => item.parent_id === selectedItemId) || defaultData
|
||||
}
|
||||
|
||||
// Step 3: Update Helper
|
||||
const updateSelectedItemData = (newData) => {
|
||||
const updated = allData.map(item =>
|
||||
item.parent_id === selectedItemId ? { ...item, ...newData } : item
|
||||
)
|
||||
setAllData({ ...allData, items: updated })
|
||||
}
|
||||
|
||||
// Step 4: Dynamic Schema Generation
|
||||
const dynamicSchema = {
|
||||
type: "object",
|
||||
properties: { /* fields specific to selected item */ }
|
||||
}
|
||||
```
|
||||
|
||||
### JSON Schema Migration Notes
|
||||
- **Legacy to Array**: All object-based configs converted to array format
|
||||
- **ID Fields**: Added explicit `id` fields to all array items for referencing
|
||||
- **Validation**: Unified validation using `jsonschema` library server-side + RJSF client-side
|
||||
- **Backward Compatibility**: Migration handled in backend for existing configurations
|
||||
|
||||
### Development Debugging Guide
|
||||
**RJSF Error Resolution**:
|
||||
- `No widget 'X' for type 'Y'`: Check widget registration in `AllWidgets.jsx`
|
||||
- Array rendering errors: Remove `"ui:widget"` specification from array fields
|
||||
- Schema validation failures: Use `validate_schema.py` to test JSON structure
|
||||
- Form not displaying: Verify schema structure matches expected Type 1/2/3 pattern
|
||||
|
||||
**Type 3 Form Debugging**:
|
||||
- Combo not showing options: Check parent data loading and `availableItems` array
|
||||
- Form not updating: Verify `selectedItemId` state and helper functions
|
||||
- Data not persisting: Check `updateSelectedItemData()` logic and save operations
|
||||
- Schema errors: Ensure dynamic schema generation matches data structure
|
||||
|
||||
**Frontend-Backend Integration**:
|
||||
- API endpoint naming: Use consistent `/api/config/{config-name}` pattern
|
||||
- JSON structure validation: Backend uses `jsonschema`, frontend uses RJSF validation
|
||||
- Error handling: Both client and server should handle array format gracefully
|
||||
- Configuration loading: Always verify API response structure before setting form data
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeBackground": "#36182a",
|
||||
"titleBar.activeBackground": "#470606",
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
# 🏭 PLC S7-31x Streamer & Logger - Pure RJSF Dashboard
|
||||
|
||||
## 🚀 Mejoras Implementadas
|
||||
|
||||
### ✅ Dashboard Completamente Nuevo con RJSF Puro
|
||||
- **Archivo:** `frontend/src/pages/DashboardNew.jsx`
|
||||
- Implementado con React JSON Schema Form (RJSF) y tema Chakra UI
|
||||
- Elimina todos los wrappers innecesarios y usa widgets puros y extensibles
|
||||
- Todas las configuraciones se manejan directamente con esquemas JSON
|
||||
|
||||
### 🔧 StatusBar Mejorado con Control Robusto
|
||||
- **Problema resuelto:** Botones de conexión/desconexión ahora funcionan correctamente
|
||||
- Agregados estados de carga individuales para cada acción
|
||||
- Manejo adecuado de errores con toast notifications
|
||||
- Actualización automática del estado después de las acciones
|
||||
|
||||
### 📊 Dataset Manager con RJSF Puro
|
||||
- Formularios completamente basados en esquemas JSON
|
||||
- Edición directa de configuraciones sin wrappers
|
||||
- Dos pestañas: Dataset Definitions y Dataset Variables
|
||||
- Validación automática mediante esquemas JSON
|
||||
|
||||
### 📈 Plot Manager Completamente Funcional
|
||||
- **Archivo:** `frontend/src/components/PlotManager.jsx`
|
||||
- Control completo de sesiones de plotting (start/stop/clear/delete)
|
||||
- **Problema resuelto:** Botones de charts ahora funcionan correctamente
|
||||
- Configuración de plots mediante RJSF puro con esquemas
|
||||
- Vista de sesiones activas con controles en tiempo real
|
||||
|
||||
### 🔌 APIs de Plotting Añadidas
|
||||
- **Archivo:** `frontend/src/services/api.js`
|
||||
- Funciones completas para manejo de plots:
|
||||
- `getPlots()`, `createPlot()`, `deletePlot()`
|
||||
- `controlPlot()` (start/stop/clear)
|
||||
- `getPlotData()`, `getPlotConfig()`, `updatePlotConfig()`
|
||||
- `getPlotVariables()`
|
||||
|
||||
### 🎯 Arquitectura RJSF Pura
|
||||
- **Sin wrappers innecesarios:** Solo widgets extensibles y reutilizables
|
||||
- **Esquemas JSON:** Toda la configuración basada en `/config/schema/`
|
||||
- **UI Schemas:** Layout y configuración visual mediante esquemas UI
|
||||
- **Validación:** Automática con `@rjsf/validator-ajv8`
|
||||
|
||||
### 🗑️ Sistema Legacy Preparado para Eliminación
|
||||
- **Archivo de notas:** `main_cleanup_notes.py`
|
||||
- Identificadas todas las rutas legacy a eliminar
|
||||
- APIs esenciales documentadas para mantener
|
||||
- `templates/index.html` puede ser eliminado
|
||||
- JavaScript legacy en `/static/js/` puede ser eliminado
|
||||
|
||||
## 🔄 Uso de la Aplicación
|
||||
|
||||
### 1. StatusBar (Control Principal)
|
||||
```jsx
|
||||
// Conexión PLC con estado de carga
|
||||
<Button onClick={handleConnectPlc} isLoading={loading}>
|
||||
🔗 Connect
|
||||
</Button>
|
||||
|
||||
// UDP Streaming con feedback
|
||||
<Button onClick={handleStartStreaming} isLoading={loading}>
|
||||
▶️ Start
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. Configuración RJSF
|
||||
```jsx
|
||||
// Formularios puros sin wrappers
|
||||
<Form
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onSubmit={({ formData }) => saveConfig(formData)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. Control de Plots
|
||||
```jsx
|
||||
// Botones funcionales para charts
|
||||
<Button onClick={() => controlPlot(sessionId, 'start')}>
|
||||
▶️ Start
|
||||
</Button>
|
||||
<Button onClick={() => controlPlot(sessionId, 'stop')}>
|
||||
⏹️ Stop
|
||||
</Button>
|
||||
```
|
||||
|
||||
## 📁 Estructura de Archivos Actualizada
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── pages/
|
||||
│ ├── Dashboard.jsx (old - puede ser eliminado)
|
||||
│ └── DashboardNew.jsx ⭐ (nuevo, RJSF puro)
|
||||
├── components/
|
||||
│ ├── DatasetManager.jsx (mejorado con RJSF puro)
|
||||
│ └── PlotManager.jsx ⭐ (nuevo, control completo de charts)
|
||||
└── services/
|
||||
└── api.js (APIs de plotting añadidas)
|
||||
```
|
||||
|
||||
## 🚦 Rutas de la Aplicación
|
||||
|
||||
- **`/`** → React SPA principal
|
||||
- **`/app`** → Dashboard principal
|
||||
- **`/app/*`** → Rutas internas de React
|
||||
|
||||
## 🎨 Tema Chakra UI Completo
|
||||
|
||||
- Todos los componentes usan tema Chakra UI consistente
|
||||
- Color modes (light/dark) funcionales
|
||||
- Cards, Buttons, Tables con estilos uniformes
|
||||
- Toast notifications para feedback
|
||||
|
||||
## ✅ Problemas Resueltos
|
||||
|
||||
1. **✅ Botón connect/disconnect:** Ahora funciona correctamente con estados de carga
|
||||
2. **✅ Edición JSON:** RJSF puro permite edición completa de configuraciones
|
||||
3. **✅ Botones de charts:** Plot Manager implementado con controles funcionales
|
||||
4. **✅ RJSF puro:** Sin wrappers, solo widgets extensibles
|
||||
5. **✅ Layout y esquemas:** Configuración dual (schema + uiSchema) implementada
|
||||
|
||||
## 🚀 Listo para Producción
|
||||
|
||||
- Build exitoso: ✅
|
||||
- RJSF + Chakra UI: ✅
|
||||
- APIs funcionionales: ✅
|
||||
- Sistema legacy preparado para eliminación: ✅
|
||||
- Dashboard completamente funcional: ✅
|
|
@ -1,115 +0,0 @@
|
|||
# UI Schema Layout Support Enhancement Summary
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Enhanced `DashboardNew.jsx`
|
||||
- **Added imports**: `LayoutObjectFieldTemplate` and comprehensive widget collection
|
||||
- **Updated Form components**: All RJSF Form components now use:
|
||||
- `widgets={allWidgets}` - Comprehensive widget collection
|
||||
- `templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}` - Layout support
|
||||
- **Enhanced Configuration Panel**: Now supports full UI schema features
|
||||
- **Updated Dataset Manager**: Both definitions and variables forms support layouts
|
||||
- **Added comprehensive documentation**: Detailed comments explaining UI schema features
|
||||
|
||||
### 2. Created `AllWidgets.jsx`
|
||||
- **Comprehensive widget collection**: Merges all available widgets
|
||||
- **Widget aliases**: Support for different naming conventions in UI schemas
|
||||
- **Custom widget integration**: Includes VariableSelectorWidget and PLC widgets
|
||||
- **Backward compatibility**: Ensures existing UI schemas continue to work
|
||||
|
||||
### 3. Enhanced `CustomWidgets.jsx`
|
||||
- **Added widget aliases**: `variableSelector` for UI schema compatibility
|
||||
- **Maintained existing functionality**: VariableSelectorWidget continues to work
|
||||
|
||||
### 4. Updated `PlotManager.jsx`
|
||||
- **Enhanced Form components**: Added layout template and comprehensive widgets
|
||||
- **Consistent widget usage**: Uses same widget collection as DashboardNew
|
||||
|
||||
### 5. Created Demo Files
|
||||
- **`layout-demo.schema.json`**: Example schema for testing layouts
|
||||
- **`layout-demo.uischema.json`**: Comprehensive UI schema example
|
||||
|
||||
## UI Schema Features Now Supported
|
||||
|
||||
### Layout Management
|
||||
```json
|
||||
{
|
||||
"ui:layout": [
|
||||
[
|
||||
{ "name": "field1", "width": 6 },
|
||||
{ "name": "field2", "width": 6 }
|
||||
],
|
||||
[
|
||||
{ "name": "field3", "width": 12 }
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Types
|
||||
- `updown` - Number input with +/- buttons
|
||||
- `checkbox` - Boolean checkbox
|
||||
- `text` - Text input
|
||||
- `textarea` - Multi-line text
|
||||
- `select` - Dropdown selection
|
||||
- `VariableSelectorWidget` - Custom PLC variable selector
|
||||
|
||||
### Field Properties
|
||||
- `ui:help` - Help text for fields
|
||||
- `ui:placeholder` - Placeholder text
|
||||
- `ui:readonly` - Read-only fields
|
||||
- `ui:order` - Field ordering
|
||||
- `ui:column` - Column width (1-12 grid)
|
||||
|
||||
### Examples from Existing Schemas
|
||||
|
||||
#### PLC Configuration with Layout
|
||||
```json
|
||||
{
|
||||
"plc_config": {
|
||||
"ui:layout": [
|
||||
[
|
||||
{ "name": "ip", "width": 6 },
|
||||
{ "name": "rack", "width": 3 },
|
||||
{ "name": "slot", "width": 3 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Dataset Definitions with Responsive Layout
|
||||
```json
|
||||
{
|
||||
"datasets": {
|
||||
"ui:layout": [
|
||||
[
|
||||
{ "name": "name", "width": 3 },
|
||||
{ "name": "prefix", "width": 3 },
|
||||
{ "name": "sampling_interval", "width": 3 },
|
||||
{ "name": "enabled", "width": 3 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Responsive Design**: 12-column grid system adapts to different screen sizes
|
||||
2. **Better UX**: Logical field grouping and intuitive layouts
|
||||
3. **Consistent Styling**: All forms use the same Chakra UI components
|
||||
4. **Extensible**: Easy to add new widgets and layout patterns
|
||||
5. **Backward Compatible**: Existing configurations continue to work
|
||||
6. **Documentation**: Clear examples and comprehensive comments
|
||||
|
||||
## Testing
|
||||
|
||||
The enhanced UI schema support can be tested by:
|
||||
1. Loading any existing configuration (PLC, datasets, plots)
|
||||
2. Observing the improved layout with proper field grouping
|
||||
3. Testing different screen sizes for responsive behavior
|
||||
4. Adding new configurations with custom layouts
|
||||
5. Using the demo schema files for comprehensive testing
|
||||
|
||||
All existing functionality is preserved while adding powerful new layout capabilities.
|
File diff suppressed because it is too large
Load Diff
|
@ -1,27 +1,18 @@
|
|||
{
|
||||
"datasets": [
|
||||
{
|
||||
"created": "2025-08-08T15:47:18.566053",
|
||||
"enabled": true,
|
||||
"id": "DAR",
|
||||
"name": "DAR",
|
||||
"prefix": "gateway_phoenix",
|
||||
"sampling_interval": 0.5
|
||||
},
|
||||
{
|
||||
"created": "2025-08-09T02:06:26.840011",
|
||||
"enabled": true,
|
||||
"id": "Fast",
|
||||
"name": "Fast",
|
||||
"prefix": "fast",
|
||||
"sampling_interval": 0.62
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"id": "Test",
|
||||
"name": "test",
|
||||
"prefix": "test",
|
||||
"sampling_interval": 1
|
||||
"datasets": {
|
||||
"DAR": {
|
||||
"created": "2025-08-08T15:47:18.566053",
|
||||
"enabled": true,
|
||||
"name": "DAR",
|
||||
"prefix": "gateway_phoenix",
|
||||
"sampling_interval": 1
|
||||
},
|
||||
"Fast": {
|
||||
"created": "2025-08-09T02:06:26.840011",
|
||||
"enabled": true,
|
||||
"name": "Fast",
|
||||
"prefix": "fast",
|
||||
"sampling_interval": 0.1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,37 +1,52 @@
|
|||
{
|
||||
"variables": [
|
||||
{
|
||||
"dataset_id": "DAR",
|
||||
"variables": [
|
||||
{
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"name": "UR29_Brix",
|
||||
"offset": 1322,
|
||||
"streaming": true,
|
||||
"type": "real"
|
||||
"dataset_variables": {
|
||||
"DAR": {
|
||||
"variables": {
|
||||
"UR29_Brix": {
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"offset": 1322,
|
||||
"streaming": true,
|
||||
"type": "real"
|
||||
},
|
||||
"UR29_ma": {
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"offset": 1296,
|
||||
"streaming": true,
|
||||
"type": "real"
|
||||
},
|
||||
"fUR29_Brix": {
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"offset": 1322,
|
||||
"streaming": false,
|
||||
"type": "real"
|
||||
}
|
||||
},
|
||||
"streaming_variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"name": "UR29_ma",
|
||||
"offset": 1296,
|
||||
"streaming": true,
|
||||
"type": "real"
|
||||
},
|
||||
{
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"name": "fUR29_Brix",
|
||||
"offset": 1322,
|
||||
"streaming": true,
|
||||
"type": "real"
|
||||
"Fast": {
|
||||
"variables": {
|
||||
"fUR29_Brix": {
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"offset": 1322,
|
||||
"streaming": false,
|
||||
"type": "real"
|
||||
},
|
||||
"fUR29_ma": {
|
||||
"area": "db",
|
||||
"db": 1011,
|
||||
"offset": 1296,
|
||||
"streaming": false,
|
||||
"type": "real"
|
||||
}
|
||||
},
|
||||
"streaming_variables": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"dataset_id": "Fast",
|
||||
"variables": []
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,18 +1,21 @@
|
|||
{
|
||||
"csv_config": {
|
||||
"max_days": 30,
|
||||
"max_size_mb": 1000,
|
||||
"records_directory": "records",
|
||||
"rotation_enabled": true
|
||||
},
|
||||
"plc_config": {
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
},
|
||||
"udp_config": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9870,
|
||||
"sampling_interval": 1
|
||||
}
|
||||
"plc_config": {
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
},
|
||||
"udp_config": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9870
|
||||
},
|
||||
"sampling_interval": 0.1,
|
||||
"csv_config": {
|
||||
"records_directory": "records",
|
||||
"rotation_enabled": true,
|
||||
"max_size_mb": 1000,
|
||||
"max_days": 30,
|
||||
"max_hours": null,
|
||||
"cleanup_interval_hours": 24,
|
||||
"last_cleanup": "2025-08-13T00:09:19.306354"
|
||||
}
|
||||
}
|
|
@ -1,21 +1,14 @@
|
|||
{
|
||||
"plots": [
|
||||
{
|
||||
"id": "plot_1",
|
||||
"name": "UR29",
|
||||
"time_window": 25,
|
||||
"trigger_enabled": false,
|
||||
"trigger_on_true": true,
|
||||
"trigger_variable": null,
|
||||
"y_max": null,
|
||||
"y_min": null
|
||||
},
|
||||
{
|
||||
"id": "Brix",
|
||||
"name": "Brix",
|
||||
"time_window": 60,
|
||||
"trigger_enabled": false,
|
||||
"trigger_on_true": true
|
||||
"plots": {
|
||||
"plot_1": {
|
||||
"name": "UR29",
|
||||
"time_window": 75,
|
||||
"y_min": null,
|
||||
"y_max": null,
|
||||
"trigger_variable": null,
|
||||
"trigger_enabled": false,
|
||||
"trigger_on_true": true,
|
||||
"session_id": "plot_1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,19 +1,28 @@
|
|||
{
|
||||
"variables": [
|
||||
{
|
||||
"plot_id": "plot_1",
|
||||
"variables": [
|
||||
{
|
||||
"color": "#3498db",
|
||||
"enabled": true,
|
||||
"variable_name": "UR29_Brix"
|
||||
},
|
||||
{
|
||||
"color": "#e74c3c",
|
||||
"enabled": true,
|
||||
"variable_name": "UR29_ma"
|
||||
"plot_variables": {
|
||||
"plot_1": {
|
||||
"variables": {
|
||||
"var_1": {
|
||||
"variable_name": "UR29_Brix",
|
||||
"color": "#3498db",
|
||||
"enabled": true
|
||||
},
|
||||
"var_2": {
|
||||
"variable_name": "UR29_ma",
|
||||
"color": "#e74c3c",
|
||||
"enabled": true
|
||||
},
|
||||
"var_3": {
|
||||
"variable_name": "fUR29_Brix",
|
||||
"color": "#2ecc71",
|
||||
"enabled": true
|
||||
},
|
||||
"var_4": {
|
||||
"variable_name": "fUR29_ma",
|
||||
"color": "#f39c12",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,20 +1,33 @@
|
|||
{
|
||||
"$id": "dataset-definitions.array.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "dataset-definitions.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"description": "Schema for dataset definitions (metadata only, no variables)",
|
||||
"properties": {
|
||||
"datasets": {
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"additionalProperties": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "Unique identifier of the dataset",
|
||||
"maxLength": 60,
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"title": "ID",
|
||||
"type": "string"
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"enum": [
|
||||
true,
|
||||
false
|
||||
],
|
||||
"options": {
|
||||
"enum_titles": [
|
||||
"Activate",
|
||||
"Deactivate"
|
||||
]
|
||||
},
|
||||
"title": "Dataset Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "Human-readable name of the dataset",
|
||||
|
@ -31,47 +44,30 @@
|
|||
"title": "CSV Prefix",
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"options": {
|
||||
"enum_titles": [
|
||||
"Activate",
|
||||
"Deactivate"
|
||||
]
|
||||
},
|
||||
"title": "Dataset Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"title": "Sampling interval (s)",
|
||||
"type": ["number", "null"],
|
||||
"minimum": 0.01,
|
||||
"description": "Leave empty to use the global interval",
|
||||
"maximum": 10,
|
||||
"default": null,
|
||||
"description": "Leave null to use global sampling_interval"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
"minimum": 0.01,
|
||||
"title": "Sampling interval (s)",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"prefix"
|
||||
],
|
||||
"title": "Dataset",
|
||||
"type": "object",
|
||||
"dependencies": {}
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Datasets",
|
||||
"type": "array"
|
||||
"title": "Dataset Definitions",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"datasets"
|
||||
],
|
||||
"title": "Dataset Definitions",
|
||||
"type": "object",
|
||||
"dependencies": {}
|
||||
"type": "object"
|
||||
}
|
|
@ -1,35 +1,24 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "dataset-variables.schema.json",
|
||||
"title": "Dataset Variables",
|
||||
"description": "Schema for variables assigned to each dataset",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"variables": {
|
||||
"type": "array",
|
||||
"title": "Dataset Variables Collection",
|
||||
"description": "Array of dataset variable configurations",
|
||||
"items": {
|
||||
"dataset_variables": {
|
||||
"type": "object",
|
||||
"title": "Variables by Dataset",
|
||||
"description": "Variables organized by dataset ID",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataset_id": {
|
||||
"type": "string",
|
||||
"title": "Dataset ID",
|
||||
"description": "Unique identifier for the dataset"
|
||||
},
|
||||
"variables": {
|
||||
"type": "array",
|
||||
"title": "Variables",
|
||||
"description": "Array of PLC variables for this dataset",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"title": "Dataset Variables",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Variable Name",
|
||||
"description": "Human-readable name for the variable"
|
||||
},
|
||||
"area": {
|
||||
"type": "string",
|
||||
"title": "Memory Area",
|
||||
|
@ -89,27 +78,33 @@
|
|||
"streaming": {
|
||||
"type": "boolean",
|
||||
"title": "Stream to PlotJuggler",
|
||||
"description": "Include this variable in UDP streaming",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"area",
|
||||
"offset",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"streaming_variables": {
|
||||
"type": "array",
|
||||
"title": "Streaming variables",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dataset_id",
|
||||
"variables"
|
||||
"variables",
|
||||
"streaming_variables"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"variables"
|
||||
"dataset_variables"
|
||||
]
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Enhanced UI Schema Layout Demo",
|
||||
"description": "Demo configuration showcasing enhanced UI schema layout features",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"basic_text": {
|
||||
"type": "string",
|
||||
"title": "Basic Text Input",
|
||||
"description": "A simple text input field"
|
||||
},
|
||||
"updown_number": {
|
||||
"type": "number",
|
||||
"title": "Number Input",
|
||||
"description": "A numeric input with up/down controls",
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"default": 50
|
||||
},
|
||||
"enabled_checkbox": {
|
||||
"type": "boolean",
|
||||
"title": "Enable Feature",
|
||||
"description": "Toggle to enable or disable this feature",
|
||||
"default": true
|
||||
},
|
||||
"long_description": {
|
||||
"type": "string",
|
||||
"title": "Description",
|
||||
"description": "A longer text description"
|
||||
},
|
||||
"dropdown_selection": {
|
||||
"type": "string",
|
||||
"title": "Selection",
|
||||
"description": "Choose from predefined options",
|
||||
"enum": [
|
||||
"option1",
|
||||
"option2",
|
||||
"option3"
|
||||
],
|
||||
"enumNames": [
|
||||
"First Option",
|
||||
"Second Option",
|
||||
"Third Option"
|
||||
]
|
||||
},
|
||||
"variable_selector": {
|
||||
"type": "string",
|
||||
"title": "PLC Variable",
|
||||
"description": "Select a variable from the PLC"
|
||||
},
|
||||
"readonly_field": {
|
||||
"type": "string",
|
||||
"title": "Read-only Field",
|
||||
"description": "This field cannot be modified",
|
||||
"default": "Read-only value"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"basic_text",
|
||||
"updown_number"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"$id": "plc.schema.json",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"dependencies": {},
|
||||
"description": "Schema to edit plc_config.json",
|
||||
|
@ -8,12 +9,31 @@
|
|||
"additionalProperties": false,
|
||||
"dependencies": {},
|
||||
"properties": {
|
||||
"cleanup_interval_hours": {
|
||||
"default": 24,
|
||||
"minimum": 1,
|
||||
"title": "Cleanup Interval (h)",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_cleanup": {
|
||||
"title": "Last Cleanup",
|
||||
"type": "string"
|
||||
},
|
||||
"max_days": {
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"title": "Max Days",
|
||||
"type": "integer"
|
||||
},
|
||||
"max_hours": {
|
||||
"default": null,
|
||||
"minimum": 1,
|
||||
"title": "Max Hours",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"max_size_mb": {
|
||||
"default": 1000,
|
||||
"minimum": 1,
|
||||
|
@ -39,6 +59,7 @@
|
|||
}
|
||||
},
|
||||
"required": [
|
||||
"cleanup_interval_hours",
|
||||
"records_directory",
|
||||
"rotation_enabled"
|
||||
],
|
||||
|
@ -56,11 +77,18 @@
|
|||
"type": "string"
|
||||
},
|
||||
"rack": {
|
||||
"default": 0,
|
||||
"description": "Rack of PLC",
|
||||
"maximum": 7,
|
||||
"minimum": 0,
|
||||
"title": "Rack",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
"type": "integer"
|
||||
},
|
||||
"slot": {
|
||||
"default": 2,
|
||||
"description": "Normally 2",
|
||||
"maximum": 31,
|
||||
"minimum": 0,
|
||||
"title": "Slot",
|
||||
"type": "integer"
|
||||
}
|
||||
|
@ -73,6 +101,14 @@
|
|||
"title": "PLC Configuration",
|
||||
"type": "object"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"default": 0.1,
|
||||
"description": "interval sampling in seconds",
|
||||
"maximum": 10,
|
||||
"minimum": 0.01,
|
||||
"title": "Sampling Interval (s)",
|
||||
"type": "number"
|
||||
},
|
||||
"udp_config": {
|
||||
"additionalProperties": false,
|
||||
"dependencies": {},
|
||||
|
@ -88,30 +124,21 @@
|
|||
"default": 9870,
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"title": "UDP Port",
|
||||
"type": "integer"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"default": 1,
|
||||
"description": "Time interval for UDP streaming data transmission",
|
||||
"maximum": 60,
|
||||
"minimum": 0.01,
|
||||
"title": "UDP Sampling Interval (s)",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port",
|
||||
"sampling_interval"
|
||||
"port"
|
||||
],
|
||||
"title": "UDP Streaming Configuration",
|
||||
"title": "UDP Configuration",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"csv_config",
|
||||
"plc_config",
|
||||
"sampling_interval",
|
||||
"udp_config"
|
||||
],
|
||||
"title": "PLC & UDP Configuration",
|
||||
|
|
|
@ -1,64 +1,76 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "plot-definitions.schema.json",
|
||||
"title": "Plot Definitions",
|
||||
"description": "Schema for plot session definitions (metadata only, no variables)",
|
||||
"type": "object",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"description": "Schema for plot session definitions (metadata only, no variables)",
|
||||
"properties": {
|
||||
"plots": {
|
||||
"type": "array",
|
||||
"title": "Plot Definitions",
|
||||
"description": "Array of plot session configurations",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Plot ID",
|
||||
"description": "Unique identifier for the plot session"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name of the plot session",
|
||||
"title": "Plot Name",
|
||||
"description": "Human-readable name of the plot session"
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"title": "Session Id",
|
||||
"type": "string"
|
||||
},
|
||||
"time_window": {
|
||||
"type": "integer",
|
||||
"title": "Time window (s)",
|
||||
"default": 60,
|
||||
"description": "Time window in seconds",
|
||||
"minimum": 5,
|
||||
"maximum": 3600,
|
||||
"default": 60
|
||||
},
|
||||
"y_min": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Y Min",
|
||||
"description": "Leave empty for auto"
|
||||
},
|
||||
"y_max": {
|
||||
"type": ["number", "null"],
|
||||
"title": "Y Max",
|
||||
"description": "Leave empty for auto"
|
||||
},
|
||||
"trigger_variable": {
|
||||
"type": ["string", "null"],
|
||||
"title": "Trigger Variable"
|
||||
"minimum": 5,
|
||||
"title": "Time window (s)",
|
||||
"type": "integer"
|
||||
},
|
||||
"trigger_enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"title": "Enable Trigger",
|
||||
"default": false
|
||||
"type": "boolean"
|
||||
},
|
||||
"trigger_on_true": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"title": "Trigger on True",
|
||||
"default": true
|
||||
"type": "boolean"
|
||||
},
|
||||
"trigger_variable": {
|
||||
"title": "Trigger Variable",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"y_max": {
|
||||
"description": "Leave empty for auto",
|
||||
"title": "Y Max",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"y_min": {
|
||||
"description": "Leave empty for auto",
|
||||
"title": "Y Min",
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "time_window"]
|
||||
}
|
||||
"required": [
|
||||
"name",
|
||||
"time_window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Plot Definitions",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["plots"]
|
||||
"required": [
|
||||
"plots"
|
||||
],
|
||||
"title": "Plot Definitions",
|
||||
"type": "object"
|
||||
}
|
|
@ -1,34 +1,29 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "plot-variables.schema.json",
|
||||
"title": "Plot Variables",
|
||||
"description": "Schema for variables assigned to each plot session",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"variables": {
|
||||
"type": "array",
|
||||
"title": "Plot Variables Collection",
|
||||
"description": "Array of plot variable configurations",
|
||||
"items": {
|
||||
"plot_variables": {
|
||||
"type": "object",
|
||||
"title": "Variables by Plot",
|
||||
"description": "Variables organized by plot ID",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plot_id": {
|
||||
"type": "string",
|
||||
"title": "Plot ID",
|
||||
"description": "Unique identifier for the plot session"
|
||||
},
|
||||
"variables": {
|
||||
"type": "array",
|
||||
"title": "Variables",
|
||||
"description": "Array of variables for this plot with visualization settings",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"title": "Plot Variables",
|
||||
"description": "Variables configuration for plotting with colors",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"variable_name": {
|
||||
"type": "string",
|
||||
"title": "Variable Name",
|
||||
"description": "Name of the variable to plot (must exist in dataset variables)"
|
||||
"description": "Select a variable from available dataset variables"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
|
@ -46,19 +41,19 @@
|
|||
},
|
||||
"required": [
|
||||
"variable_name",
|
||||
"color"
|
||||
"color",
|
||||
"enabled"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plot_id",
|
||||
"variables"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"variables"
|
||||
"plot_variables"
|
||||
]
|
||||
}
|
|
@ -1,25 +1,14 @@
|
|||
{
|
||||
"ui:order": ["datasets"],
|
||||
"datasets": {
|
||||
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
|
||||
"ui:options": { "addable": true, "orderable": true, "removable": true },
|
||||
"items": {
|
||||
"ui:order": ["id", "name", "prefix", "enabled", "sampling_interval", "created"],
|
||||
"ui:layout": [
|
||||
[
|
||||
{ "name": "id", "width": 3 },
|
||||
{ "name": "name", "width": 3 },
|
||||
{ "name": "prefix", "width": 3 },
|
||||
{ "name": "enabled", "width": 3 }
|
||||
],
|
||||
[
|
||||
{ "name": "sampling_interval", "width": 3 },
|
||||
{ "name": "created", "width": 3 }
|
||||
]
|
||||
],
|
||||
"id": {
|
||||
"ui:help": "Unique ID for this dataset (alphanumeric, underscore, dash)",
|
||||
"ui:placeholder": "e.g., DAR, Fast"
|
||||
"additionalProperties": {
|
||||
"created": {
|
||||
"ui:help": "Timestamp when this dataset was created",
|
||||
"ui:readonly": true,
|
||||
"ui:widget": "text"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"name": {
|
||||
"ui:help": "Human-readable name for this dataset",
|
||||
|
@ -29,21 +18,60 @@
|
|||
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
|
||||
"ui:placeholder": "e.g., temp, line_a, sensors"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:help": "Custom sampling interval in seconds (0.01–10). Leave empty to use the global PLC sampling interval.",
|
||||
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval.",
|
||||
"ui:placeholder": "Leave empty to use global interval",
|
||||
"ui:widget": "updown",
|
||||
"ui:options": { "step": 0.01, "min": 0.01, "max": 10 }
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"created": {
|
||||
"ui:help": "Timestamp when this dataset was created",
|
||||
"ui:readonly": true,
|
||||
"ui:widget": "text"
|
||||
}
|
||||
"ui:order": [
|
||||
"name",
|
||||
"prefix",
|
||||
"enabled",
|
||||
"sampling_interval",
|
||||
"created"
|
||||
]
|
||||
},
|
||||
"ui:column": 3,
|
||||
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "name",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "prefix",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "sampling_interval",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "enabled",
|
||||
"width": 3
|
||||
}
|
||||
]
|
||||
],
|
||||
"name": {
|
||||
"ui:column": 3
|
||||
},
|
||||
"prefix": {
|
||||
"ui:column": 3
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:column": 3
|
||||
},
|
||||
"enabled": {
|
||||
"ui:column": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui:order": [
|
||||
"datasets"
|
||||
]
|
||||
}
|
|
@ -1,21 +1,16 @@
|
|||
{
|
||||
"variables": {
|
||||
"dataset_variables": {
|
||||
"ui:description": "⚙️ Configure PLC variables for each dataset - specify memory areas, data types, and streaming options",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"orderable": false,
|
||||
"removable": true
|
||||
},
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"ui:order": [
|
||||
"dataset_id",
|
||||
"variables"
|
||||
"variables",
|
||||
"streaming_variables"
|
||||
],
|
||||
"dataset_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Enter unique dataset identifier",
|
||||
"ui:help": "🆔 Unique identifier for this dataset (must match existing dataset)"
|
||||
},
|
||||
"variables": {
|
||||
"ui:description": "🔧 PLC Variable Definitions",
|
||||
"ui:help": "Define PLC memory locations, data types, and properties for each variable",
|
||||
|
@ -24,9 +19,8 @@
|
|||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"ui:order": [
|
||||
"name",
|
||||
"area",
|
||||
"db",
|
||||
"offset",
|
||||
|
@ -36,13 +30,9 @@
|
|||
],
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "name",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "area",
|
||||
"width": 2
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "db",
|
||||
|
@ -50,6 +40,10 @@
|
|||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "bit",
|
||||
"width": 2
|
||||
},
|
||||
{
|
||||
|
@ -58,21 +52,12 @@
|
|||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "bit",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "streaming",
|
||||
"width": 9
|
||||
"width": 12
|
||||
}
|
||||
]
|
||||
],
|
||||
"name": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Variable name",
|
||||
"ui:help": "📝 Human-readable name for this variable"
|
||||
},
|
||||
"area": {
|
||||
"ui:widget": "select",
|
||||
"ui:help": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
|
||||
|
@ -187,10 +172,15 @@
|
|||
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming_variables": {
|
||||
"ui:widget": "checkboxes",
|
||||
"ui:description": "📡 Streaming Variables",
|
||||
"ui:help": "Variables that are streamed in real-time to PlotJuggler. This list is automatically updated when you enable/disable streaming on individual variables above."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui:order": [
|
||||
"variables"
|
||||
"dataset_variables"
|
||||
]
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
{
|
||||
"ui:title": "Enhanced UI Schema Layout Demo",
|
||||
"ui:description": "This demo showcases the full UI schema layout capabilities of the enhanced RJSF implementation",
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "basic_text",
|
||||
"width": 6
|
||||
},
|
||||
{
|
||||
"name": "updown_number",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "enabled_checkbox",
|
||||
"width": 3
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "long_description",
|
||||
"width": 12
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "dropdown_selection",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "variable_selector",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "readonly_field",
|
||||
"width": 4
|
||||
}
|
||||
]
|
||||
],
|
||||
"ui:order": [
|
||||
"basic_text",
|
||||
"updown_number",
|
||||
"enabled_checkbox",
|
||||
"long_description",
|
||||
"dropdown_selection",
|
||||
"variable_selector",
|
||||
"readonly_field"
|
||||
],
|
||||
"basic_text": {
|
||||
"ui:placeholder": "Enter some text here...",
|
||||
"ui:help": "This is a standard text input with placeholder and help text"
|
||||
},
|
||||
"updown_number": {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "Use +/- buttons to adjust the value"
|
||||
},
|
||||
"enabled_checkbox": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "Toggle this setting on/off"
|
||||
},
|
||||
"long_description": {
|
||||
"ui:widget": "textarea",
|
||||
"ui:placeholder": "Enter a longer description here...",
|
||||
"ui:help": "Multi-line text area that spans the full width"
|
||||
},
|
||||
"dropdown_selection": {
|
||||
"ui:widget": "select",
|
||||
"ui:help": "Choose an option from the dropdown"
|
||||
},
|
||||
"variable_selector": {
|
||||
"ui:widget": "VariableSelectorWidget",
|
||||
"ui:help": "Select a variable from the available PLC variables"
|
||||
},
|
||||
"readonly_field": {
|
||||
"ui:readonly": true,
|
||||
"ui:help": "This field is read-only and cannot be edited"
|
||||
}
|
||||
}
|
|
@ -1,62 +1,81 @@
|
|||
{
|
||||
"csv_config": {
|
||||
"cleanup_interval_hours": {
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"last_cleanup": {},
|
||||
"max_days": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"max_hours": {
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"max_size_mb": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"records_directory": {
|
||||
"ui:column": 3,
|
||||
"ui:placeholder": "records"
|
||||
},
|
||||
"rotation_enabled": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"ui:order": [
|
||||
"max_days",
|
||||
"max_size_mb",
|
||||
"records_directory",
|
||||
"rotation_enabled"
|
||||
],
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "max_days",
|
||||
"name": "cleanup_interval_hours",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "last_cleanup",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "max_days",
|
||||
"width": 2
|
||||
},
|
||||
{
|
||||
"name": "max_hours",
|
||||
"width": 2
|
||||
},
|
||||
{
|
||||
"name": "max_size_mb",
|
||||
"width": 3
|
||||
"width": 2
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "records_directory",
|
||||
"width": 10
|
||||
},
|
||||
{
|
||||
"name": "rotation_enabled",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "records_directory",
|
||||
"width": 3
|
||||
"width": 2
|
||||
}
|
||||
]
|
||||
],
|
||||
"ui:order": [
|
||||
"cleanup_interval_hours",
|
||||
"last_cleanup",
|
||||
"max_days",
|
||||
"max_hours",
|
||||
"max_size_mb",
|
||||
"records_directory",
|
||||
"rotation_enabled"
|
||||
]
|
||||
},
|
||||
"plc_config": {
|
||||
"ip": {
|
||||
"ui:column": 6,
|
||||
"ui:placeholder": "192.168.1.100"
|
||||
"ui:placeholder": "192.168.1.100",
|
||||
"ui:column": 6
|
||||
},
|
||||
"rack": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown"
|
||||
"ui:widget": "updown",
|
||||
"ui:column": 3
|
||||
},
|
||||
"slot": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown"
|
||||
"ui:widget": "updown",
|
||||
"ui:column": 3
|
||||
},
|
||||
"ui:column": 12,
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
|
@ -79,66 +98,59 @@
|
|||
"slot"
|
||||
]
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"udp_config": {
|
||||
"host": {
|
||||
"ui:column": 4,
|
||||
"ui:placeholder": "127.0.0.1"
|
||||
},
|
||||
"port": {
|
||||
"ui:column": 4,
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:column": 4,
|
||||
"ui:help": "⏱️ Time interval between UDP data transmissions for real-time streaming",
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"ui:column": 12,
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "host",
|
||||
"width": 4
|
||||
"width": 6
|
||||
},
|
||||
{
|
||||
"name": "port",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "sampling_interval",
|
||||
"width": 4
|
||||
"width": 6
|
||||
}
|
||||
]
|
||||
],
|
||||
"ui:order": [
|
||||
"host",
|
||||
"port",
|
||||
"sampling_interval"
|
||||
"port"
|
||||
]
|
||||
},
|
||||
"ui:order": [
|
||||
"csv_config",
|
||||
"plc_config",
|
||||
"udp_config"
|
||||
],
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "plc_config",
|
||||
"width": 12
|
||||
}
|
||||
],
|
||||
[
|
||||
"width": 6
|
||||
},
|
||||
{
|
||||
"name": "udp_config",
|
||||
"width": 12
|
||||
"width": 6
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "csv_config",
|
||||
"width": 12
|
||||
"width": 10
|
||||
},
|
||||
{
|
||||
"name": "sampling_interval",
|
||||
"width": 2
|
||||
}
|
||||
]
|
||||
],
|
||||
"ui:order": [
|
||||
"csv_config",
|
||||
"plc_config",
|
||||
"sampling_interval",
|
||||
"udp_config"
|
||||
]
|
||||
}
|
|
@ -1,104 +1,77 @@
|
|||
{
|
||||
"plots": {
|
||||
"ui:description": "🎯 Configure plot sessions - set time windows, Y axis ranges, and trigger conditions",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"items": {
|
||||
"ui:order": [
|
||||
"id",
|
||||
"name",
|
||||
"time_window",
|
||||
"y_min",
|
||||
"y_max",
|
||||
"trigger_variable",
|
||||
"trigger_enabled",
|
||||
"trigger_on_true"
|
||||
],
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "id",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"width": 6
|
||||
},
|
||||
{
|
||||
"name": "time_window",
|
||||
"width": 2
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "y_min",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "y_max",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "trigger_variable",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "trigger_enabled",
|
||||
"width": 3
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "trigger_on_true",
|
||||
"width": 12
|
||||
}
|
||||
]
|
||||
],
|
||||
"id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "plot_1",
|
||||
"ui:help": "🆔 Unique identifier for this plot"
|
||||
},
|
||||
"name": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "My Plot",
|
||||
"ui:help": "📊 Human-readable name for the plot"
|
||||
},
|
||||
"session_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "plot_1",
|
||||
"ui:help": "🔗 Session identifier (usually same as ID)"
|
||||
},
|
||||
"time_window": {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "⏱️ Time window in seconds (5-3600)"
|
||||
},
|
||||
"y_min": {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "📉 Minimum Y axis value (leave empty for auto)"
|
||||
},
|
||||
"y_max": {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "📈 Maximum Y axis value (leave empty for auto)"
|
||||
},
|
||||
"trigger_variable": {
|
||||
"ui:widget": "text",
|
||||
"ui:help": "🎯 Variable name to use as trigger (optional)"
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"trigger_enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "✅ Enable trigger-based recording"
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"trigger_on_true": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
|
||||
"ui:widget": "checkbox"
|
||||
},
|
||||
"y_max": {
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"y_min": {
|
||||
"ui:widget": "updown"
|
||||
}
|
||||
},
|
||||
"ui:description": "Plot session configuration (time window, Y axis, triggers)",
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "session_id",
|
||||
"width": 2
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "trigger_variable",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "trigger_enabled",
|
||||
"width": 2
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "time_window",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "y_min",
|
||||
"width": 4
|
||||
},
|
||||
{
|
||||
"name": "y_max",
|
||||
"width": 4
|
||||
}
|
||||
]
|
||||
],
|
||||
"session_id": {
|
||||
"ui:column": 2
|
||||
},
|
||||
"name": {
|
||||
"ui:column": 4
|
||||
},
|
||||
"trigger_variable": {
|
||||
"ui:column": 4
|
||||
},
|
||||
"trigger_enabled": {
|
||||
"ui:column": 2
|
||||
},
|
||||
"time_window": {
|
||||
"ui:column": 4
|
||||
},
|
||||
"y_min": {
|
||||
"ui:column": 4
|
||||
},
|
||||
"y_max": {
|
||||
"ui:column": 4
|
||||
}
|
||||
},
|
||||
"ui:order": [
|
||||
"plots"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,21 +1,15 @@
|
|||
{
|
||||
"variables": {
|
||||
"plot_variables": {
|
||||
"ui:description": "📊 Configure plot variables with colors and settings for real-time visualization",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
"orderable": false,
|
||||
"removable": true
|
||||
},
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"ui:order": [
|
||||
"plot_id",
|
||||
"variables"
|
||||
],
|
||||
"plot_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Enter unique plot identifier",
|
||||
"ui:help": "🆔 Unique identifier for this plot session (must match existing plot)"
|
||||
},
|
||||
"variables": {
|
||||
"ui:description": "🎨 Plot Variable Configuration",
|
||||
"ui:help": "Configure colors and display settings for each variable in the plot",
|
||||
|
@ -24,32 +18,38 @@
|
|||
"orderable": true,
|
||||
"removable": true
|
||||
},
|
||||
"items": {
|
||||
"additionalProperties": {
|
||||
"ui:order": [
|
||||
"variable_name",
|
||||
"color",
|
||||
"enabled"
|
||||
"enabled",
|
||||
"color"
|
||||
],
|
||||
"ui:layout": [
|
||||
[
|
||||
{
|
||||
"name": "variable_name",
|
||||
"width": 12
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "enabled",
|
||||
"width": 6
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"width": 3
|
||||
},
|
||||
{
|
||||
"name": "enabled",
|
||||
"width": 3
|
||||
"width": 6
|
||||
}
|
||||
]
|
||||
],
|
||||
"variable_name": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "UR29_Brix",
|
||||
"ui:help": "<22> Name of the variable to plot (must exist in dataset variables)"
|
||||
"ui:widget": "VariableSelectorWidget",
|
||||
"ui:help": "🔍 Select a variable from the available dataset variables",
|
||||
"ui:description": "Choose from existing PLC variables defined in your datasets"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
|
||||
},
|
||||
"color": {
|
||||
"ui:widget": "color",
|
||||
|
@ -71,16 +71,12 @@
|
|||
"#16a085"
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui:order": [
|
||||
"variables"
|
||||
"plot_variables"
|
||||
]
|
||||
}
|
|
@ -41,8 +41,8 @@ class ConfigManager:
|
|||
|
||||
# Default configurations
|
||||
self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2}
|
||||
self.udp_config = {"host": "127.0.0.1", "port": 9870, "sampling_interval": 1.0}
|
||||
self.sampling_interval = 0.1 # Legacy fallback
|
||||
self.udp_config = {"host": "127.0.0.1", "port": 9870}
|
||||
self.sampling_interval = 0.1
|
||||
|
||||
# CSV recording configuration
|
||||
self.csv_config = {
|
||||
|
@ -50,6 +50,9 @@ class ConfigManager:
|
|||
"rotation_enabled": True,
|
||||
"max_size_mb": 1000, # Maximum total size in MB (1GB default)
|
||||
"max_days": 30, # Maximum days to keep files
|
||||
"max_hours": None, # Maximum hours to keep files (None = use max_days)
|
||||
"cleanup_interval_hours": 24, # How often to run cleanup (hours)
|
||||
"last_cleanup": None, # Last cleanup timestamp
|
||||
}
|
||||
|
||||
# Datasets management
|
||||
|
@ -85,27 +88,10 @@ class ConfigManager:
|
|||
with open(self.config_file, "r") as f:
|
||||
config = json.load(f)
|
||||
self.plc_config = config.get("plc_config", self.plc_config)
|
||||
|
||||
# Handle UDP config with sampling_interval migration
|
||||
udp_config = config.get("udp_config", self.udp_config)
|
||||
legacy_sampling = config.get("sampling_interval")
|
||||
|
||||
# If sampling_interval is not in udp_config but exists at root level, migrate it
|
||||
if (
|
||||
"sampling_interval" not in udp_config
|
||||
and legacy_sampling is not None
|
||||
):
|
||||
udp_config["sampling_interval"] = legacy_sampling
|
||||
if self.logger:
|
||||
self.logger.info("Migrated sampling_interval to udp_config")
|
||||
|
||||
self.udp_config = udp_config
|
||||
|
||||
# Keep legacy sampling_interval for backward compatibility
|
||||
self.sampling_interval = self.udp_config.get(
|
||||
self.udp_config = config.get("udp_config", self.udp_config)
|
||||
self.sampling_interval = config.get(
|
||||
"sampling_interval", self.sampling_interval
|
||||
)
|
||||
|
||||
self.csv_config = {
|
||||
**self.csv_config,
|
||||
**config.get("csv_config", {}),
|
||||
|
@ -123,13 +109,10 @@ class ConfigManager:
|
|||
def save_configuration(self):
|
||||
"""Save PLC and UDP configuration to JSON file"""
|
||||
try:
|
||||
# Ensure sampling_interval is always in udp_config
|
||||
if "sampling_interval" not in self.udp_config:
|
||||
self.udp_config["sampling_interval"] = self.sampling_interval
|
||||
|
||||
config = {
|
||||
"plc_config": self.plc_config,
|
||||
"udp_config": self.udp_config,
|
||||
"sampling_interval": self.sampling_interval,
|
||||
"csv_config": self.csv_config,
|
||||
}
|
||||
with open(self.config_file, "w") as f:
|
||||
|
@ -173,81 +156,40 @@ class ConfigManager:
|
|||
self.logger.error(f"Error loading datasets: {e}")
|
||||
|
||||
def _load_datasets_separated(self):
|
||||
"""Load datasets from separated definition and variable files (new array format)"""
|
||||
"""Load datasets from separated definition and variable files"""
|
||||
try:
|
||||
# Load definitions (new array format: {"datasets": [array]})
|
||||
# Load definitions
|
||||
with open(self.dataset_definitions_file, "r") as f:
|
||||
definitions_data = json.load(f)
|
||||
|
||||
# Load variables (new array format: {"variables": [array]})
|
||||
# Load variables
|
||||
with open(self.dataset_variables_file, "r") as f:
|
||||
variables_data = json.load(f)
|
||||
|
||||
# Convert new array format to internal dictionary format for compatibility
|
||||
# Merge data back to legacy format for compatibility
|
||||
self.datasets = {}
|
||||
dataset_defs = definitions_data.get("datasets", {})
|
||||
dataset_vars = variables_data.get("dataset_variables", {})
|
||||
|
||||
# Process dataset definitions array
|
||||
dataset_defs_array = definitions_data.get("datasets", [])
|
||||
for definition in dataset_defs_array:
|
||||
dataset_id = definition.get("id")
|
||||
if not dataset_id:
|
||||
if self.logger:
|
||||
self.logger.warning("Skipping dataset definition without id")
|
||||
continue
|
||||
|
||||
# Store definition without the id field (since id is the key)
|
||||
dataset_def = {k: v for k, v in definition.items() if k != "id"}
|
||||
self.datasets[dataset_id] = dataset_def
|
||||
|
||||
# Process dataset variables array and merge with definitions
|
||||
dataset_vars_array = variables_data.get("variables", [])
|
||||
for variables_info in dataset_vars_array:
|
||||
dataset_id = variables_info.get("dataset_id")
|
||||
if not dataset_id:
|
||||
if self.logger:
|
||||
self.logger.warning(
|
||||
"Skipping dataset variables without dataset_id"
|
||||
)
|
||||
continue
|
||||
|
||||
if dataset_id not in self.datasets:
|
||||
if self.logger:
|
||||
self.logger.warning(
|
||||
f"Found variables for unknown dataset: {dataset_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Convert variables array to dictionary format for internal use
|
||||
variables_list = variables_info.get("variables", [])
|
||||
variables_dict = {}
|
||||
streaming_variables = []
|
||||
|
||||
for var in variables_list:
|
||||
var_name = var.get("name")
|
||||
if not var_name:
|
||||
continue
|
||||
|
||||
# Build variable config (remove name since it's the key)
|
||||
var_config = {k: v for k, v in var.items() if k != "name"}
|
||||
variables_dict[var_name] = var_config
|
||||
|
||||
# Add to streaming list if enabled
|
||||
if var_config.get("streaming", False):
|
||||
streaming_variables.append(var_name)
|
||||
|
||||
# Add variables to dataset
|
||||
self.datasets[dataset_id]["variables"] = variables_dict
|
||||
self.datasets[dataset_id]["streaming_variables"] = streaming_variables
|
||||
for dataset_id, definition in dataset_defs.items():
|
||||
variables_info = dataset_vars.get(dataset_id, {})
|
||||
self.datasets[dataset_id] = {
|
||||
**definition,
|
||||
"variables": variables_info.get("variables", {}),
|
||||
"streaming_variables": variables_info.get(
|
||||
"streaming_variables", []
|
||||
),
|
||||
}
|
||||
|
||||
# Calculate active_datasets automatically from enabled field
|
||||
self.active_datasets = set()
|
||||
for dataset_id, definition in self.datasets.items():
|
||||
for dataset_id, definition in dataset_defs.items():
|
||||
if definition.get("enabled", False):
|
||||
self.active_datasets.add(dataset_id)
|
||||
|
||||
# current_dataset_id is optional for UI, use first available if not set
|
||||
self.current_dataset_id = None
|
||||
if self.datasets:
|
||||
self.current_dataset_id = definitions_data.get("current_dataset_id")
|
||||
if not self.current_dataset_id and self.datasets:
|
||||
self.current_dataset_id = next(iter(self.datasets.keys()))
|
||||
|
||||
if self.logger:
|
||||
|
@ -271,7 +213,8 @@ class ConfigManager:
|
|||
self.active_datasets = set(legacy_data.get("active_datasets", []))
|
||||
self.current_dataset_id = legacy_data.get("current_dataset_id")
|
||||
|
||||
# Note: Migration complete - data now managed by frontend via RJSF
|
||||
# Save to new separated format
|
||||
self.save_datasets()
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
|
@ -284,9 +227,53 @@ class ConfigManager:
|
|||
self.logger.error(f"Error migrating legacy datasets: {e}")
|
||||
raise
|
||||
|
||||
# DEPRECATED: save_datasets() method removed
|
||||
# Data is now saved directly from frontend via RJSF and API endpoints
|
||||
# Use load_datasets_separated() to reload configuration when needed
|
||||
def save_datasets(self):
|
||||
"""Save datasets configuration to separated JSON files"""
|
||||
try:
|
||||
# timestamp removed as we don't save static fields anymore
|
||||
|
||||
# Prepare definitions data - only datasets, no static fields
|
||||
definitions_data = {
|
||||
"datasets": {},
|
||||
}
|
||||
|
||||
# Prepare variables data - only variables, no static fields
|
||||
variables_data = {
|
||||
"dataset_variables": {},
|
||||
}
|
||||
|
||||
# Split datasets into definitions and variables
|
||||
for dataset_id, dataset_info in self.datasets.items():
|
||||
# Extract definition (metadata only)
|
||||
definition = {
|
||||
key: value
|
||||
for key, value in dataset_info.items()
|
||||
if key not in ["variables", "streaming_variables"]
|
||||
}
|
||||
definitions_data["datasets"][dataset_id] = definition
|
||||
|
||||
# Extract variables
|
||||
variables_data["dataset_variables"][dataset_id] = {
|
||||
"variables": dataset_info.get("variables", {}),
|
||||
"streaming_variables": dataset_info.get("streaming_variables", []),
|
||||
}
|
||||
|
||||
# Save both files
|
||||
with open(self.dataset_definitions_file, "w") as f:
|
||||
json.dump(definitions_data, f, indent=4)
|
||||
|
||||
with open(self.dataset_variables_file, "w") as f:
|
||||
json.dump(variables_data, f, indent=4)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Datasets configuration saved to separated files: "
|
||||
f"{self.dataset_definitions_file} and {self.dataset_variables_file}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"Error saving datasets: {e}")
|
||||
|
||||
def sync_streaming_variables(self):
|
||||
"""Synchronize streaming variables configuration"""
|
||||
|
@ -319,12 +306,9 @@ class ConfigManager:
|
|||
)
|
||||
|
||||
if sync_needed:
|
||||
# Note: Configuration is now managed by frontend via RJSF
|
||||
# No automatic save needed - frontend will save when user makes changes
|
||||
self.save_datasets()
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
"Streaming variables configuration synchronized in memory"
|
||||
)
|
||||
self.logger.info("Streaming variables configuration synchronized")
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
|
@ -374,33 +358,6 @@ class ConfigManager:
|
|||
if self.logger:
|
||||
self.logger.error(f"Error saving system state: {e}")
|
||||
|
||||
# Helper to update active datasets set from current self.datasets
|
||||
def _update_active_datasets(self):
|
||||
try:
|
||||
active = set()
|
||||
for dataset_id, cfg in (self.datasets or {}).items():
|
||||
if isinstance(cfg, dict) and cfg.get("enabled", False):
|
||||
active.add(dataset_id)
|
||||
self.active_datasets = active
|
||||
# Ensure current_dataset_id is valid
|
||||
if self.current_dataset_id not in self.datasets:
|
||||
self.current_dataset_id = next(iter(self.datasets), None)
|
||||
if self.logger:
|
||||
self.logger.debug(
|
||||
f"Active datasets updated: {sorted(list(self.active_datasets))}"
|
||||
)
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.warning(f"Failed to update active datasets: {e}")
|
||||
|
||||
# CSV helpers used by API endpoints
|
||||
def get_csv_directory_path(self) -> str:
|
||||
try:
|
||||
base = self.csv_config.get("records_directory", "records")
|
||||
except Exception:
|
||||
base = "records"
|
||||
return resource_path(base)
|
||||
|
||||
# PLC Configuration Methods
|
||||
def update_plc_config(self, ip: str, rack: int, slot: int):
|
||||
"""Update PLC configuration"""
|
||||
|
@ -409,37 +366,17 @@ class ConfigManager:
|
|||
self.save_configuration()
|
||||
return {"old_config": old_config, "new_config": self.plc_config}
|
||||
|
||||
def update_udp_config(self, host: str, port: int, sampling_interval: float = None):
|
||||
"""Update UDP configuration including sampling interval"""
|
||||
def update_udp_config(self, host: str, port: int):
|
||||
"""Update UDP configuration"""
|
||||
old_config = self.udp_config.copy()
|
||||
|
||||
# Preserve existing sampling_interval if not provided
|
||||
current_sampling = self.udp_config.get(
|
||||
"sampling_interval", self.sampling_interval
|
||||
)
|
||||
|
||||
self.udp_config = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"sampling_interval": (
|
||||
sampling_interval if sampling_interval is not None else current_sampling
|
||||
),
|
||||
}
|
||||
|
||||
# Update legacy field for backward compatibility
|
||||
self.sampling_interval = self.udp_config["sampling_interval"]
|
||||
|
||||
self.udp_config = {"host": host, "port": port}
|
||||
self.save_configuration()
|
||||
return {"old_config": old_config, "new_config": self.udp_config}
|
||||
|
||||
def update_sampling_interval(self, interval: float):
|
||||
"""Update sampling interval (updates udp_config.sampling_interval)"""
|
||||
old_interval = self.udp_config.get("sampling_interval", self.sampling_interval)
|
||||
|
||||
# Update both the udp_config and legacy field
|
||||
self.udp_config["sampling_interval"] = interval
|
||||
"""Update sampling interval"""
|
||||
old_interval = self.sampling_interval
|
||||
self.sampling_interval = interval
|
||||
|
||||
self.save_configuration()
|
||||
return {"old_interval": old_interval, "new_interval": interval}
|
||||
|
||||
|
@ -454,6 +391,8 @@ class ConfigManager:
|
|||
"rotation_enabled",
|
||||
"max_size_mb",
|
||||
"max_days",
|
||||
"max_hours",
|
||||
"cleanup_interval_hours",
|
||||
}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
|
@ -463,11 +402,16 @@ class ConfigManager:
|
|||
raise ValueError("records_directory must be a string")
|
||||
elif key == "rotation_enabled" and not isinstance(value, bool):
|
||||
raise ValueError("rotation_enabled must be a boolean")
|
||||
elif key in ["max_size_mb", "max_days"]:
|
||||
elif key in ["max_size_mb", "max_days", "cleanup_interval_hours"]:
|
||||
if value is not None and (
|
||||
not isinstance(value, (int, float)) or value <= 0
|
||||
):
|
||||
raise ValueError(f"{key} must be a positive number or None")
|
||||
elif key == "max_hours":
|
||||
if value is not None and (
|
||||
not isinstance(value, (int, float)) or value <= 0
|
||||
):
|
||||
raise ValueError("max_hours must be a positive number or None")
|
||||
|
||||
self.csv_config[key] = value
|
||||
|
||||
|
@ -485,12 +429,27 @@ class ConfigManager:
|
|||
return os.path.join(self.get_csv_directory_path(), day_folder)
|
||||
|
||||
def should_perform_cleanup(self) -> bool:
|
||||
"""Check if cleanup should be performed"""
|
||||
return self.csv_config["rotation_enabled"]
|
||||
"""Check if cleanup should be performed based on interval"""
|
||||
if not self.csv_config["rotation_enabled"]:
|
||||
return False
|
||||
|
||||
last_cleanup = self.csv_config.get("last_cleanup")
|
||||
if not last_cleanup:
|
||||
return True
|
||||
|
||||
try:
|
||||
last_cleanup_dt = datetime.fromisoformat(last_cleanup)
|
||||
hours_since_cleanup = (
|
||||
datetime.now() - last_cleanup_dt
|
||||
).total_seconds() / 3600
|
||||
return hours_since_cleanup >= self.csv_config["cleanup_interval_hours"]
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
def mark_cleanup_performed(self):
|
||||
"""Mark that cleanup was performed (simplified - no persistent tracking)"""
|
||||
pass
|
||||
"""Mark that cleanup was performed"""
|
||||
self.csv_config["last_cleanup"] = datetime.now().isoformat()
|
||||
self.save_configuration()
|
||||
|
||||
# Dataset Management Methods
|
||||
def create_dataset(
|
||||
|
@ -516,7 +475,7 @@ class ConfigManager:
|
|||
if not self.current_dataset_id:
|
||||
self.current_dataset_id = dataset_id
|
||||
|
||||
# Note: Dataset changes now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
return new_dataset
|
||||
|
||||
def delete_dataset(self, dataset_id: str):
|
||||
|
@ -536,7 +495,7 @@ class ConfigManager:
|
|||
next(iter(self.datasets.keys())) if self.datasets else None
|
||||
)
|
||||
|
||||
# Note: Dataset deletion now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
return dataset_info
|
||||
|
||||
def get_current_dataset(self):
|
||||
|
@ -552,14 +511,15 @@ class ConfigManager:
|
|||
return {}
|
||||
|
||||
def get_dataset_sampling_interval(self, dataset_id: str):
|
||||
"""Get sampling interval for a dataset (falls back to UDP config sampling interval if not set)"""
|
||||
"""Get sampling interval for a dataset (falls back to global if not set)"""
|
||||
if dataset_id in self.datasets:
|
||||
dataset_interval = self.datasets[dataset_id].get("sampling_interval")
|
||||
if dataset_interval is not None:
|
||||
return dataset_interval
|
||||
|
||||
# Fallback to UDP config sampling_interval, then legacy sampling_interval
|
||||
return self.udp_config.get("sampling_interval", self.sampling_interval)
|
||||
return (
|
||||
dataset_interval
|
||||
if dataset_interval is not None
|
||||
else self.sampling_interval
|
||||
)
|
||||
return self.sampling_interval
|
||||
|
||||
def add_variable_to_dataset(
|
||||
self,
|
||||
|
@ -617,7 +577,7 @@ class ConfigManager:
|
|||
if name not in self.datasets[dataset_id]["streaming_variables"]:
|
||||
self.datasets[dataset_id]["streaming_variables"].append(name)
|
||||
|
||||
# Note: Variable addition now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
return var_config
|
||||
|
||||
def remove_variable_from_dataset(self, dataset_id: str, name: str):
|
||||
|
@ -635,7 +595,7 @@ class ConfigManager:
|
|||
if name in self.datasets[dataset_id]["streaming_variables"]:
|
||||
self.datasets[dataset_id]["streaming_variables"].remove(name)
|
||||
|
||||
# Note: Variable removal now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
return var_config
|
||||
|
||||
def toggle_variable_streaming(self, dataset_id: str, name: str, enabled: bool):
|
||||
|
@ -657,7 +617,7 @@ class ConfigManager:
|
|||
if name in self.datasets[dataset_id]["streaming_variables"]:
|
||||
self.datasets[dataset_id]["streaming_variables"].remove(name)
|
||||
|
||||
# Note: Streaming toggle now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
|
||||
def activate_dataset(self, dataset_id: str):
|
||||
"""Mark a dataset as active"""
|
||||
|
@ -666,7 +626,7 @@ class ConfigManager:
|
|||
|
||||
self.datasets[dataset_id]["enabled"] = True
|
||||
self._update_active_datasets()
|
||||
# Note: Dataset activation now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
|
||||
def deactivate_dataset(self, dataset_id: str):
|
||||
"""Mark a dataset as inactive"""
|
||||
|
@ -675,7 +635,7 @@ class ConfigManager:
|
|||
|
||||
self.datasets[dataset_id]["enabled"] = False
|
||||
self._update_active_datasets()
|
||||
# Note: Dataset deactivation now saved via frontend RJSF
|
||||
self.save_datasets()
|
||||
|
||||
def _update_active_datasets(self):
|
||||
"""Update active_datasets based on enabled field of each dataset"""
|
||||
|
@ -687,15 +647,14 @@ class ConfigManager:
|
|||
def get_status(self):
|
||||
"""Get configuration status"""
|
||||
total_variables = sum(
|
||||
len(self.get_dataset_variables(dataset_id))
|
||||
for dataset_id in self.datasets.keys()
|
||||
len(dataset["variables"]) for dataset in self.datasets.values()
|
||||
)
|
||||
|
||||
# Count only variables that are in streaming_variables list AND have streaming=true
|
||||
total_streaming_vars = 0
|
||||
for dataset_id, dataset in self.datasets.items():
|
||||
for dataset in self.datasets.values():
|
||||
streaming_vars = dataset.get("streaming_variables", [])
|
||||
variables_config = self.get_dataset_variables(dataset_id)
|
||||
variables_config = dataset.get("variables", {})
|
||||
active_streaming_vars = [
|
||||
var
|
||||
for var in streaming_vars
|
||||
|
@ -717,12 +676,12 @@ class ConfigManager:
|
|||
dataset_id: {
|
||||
"name": info["name"],
|
||||
"prefix": info["prefix"],
|
||||
"variables_count": len(self.get_dataset_variables(dataset_id)),
|
||||
"variables_count": len(info["variables"]),
|
||||
"streaming_count": len(
|
||||
[
|
||||
var
|
||||
for var in info.get("streaming_variables", [])
|
||||
if self.get_dataset_variables(dataset_id)
|
||||
if info.get("variables", {})
|
||||
.get(var, {})
|
||||
.get("streaming", False)
|
||||
]
|
||||
|
|
|
@ -44,7 +44,6 @@ class PLCClient:
|
|||
def connect(self, ip: str, rack: int, slot: int) -> bool:
|
||||
"""Connect to S7-315 PLC"""
|
||||
try:
|
||||
self.last_connection_attempt = time.time()
|
||||
if self.plc:
|
||||
self.plc.disconnect()
|
||||
|
||||
|
@ -134,7 +133,6 @@ class PLCClient:
|
|||
return False
|
||||
|
||||
try:
|
||||
self.last_connection_attempt = time.time()
|
||||
if self.logger:
|
||||
ip = self.connection_config["ip"]
|
||||
self.logger.info(f"Attempting reconnection to PLC {ip}...")
|
||||
|
@ -175,31 +173,6 @@ class PLCClient:
|
|||
|
||||
return False
|
||||
|
||||
# ---- Status helpers expected by API ----
|
||||
def get_reconnection_status(self) -> Dict[str, Any]:
|
||||
"""Return reconnection settings/state for diagnostics."""
|
||||
try:
|
||||
backoff = self._calculate_backoff_delay()
|
||||
except Exception:
|
||||
backoff = 0
|
||||
return {
|
||||
"enabled": bool(self.reconnection_enabled),
|
||||
"active": bool(self.is_reconnecting),
|
||||
"consecutive_failures": int(self.consecutive_failures),
|
||||
"last_attempt_ts": float(self.last_connection_attempt or 0),
|
||||
"next_backoff_seconds": float(backoff),
|
||||
}
|
||||
|
||||
def get_connection_info(self) -> Dict[str, Any]:
|
||||
"""Return basic connection info for status panels."""
|
||||
return {
|
||||
"connected": self.is_connected(),
|
||||
"ip": self.connection_config.get("ip"),
|
||||
"rack": self.connection_config.get("rack"),
|
||||
"slot": self.connection_config.get("slot"),
|
||||
"last_error": self.last_error,
|
||||
}
|
||||
|
||||
def _start_automatic_reconnection(self):
|
||||
"""Start automatic reconnection in background thread"""
|
||||
if not self.reconnection_enabled:
|
||||
|
|
|
@ -366,8 +366,8 @@ class PLCDataStreamer:
|
|||
"datasets_count": len(self.config_manager.datasets),
|
||||
"active_datasets_count": len(self.config_manager.active_datasets),
|
||||
"total_variables": sum(
|
||||
len(self.config_manager.get_dataset_variables(dataset_id))
|
||||
for dataset_id in self.config_manager.datasets.keys()
|
||||
len(dataset["variables"])
|
||||
for dataset in self.config_manager.datasets.values()
|
||||
),
|
||||
"streaming_variables_count": sum(
|
||||
len(dataset.get("streaming_variables", []))
|
||||
|
@ -586,7 +586,7 @@ class PLCDataStreamer:
|
|||
def current_dataset_id(self, value):
|
||||
"""Set current dataset ID (backward compatibility)"""
|
||||
self.config_manager.current_dataset_id = value
|
||||
# Note: Dataset changes now saved via frontend RJSF
|
||||
self.config_manager.save_datasets()
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
|
@ -598,6 +598,6 @@ class PLCDataStreamer:
|
|||
"""Get streaming status (backward compatibility)"""
|
||||
return self.data_streamer.is_streaming()
|
||||
|
||||
# DEPRECATED: save_datasets() method removed
|
||||
# Data is now saved directly from frontend via RJSF and API endpoints
|
||||
# Use load_datasets() to reload configuration when needed
|
||||
def save_datasets(self):
|
||||
"""Save datasets (backward compatibility)"""
|
||||
self.config_manager.save_datasets()
|
||||
|
|
|
@ -284,10 +284,8 @@ class PlotManager:
|
|||
def create_session(self, config: Dict[str, Any]) -> str:
|
||||
"""Crear una nueva sesión de plotting"""
|
||||
with self.lock:
|
||||
# Use the ID from config if available, otherwise generate one
|
||||
session_id = config.get("id") or f"plot_{self.session_counter}"
|
||||
if not config.get("id"):
|
||||
self.session_counter += 1
|
||||
session_id = f"plot_{self.session_counter}"
|
||||
self.session_counter += 1
|
||||
|
||||
session = PlotSession(session_id, config)
|
||||
# 🔑 CAMBIO: Crear sesiones en modo activo por defecto para mejor UX
|
||||
|
@ -296,7 +294,8 @@ class PlotManager:
|
|||
|
||||
self.sessions[session_id] = session
|
||||
|
||||
# Note: Plot session configuration now saved via frontend RJSF
|
||||
# Guardar automáticamente la configuración
|
||||
self.save_plots()
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
|
@ -339,7 +338,8 @@ class PlotManager:
|
|||
|
||||
del self.sessions[session_id]
|
||||
|
||||
# Note: Plot session removal now saved via frontend RJSF
|
||||
# Guardar automáticamente después de eliminar
|
||||
self.save_plots()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
@ -446,57 +446,26 @@ class PlotManager:
|
|||
self.session_counter = 0
|
||||
|
||||
def _load_plots_separated(self):
|
||||
"""Load plots from separated definition and variable files (new array format)"""
|
||||
"""Load plots from separated definition and variable files"""
|
||||
try:
|
||||
# Load definitions (new array format: {"plots": [array]})
|
||||
# Load definitions
|
||||
with open(self.plot_definitions_file, "r", encoding="utf-8") as f:
|
||||
definitions_data = json.load(f)
|
||||
|
||||
# Load variables (new array format: {"variables": [array]})
|
||||
# Load variables
|
||||
with open(self.plot_variables_file, "r", encoding="utf-8") as f:
|
||||
variables_data = json.load(f)
|
||||
|
||||
# Convert new array format to internal dictionary format for compatibility
|
||||
plots_array = definitions_data.get("plots", [])
|
||||
plot_vars_array = variables_data.get("variables", [])
|
||||
# Merge data back for session creation
|
||||
plots_data = definitions_data.get("plots", {})
|
||||
plot_vars = variables_data.get("plot_variables", {})
|
||||
|
||||
# Build plot variables lookup by plot_id
|
||||
plot_variables_lookup = {}
|
||||
for plot_vars_entry in plot_vars_array:
|
||||
plot_id = plot_vars_entry.get("plot_id")
|
||||
if plot_id:
|
||||
# Convert variables array to format expected by PlotSession
|
||||
variables_list = plot_vars_entry.get("variables", [])
|
||||
# Convert to object format with variable names and properties
|
||||
variables_config = {}
|
||||
for var in variables_list:
|
||||
var_name = var.get("variable_name")
|
||||
if var_name:
|
||||
variables_config[var_name] = {
|
||||
"variable_name": var_name,
|
||||
"color": var.get("color", "#3498db"),
|
||||
"enabled": var.get("enabled", True),
|
||||
}
|
||||
plot_variables_lookup[plot_id] = variables_config
|
||||
|
||||
# Process plot definitions
|
||||
for plot_def in plots_array:
|
||||
session_id = plot_def.get("id") or plot_def.get("session_id")
|
||||
if not session_id:
|
||||
if self.logger:
|
||||
self.logger.warning("Skipping plot definition without id")
|
||||
continue
|
||||
|
||||
# Build full config with variables
|
||||
for session_id, plot_config in plots_data.items():
|
||||
# Add variables to config
|
||||
variables_info = plot_vars.get(session_id, {})
|
||||
full_config = {
|
||||
"name": plot_def.get("name", f"Plot {session_id}"),
|
||||
"time_window": plot_def.get("time_window", 60),
|
||||
"y_min": plot_def.get("y_min"),
|
||||
"y_max": plot_def.get("y_max"),
|
||||
"trigger_variable": plot_def.get("trigger_variable"),
|
||||
"trigger_enabled": plot_def.get("trigger_enabled", False),
|
||||
"trigger_on_true": plot_def.get("trigger_on_true", True),
|
||||
"variables": plot_variables_lookup.get(session_id, {}),
|
||||
**plot_config,
|
||||
"variables": variables_info.get("variables", []),
|
||||
}
|
||||
|
||||
# Create session with full configuration
|
||||
|
@ -509,16 +478,16 @@ class PlotManager:
|
|||
|
||||
# Update counter to avoid duplicate IDs
|
||||
try:
|
||||
if session_id.startswith("plot_"):
|
||||
session_num = int(session_id.split("_")[1])
|
||||
if session_num >= self.session_counter:
|
||||
self.session_counter = session_num + 1
|
||||
session_num = int(session_id.split("_")[1])
|
||||
if session_num >= self.session_counter:
|
||||
self.session_counter = session_num + 1
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# Ensure session counter is at least the number of sessions
|
||||
if len(self.sessions) >= self.session_counter:
|
||||
self.session_counter = len(self.sessions)
|
||||
# Load counter from definitions
|
||||
saved_counter = definitions_data.get("session_counter", len(self.sessions))
|
||||
if saved_counter > self.session_counter:
|
||||
self.session_counter = saved_counter
|
||||
|
||||
if self.logger and self.sessions:
|
||||
self.logger.info(
|
||||
|
@ -558,7 +527,8 @@ class PlotManager:
|
|||
if saved_counter > self.session_counter:
|
||||
self.session_counter = saved_counter
|
||||
|
||||
# Note: Migration complete - data now managed by frontend via RJSF
|
||||
# Save to new separated format
|
||||
self.save_plots()
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
|
@ -570,9 +540,53 @@ class PlotManager:
|
|||
self.logger.error(f"Error migrating legacy plots: {e}")
|
||||
raise
|
||||
|
||||
# DEPRECATED: save_plots() method removed
|
||||
# Data is now saved directly from frontend via RJSF and API endpoints
|
||||
# Use _load_plots_separated() to reload configuration when needed
|
||||
def save_plots(self):
|
||||
"""Guardar plots a archivos separados de persistencia"""
|
||||
try:
|
||||
# Prepare definitions data - only plots, no static fields
|
||||
definitions_data = {
|
||||
"plots": {},
|
||||
}
|
||||
|
||||
# Prepare variables data - only variables, no static fields
|
||||
variables_data = {
|
||||
"plot_variables": {},
|
||||
}
|
||||
|
||||
# Split sessions into definitions and variables
|
||||
for session_id, session in self.sessions.items():
|
||||
# Extract definition (metadata without variables)
|
||||
definitions_data["plots"][session_id] = {
|
||||
"name": session.name,
|
||||
"time_window": session.time_window,
|
||||
"y_min": session.y_min,
|
||||
"y_max": session.y_max,
|
||||
"trigger_variable": session.trigger_variable,
|
||||
"trigger_enabled": session.trigger_enabled,
|
||||
"trigger_on_true": session.trigger_on_true,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
# Extract variables
|
||||
variables_data["plot_variables"][session_id] = {
|
||||
"variables": session.variables,
|
||||
}
|
||||
|
||||
# Save both files
|
||||
with open(self.plot_definitions_file, "w", encoding="utf-8") as f:
|
||||
json.dump(definitions_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
with open(self.plot_variables_file, "w", encoding="utf-8") as f:
|
||||
json.dump(variables_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
if self.logger:
|
||||
self.logger.debug(
|
||||
f"Saved {len(self.sessions)} plot sessions to separated files"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"Error saving plot sessions: {e}")
|
||||
|
||||
def update_session_config(self, session_id: str, config: Dict[str, Any]) -> bool:
|
||||
"""Actualizar configuración de una sesión existente"""
|
||||
|
@ -618,7 +632,8 @@ class PlotManager:
|
|||
old_data = list(session.data[var])
|
||||
session.data[var] = deque(old_data, maxlen=max_points)
|
||||
|
||||
# Note: Plot session configuration changes now saved via frontend RJSF
|
||||
# Guardar cambios
|
||||
self.save_plots()
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"Updated plot session '{session.name}' configuration")
|
||||
|
|
|
@ -156,18 +156,7 @@ class ConfigSchemaManager:
|
|||
path = self.config_files[config_id]
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Present definitions as array for RJSF even if stored as dict
|
||||
if config_id == "dataset-definitions":
|
||||
datasets_val = data.get("datasets", {})
|
||||
if isinstance(datasets_val, dict):
|
||||
# Transform dict -> array with embedded id
|
||||
arr = []
|
||||
for ds_id, ds_def in datasets_val.items():
|
||||
item = {"id": ds_id, **ds_def}
|
||||
arr.append(item)
|
||||
data = {**data, "datasets": arr}
|
||||
return data
|
||||
return json.load(f)
|
||||
# Return default structure for each type
|
||||
if config_id == "dataset-definitions":
|
||||
return {
|
||||
|
@ -253,69 +242,20 @@ class ConfigSchemaManager:
|
|||
|
||||
if config_id == "dataset-definitions":
|
||||
# Manejar solo datasets individuales, calcular active_datasets automáticamente
|
||||
datasets_in = data.get("datasets", {})
|
||||
# Accept both array (RJSF-friendly) and dict (internal)
|
||||
if isinstance(datasets_in, list):
|
||||
# Convert array -> dict keyed by id (or derived id)
|
||||
def make_id(item: Dict[str, Any]) -> str:
|
||||
import re
|
||||
|
||||
if item.get("id"):
|
||||
return str(item["id"]).strip()
|
||||
# Derive from prefix or name
|
||||
base = str(
|
||||
item.get("prefix") or item.get("name") or "dataset"
|
||||
).strip()
|
||||
# slugify to allowed pattern [a-zA-Z0-9_-]
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]", "_", base)[:60]
|
||||
return slug or "dataset"
|
||||
|
||||
tmp: Dict[str, Any] = {}
|
||||
for item in datasets_in:
|
||||
ds_id = make_id(item)
|
||||
# ensure uniqueness by appending numeric suffix when needed
|
||||
orig = ds_id
|
||||
counter = 1
|
||||
while ds_id in tmp:
|
||||
ds_id = f"{orig}_{counter}"
|
||||
counter += 1
|
||||
# store without duplicating id inside the value
|
||||
value = {k: v for k, v in item.items() if k != "id"}
|
||||
tmp[ds_id] = value
|
||||
datasets = tmp
|
||||
elif isinstance(datasets_in, dict):
|
||||
datasets = datasets_in
|
||||
else:
|
||||
raise ValueError("Invalid datasets format: expected array or object")
|
||||
datasets = data.get("datasets", {})
|
||||
|
||||
# Actualizar datasets en ConfigManager
|
||||
self.config_manager.datasets.update(datasets)
|
||||
|
||||
# Calcular active_datasets automáticamente desde enabled field
|
||||
# Ensure method exists (backward compat)
|
||||
try:
|
||||
updater = getattr(self.config_manager, "_update_active_datasets")
|
||||
except AttributeError:
|
||||
updater = None
|
||||
if callable(updater):
|
||||
updater()
|
||||
else:
|
||||
# Fallback: compute inline
|
||||
try:
|
||||
active = set()
|
||||
for ds_id, cfg in self.config_manager.datasets.items():
|
||||
if cfg.get("enabled", False):
|
||||
active.add(ds_id)
|
||||
self.config_manager.active_datasets = active
|
||||
except Exception:
|
||||
pass
|
||||
self.config_manager._update_active_datasets()
|
||||
|
||||
# current_dataset_id es opcional para UI
|
||||
current = data.get("current_dataset_id")
|
||||
if current and current in datasets:
|
||||
self.config_manager.current_dataset_id = current
|
||||
|
||||
# Note: Data is now persisted directly via frontend RJSF
|
||||
self.config_manager.save_datasets()
|
||||
return {"success": True}
|
||||
|
||||
if config_id == "plot-definitions":
|
||||
|
@ -342,7 +282,8 @@ class ConfigSchemaManager:
|
|||
else:
|
||||
self.plot_manager.session_counter = 0
|
||||
|
||||
# Note: Data is now persisted directly via frontend RJSF
|
||||
# Save to separated files
|
||||
self.plot_manager.save_plots()
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
|
@ -375,11 +316,9 @@ class ConfigSchemaManager:
|
|||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.read_config("plc"), f, indent=2)
|
||||
elif config_id == "datasets":
|
||||
# Note: Datasets now managed via separated files by frontend RJSF
|
||||
pass
|
||||
self.config_manager.save_datasets() # Now saves to separated files
|
||||
elif config_id == "plots":
|
||||
# Note: Plots now managed via separated files by frontend RJSF
|
||||
pass
|
||||
self.plot_manager.save_plots() # Now saves to separated files
|
||||
elif config_id in [
|
||||
"dataset-definitions",
|
||||
"dataset-variables",
|
||||
|
|
|
@ -683,9 +683,7 @@ class DataStreamer:
|
|||
f"Dataset activated: {dataset_info['name']}",
|
||||
{
|
||||
"dataset_id": dataset_id,
|
||||
"variables_count": len(
|
||||
self.config_manager.get_dataset_variables(dataset_id)
|
||||
),
|
||||
"variables_count": len(dataset_info["variables"]),
|
||||
"streaming_count": len(dataset_info["streaming_variables"]),
|
||||
"prefix": dataset_info["prefix"],
|
||||
},
|
||||
|
|
|
@ -6,7 +6,7 @@ import EventsPage from './pages/Events.jsx'
|
|||
import ConfigPage from './pages/Config.jsx'
|
||||
import PlotsPage from './pages/Plots.jsx'
|
||||
import PLCConfigModal from './components/PLCConfigModal.jsx'
|
||||
import DashboardPage from './pages/DashboardNew.jsx'
|
||||
import DashboardPage from './pages/Dashboard.jsx'
|
||||
import DatasetManager from './components/DatasetManager.jsx'
|
||||
|
||||
function Home() {
|
||||
|
|
|
@ -13,16 +13,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
insertNaNOnNextIngest: false,
|
||||
isPaused: false,
|
||||
isRealTimeMode: true,
|
||||
refreshRate: 1000,
|
||||
userOverrideUntil: 0,
|
||||
userPaused: false,
|
||||
sessionId: null
|
||||
refreshRate: 1000
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [dataPointsCount, setDataPointsCount] = useState(0);
|
||||
const resolvedConfigRef = useRef(null);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
@ -56,17 +52,6 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (!variables) return [];
|
||||
|
||||
if (Array.isArray(variables)) {
|
||||
// Handle array of objects with variable_name property
|
||||
if (variables.length > 0 && typeof variables[0] === 'object' && variables[0].variable_name) {
|
||||
return variables
|
||||
.filter(varConfig => varConfig.enabled !== false && varConfig.variable_name)
|
||||
.map((varConfig, index) => ({
|
||||
name: varConfig.variable_name,
|
||||
color: varConfig.color || getColor(varConfig.variable_name, index),
|
||||
enabled: varConfig.enabled !== false
|
||||
}));
|
||||
}
|
||||
// Handle simple array of strings
|
||||
return variables.map((variable, index) => ({
|
||||
name: variable,
|
||||
color: getColor(variable, index),
|
||||
|
@ -96,10 +81,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}, [getColor]);
|
||||
|
||||
const createStreamingChart = useCallback(async () => {
|
||||
const cfg = resolvedConfigRef.current || session?.config;
|
||||
if (!canvasRef.current || !cfg) return;
|
||||
|
||||
console.log(`🔧 Creating chart for session ${session?.session_id}...`);
|
||||
if (!canvasRef.current || !session?.config) return;
|
||||
|
||||
try {
|
||||
// Ensure Chart.js and plugins are loaded
|
||||
|
@ -109,32 +91,15 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
const Chart = window.Chart;
|
||||
|
||||
// Ensure zoom plugin is registered only if available to avoid plugin errors
|
||||
let zoomAvailable = false;
|
||||
// Register zoom plugin if available
|
||||
try {
|
||||
if (Chart.registry?.plugins?.get?.('zoom')) {
|
||||
zoomAvailable = true;
|
||||
} else {
|
||||
const maybeZoom = (window.ChartZoom || window.chartjsPluginZoom || window.chartjs_plugin_zoom || window.chartjsPluginZoomDefault || (window['chartjs-plugin-zoom'] && window['chartjs-plugin-zoom'].default));
|
||||
if (maybeZoom) {
|
||||
Chart.register(maybeZoom);
|
||||
zoomAvailable = true;
|
||||
}
|
||||
if (window.ChartZoom && !Chart.registry.plugins.get('zoom')) {
|
||||
Chart.register(window.ChartZoom);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Zoom plugin registration failed:', e);
|
||||
}
|
||||
|
||||
// Ensure realtime scale is available (no fallback)
|
||||
try {
|
||||
const hasRealtimeScale = !!(Chart.registry?.scales?.get && Chart.registry.scales.get('realtime'));
|
||||
if (!hasRealtimeScale) {
|
||||
throw new Error('chartjs-plugin-streaming (realtime scale) not loaded');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error('Realtime scale not available. Ensure chartjs-plugin-streaming v2.x is loaded after Chart.js.');
|
||||
}
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
|
||||
// Destroy existing chart
|
||||
|
@ -143,7 +108,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
chartRef.current = null;
|
||||
}
|
||||
|
||||
const config = cfg;
|
||||
const config = session.config;
|
||||
const enabledVariables = getEnabledVariables(config.variables);
|
||||
|
||||
const datasets = enabledVariables.map((variableInfo, index) => {
|
||||
|
@ -168,9 +133,6 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
sessionDataRef.current.datasetIndex.set(variableInfo.name, index);
|
||||
});
|
||||
|
||||
const yMinInitial = (typeof config.y_min === 'number' && isFinite(config.y_min)) ? config.y_min : undefined;
|
||||
const yMaxInitial = (typeof config.y_max === 'number' && isFinite(config.y_max)) ? config.y_max : undefined;
|
||||
|
||||
const chartConfig = {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
|
@ -217,8 +179,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
display: true,
|
||||
text: 'Valor'
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial
|
||||
min: config.y_min,
|
||||
max: config.y_max
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
@ -230,13 +192,17 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
...(zoomAvailable ? {
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x'
|
||||
},
|
||||
zoom: {
|
||||
// Evita listeners wheel/touch no-passive del plugin; usa drag + pan con modificador
|
||||
pan: { enabled: true, mode: 'x', modifierKey: 'shift' },
|
||||
zoom: { drag: { enabled: true }, wheel: { enabled: false }, pinch: { enabled: false }, mode: 'x' }
|
||||
pinch: { enabled: true },
|
||||
wheel: { enabled: true },
|
||||
mode: 'x'
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
|
@ -256,14 +222,40 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}
|
||||
};
|
||||
|
||||
chartRef.current = new Chart(ctx, chartConfig);
|
||||
sessionDataRef.current.isRealTimeMode = true;
|
||||
sessionDataRef.current.noDataCycles = 0;
|
||||
// Sync ingest pause state with initial chart pause
|
||||
const initialPaused = !session?.is_active || session?.is_paused;
|
||||
sessionDataRef.current.ingestPaused = initialPaused;
|
||||
sessionDataRef.current.isPaused = initialPaused;
|
||||
console.log(`✅ Plot ${session?.session_id}: Real-time Streaming enabled`);
|
||||
// Try to create chart with realtime scale
|
||||
try {
|
||||
chartRef.current = new Chart(ctx, chartConfig);
|
||||
sessionDataRef.current.isRealTimeMode = true;
|
||||
console.log(`✅ Plot ${session.session_id}: Real-time Streaming enabled`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Plot ${session.session_id}: Real-time scale not available. Falling back to time scale.`, e);
|
||||
|
||||
// Fallback configuration without realtime
|
||||
chartConfig.options.scales.x = {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'second',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
autoSkip: true,
|
||||
maxRotation: 0
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tiempo'
|
||||
}
|
||||
};
|
||||
|
||||
chartRef.current = new Chart(ctx, chartConfig);
|
||||
sessionDataRef.current.isRealTimeMode = false;
|
||||
|
||||
// Start manual refresh for fallback mode
|
||||
startManualRefresh();
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
|
@ -273,11 +265,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
setError(error.message);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [session, getEnabledVariables, getColor]);
|
||||
|
||||
const onStreamingRefresh = useCallback(async (chart) => {
|
||||
const sessionId = sessionDataRef.current.sessionId;
|
||||
if (!sessionId) return;
|
||||
if (!session?.session_id) return;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
@ -291,75 +282,28 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
sessionDataRef.current.lastDataFetch = now;
|
||||
|
||||
// Fetch data from backend
|
||||
const response = await fetch(`/api/plots/${sessionId}/data`);
|
||||
const response = await fetch(`/api/plots/${session.session_id}/data`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const plotData = await response.json();
|
||||
|
||||
// Add new data to chart
|
||||
const pointsAdded = addNewDataToStreaming(plotData, now);
|
||||
updatePointsCounter(plotData);
|
||||
|
||||
if (pointsAdded > 0) {
|
||||
console.log(`📊 Plot ${sessionId}: Added ${pointsAdded} points to chart`);
|
||||
}
|
||||
|
||||
// Auto-pause when no data arrives for several cycles; resume when data appears
|
||||
if (pointsAdded > 0) {
|
||||
sessionDataRef.current.noDataCycles = 0;
|
||||
// Do not auto-resume if user explicitly paused
|
||||
if (sessionDataRef.current.isPaused && !sessionDataRef.current.userPaused) {
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) rt.pause = false;
|
||||
chart.update('none');
|
||||
sessionDataRef.current.isPaused = false;
|
||||
sessionDataRef.current.ingestPaused = false;
|
||||
}
|
||||
} else {
|
||||
sessionDataRef.current.noDataCycles = (sessionDataRef.current.noDataCycles || 0) + 1;
|
||||
if (sessionDataRef.current.noDataCycles >= 3) {
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) rt.pause = true;
|
||||
chart.update('none');
|
||||
sessionDataRef.current.isPaused = true;
|
||||
sessionDataRef.current.ingestPaused = true;
|
||||
}
|
||||
}
|
||||
// Add new data to chart
|
||||
addNewDataToStreaming(plotData, now);
|
||||
updatePointsCounter(plotData);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`📈 Error in streaming refresh for ${sessionDataRef.current.sessionId}:`, error);
|
||||
console.error(`📈 Error in streaming refresh for ${session.session_id}:`, error);
|
||||
}
|
||||
}, []);
|
||||
}, [session?.session_id]);
|
||||
|
||||
const addNewDataToStreaming = useCallback((plotData, timestamp) => {
|
||||
if (!chartRef.current || !plotData) return 0;
|
||||
if (!chartRef.current || !plotData) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
const sessionData = sessionDataRef.current;
|
||||
|
||||
// Check if paused
|
||||
if (sessionData.ingestPaused) return 0;
|
||||
|
||||
// Helpers to parse flexible payloads
|
||||
const getYValue = (point) => {
|
||||
const candidate = (point?.y !== undefined) ? point.y : (point?.value !== undefined ? point.value : point?.v);
|
||||
const yNum = typeof candidate === 'number' ? candidate : Number(candidate);
|
||||
return Number.isFinite(yNum) ? yNum : null;
|
||||
};
|
||||
const getXValueMs = (point) => {
|
||||
let raw = (point?.x !== undefined) ? point.x : (point?.ts !== undefined ? point.ts : (point?.timestamp !== undefined ? point.timestamp : point?.t));
|
||||
if (typeof raw === 'string') {
|
||||
const asNum = Number(raw);
|
||||
if (Number.isFinite(asNum)) raw = asNum; else {
|
||||
const parsed = Date.parse(raw);
|
||||
if (Number.isFinite(parsed)) raw = parsed; else return null;
|
||||
}
|
||||
}
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
|
||||
// Normalize seconds to milliseconds
|
||||
const xMs = raw < 1e12 ? raw * 1000 : raw;
|
||||
return xMs;
|
||||
};
|
||||
if (sessionData.ingestPaused) return;
|
||||
|
||||
let pointsAdded = 0;
|
||||
chart.data.datasets.forEach((chartDataset, datasetIndex) => {
|
||||
|
@ -374,11 +318,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const newPoints = [];
|
||||
for (let i = 0; i < backendDataset.data.length; i++) {
|
||||
const p = backendDataset.data[i];
|
||||
const yNum = getYValue(p);
|
||||
if (yNum === null) continue;
|
||||
const yNum = typeof p.y === 'number' ? p.y : Number(p.y);
|
||||
if (!isFinite(yNum)) continue;
|
||||
|
||||
const xNum = getXValueMs(p);
|
||||
if (xNum === null) continue;
|
||||
let xNum = typeof p.x === 'number' ? p.x : Number(p.x);
|
||||
if (!isFinite(xNum)) continue;
|
||||
if (xNum < 1e12) xNum = xNum * 1000; // seconds -> ms
|
||||
if (xNum > lastPushedX) newPoints.push({ x: xNum, y: yNum });
|
||||
}
|
||||
|
||||
|
@ -409,7 +354,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
});
|
||||
|
||||
// Update chart
|
||||
if (pointsAdded > 0) {
|
||||
if (!sessionData.isRealTimeMode) {
|
||||
cleanupOldDataFallback();
|
||||
chart.update('quiet');
|
||||
} else if (pointsAdded > 0) {
|
||||
chart.update('quiet');
|
||||
}
|
||||
|
||||
|
@ -417,10 +365,22 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (pointsAdded > 0 && sessionData.insertNaNOnNextIngest) {
|
||||
sessionData.insertNaNOnNextIngest = false;
|
||||
}
|
||||
return pointsAdded;
|
||||
}, []);
|
||||
|
||||
// Fallback cleanup removed per requirement: always realtime; we pause instead when no data
|
||||
const cleanupOldDataFallback = useCallback(() => {
|
||||
if (!chartRef.current || !session?.config) return;
|
||||
|
||||
const chart = chartRef.current;
|
||||
const timeWindow = (session.config.time_window || 60) * 1000;
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - timeWindow;
|
||||
|
||||
chart.data.datasets.forEach((dataset) => {
|
||||
if (dataset.data && dataset.data.length > 0) {
|
||||
dataset.data = dataset.data.filter(point => point.x > cutoffTime);
|
||||
}
|
||||
});
|
||||
}, [session?.config]);
|
||||
|
||||
const updatePointsCounter = useCallback((plotData) => {
|
||||
const totalPoints = plotData.data_points_count || 0;
|
||||
|
@ -448,11 +408,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
if (sessionData.isRealTimeMode) {
|
||||
const chart = chartRef.current;
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
const xScale = chart.scales?.x;
|
||||
if (xScale?.realtime) {
|
||||
xScale.realtime.pause = true;
|
||||
}
|
||||
chart.update('none');
|
||||
chart.update('quiet');
|
||||
} else {
|
||||
if (sessionData.manualInterval) {
|
||||
clearInterval(sessionData.manualInterval);
|
||||
|
@ -462,7 +422,6 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
sessionData.ingestPaused = true;
|
||||
sessionData.isPaused = true;
|
||||
sessionData.userOverrideUntil = Date.now() + 3000;
|
||||
}, []);
|
||||
|
||||
const resumeStreaming = useCallback(() => {
|
||||
|
@ -471,11 +430,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
if (sessionData.isRealTimeMode) {
|
||||
const chart = chartRef.current;
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = false;
|
||||
const xScale = chart.scales?.x;
|
||||
if (xScale?.realtime) {
|
||||
xScale.realtime.pause = false;
|
||||
}
|
||||
chart.update('none');
|
||||
chart.update('quiet');
|
||||
} else {
|
||||
if (!sessionData.manualInterval) {
|
||||
startManualRefresh();
|
||||
|
@ -485,7 +444,6 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
sessionData.insertNaNOnNextIngest = true;
|
||||
sessionData.ingestPaused = false;
|
||||
sessionData.isPaused = false;
|
||||
sessionData.userOverrideUntil = Date.now() + 3000;
|
||||
}, [startManualRefresh]);
|
||||
|
||||
const clearChart = useCallback(() => {
|
||||
|
@ -504,9 +462,6 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
// Also expose control functions through props for easier access
|
||||
React.useEffect(() => {
|
||||
// Update sessionId ref when session changes
|
||||
sessionDataRef.current.sessionId = session?.session_id || null;
|
||||
|
||||
if (typeof session?.onChartReady === 'function') {
|
||||
session.onChartReady({
|
||||
pauseStreaming,
|
||||
|
@ -514,52 +469,36 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
clearChart
|
||||
});
|
||||
}
|
||||
}, [pauseStreaming, resumeStreaming, clearChart, session?.session_id, session?.onChartReady]);
|
||||
}, [pauseStreaming, resumeStreaming, clearChart, session]);
|
||||
|
||||
// Update chart when session status changes
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || !session) return;
|
||||
// Respect short user override window to avoid flapping when backend state is stale
|
||||
if (Date.now() < (sessionDataRef.current.userOverrideUntil || 0)) return;
|
||||
|
||||
const shouldPause = !session.is_active || session.is_paused;
|
||||
if (shouldPause) {
|
||||
sessionDataRef.current.userPaused = true;
|
||||
pauseStreaming();
|
||||
} else {
|
||||
sessionDataRef.current.userPaused = false;
|
||||
resumeStreaming();
|
||||
}
|
||||
}, [session?.is_active, session?.is_paused, pauseStreaming, resumeStreaming]);
|
||||
|
||||
// Initialize chart when config is resolved - simplified approach
|
||||
// Initialize chart
|
||||
useEffect(() => {
|
||||
// Only create chart once when we have a session_id and canvas
|
||||
if (session?.session_id && canvasRef.current && !chartRef.current) {
|
||||
const config = session?.config;
|
||||
if (config) {
|
||||
resolvedConfigRef.current = config;
|
||||
createStreamingChart();
|
||||
}
|
||||
if (session?.config) {
|
||||
createStreamingChart();
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (chartRef.current) {
|
||||
const rt = chartRef.current.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
chartRef.current.update('none');
|
||||
}
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
if (sessionDataRef.current.manualInterval) {
|
||||
clearInterval(sessionDataRef.current.manualInterval);
|
||||
}
|
||||
};
|
||||
}, [session?.session_id]);
|
||||
}, [createStreamingChart]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -615,10 +554,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '6px',
|
||||
touchAction: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none'
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
|
|
|
@ -17,13 +17,13 @@ import {
|
|||
} 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 { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetCompleteManager - Gestiona datasets y variables de forma simplificada
|
||||
* Incluye: tabla de datasets individuales + variables (sin campos estáticos de configuración)
|
||||
*/
|
||||
export default function DatasetCompleteManager({ status }) {
|
||||
export default function DatasetCompleteManager() {
|
||||
const [fullData, setFullData] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
@ -38,55 +38,11 @@ export default function DatasetCompleteManager({ status }) {
|
|||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const [liveValues, setLiveValues] = useState({})
|
||||
const sseRef = React.useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Subscribe to SSE for live variable values of the selected dataset
|
||||
useEffect(() => {
|
||||
// Close previous stream
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
|
||||
// Only stream when online and dataset selected
|
||||
const plcConnected = !!status?.plc_connected
|
||||
if (!plcConnected || !selectedDatasetId) {
|
||||
setLiveValues({})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0`)
|
||||
sseRef.current = es
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(evt.data)
|
||||
if (payload?.type === 'values' && payload.values) {
|
||||
setLiveValues(payload.values || {})
|
||||
} else if (payload?.type === 'no_cache' || payload?.type === 'dataset_inactive' || payload?.type === 'plc_disconnected') {
|
||||
setLiveValues({})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
es.onerror = () => {
|
||||
try { es.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return () => {
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
}
|
||||
}, [status?.plc_connected, selectedDatasetId])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
|
@ -110,8 +66,7 @@ export default function DatasetCompleteManager({ status }) {
|
|||
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)
|
||||
setVariableSchema(variableSchemaPath)
|
||||
setVariableUiSchema(variableUiSchemaPath || {})
|
||||
|
||||
setFullData(datasetDataResp.data || {})
|
||||
|
@ -151,30 +106,11 @@ export default function DatasetCompleteManager({ status }) {
|
|||
|
||||
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 })
|
||||
// Solo enviar datasets, el backend calcula active_datasets automáticamente
|
||||
const newFullData = {
|
||||
datasets: newDatasets
|
||||
}
|
||||
|
||||
// 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}`)
|
||||
|
@ -295,7 +231,6 @@ export default function DatasetCompleteManager({ status }) {
|
|||
onChange={saveDatasetVariables}
|
||||
title={`Variables for: ${fullData.datasets?.[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
liveValues={liveValues}
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
|
|
|
@ -34,13 +34,11 @@ export default function FormTable({
|
|||
title = "Data",
|
||||
keyField = "id",
|
||||
allowAdd = true,
|
||||
allowDelete = true,
|
||||
liveValues = null
|
||||
allowDelete = true
|
||||
}) {
|
||||
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')
|
||||
|
@ -175,11 +173,6 @@ export default function FormTable({
|
|||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Heading size="xs">{key}</Heading>
|
||||
{data[key] && typeof data[key].enabled === 'boolean' && (
|
||||
<Badge colorScheme={data[key].enabled ? 'green' : 'red'} size="sm">
|
||||
{data[key].enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
{editingKey === key && (
|
||||
<Badge colorScheme="orange" size="sm">Editing</Badge>
|
||||
)}
|
||||
|
@ -199,7 +192,7 @@ export default function FormTable({
|
|||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => { setEditingFormData({ ...(data[key] || {}) }); setEditingKey(key) }}
|
||||
onClick={() => setEditingKey(key)}
|
||||
/>
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
|
@ -216,19 +209,12 @@ export default function FormTable({
|
|||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{liveValues && liveValues[key] !== undefined && (
|
||||
<Box mb={2}>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Live value: <Text as="span" fontWeight="semibold">{String(liveValues[key])}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={editingKey === key ? (editingFormData || {}) : (data[key] || {})}
|
||||
formData={data[key] || {}}
|
||||
validator={validator}
|
||||
onChange={editingKey === key ? ({ formData }) => setEditingFormData(formData) : () => { }}
|
||||
onChange={editingKey === key ? undefined : () => { }}
|
||||
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={widgets}
|
||||
|
@ -240,7 +226,7 @@ export default function FormTable({
|
|||
<Button type="submit" size="sm" colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingKey(null); setEditingFormData(null) }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingKey(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
|
|
|
@ -1,533 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Grid,
|
||||
Flex,
|
||||
Spacer,
|
||||
HStack,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Heading,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Badge,
|
||||
IconButton,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Divider,
|
||||
Select
|
||||
} from '@chakra-ui/react'
|
||||
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 * as api from '../services/api'
|
||||
|
||||
// Pure RJSF Plot Manager Component
|
||||
export default function PlotManager() {
|
||||
const [plots, setPlots] = useState({})
|
||||
const [plotsSchemaData, setPlotsSchemaData] = useState(null)
|
||||
const [plotsVariablesSchemaData, setPlotsVariablesSchemaData] = useState(null)
|
||||
const [plotsConfig, setPlotsConfig] = useState(null)
|
||||
const [plotsVariablesConfig, setPlotsVariablesConfig] = useState(null)
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState({})
|
||||
|
||||
const toast = useToast()
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const setActionState = (key, loading) => {
|
||||
setActionLoading(prev => ({ ...prev, [key]: loading }))
|
||||
}
|
||||
|
||||
const loadPlotData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [
|
||||
plotsData,
|
||||
plotsSchemaResponse,
|
||||
plotsVariablesSchemaResponse,
|
||||
plotsConfigData,
|
||||
plotsVariablesConfigData
|
||||
] = await Promise.all([
|
||||
api.getPlots(),
|
||||
api.getSchema('plot-definitions'),
|
||||
api.getSchema('plot-variables'),
|
||||
api.readConfig('plot-definitions'),
|
||||
api.readConfig('plot-variables')
|
||||
])
|
||||
|
||||
setPlots(plotsData?.plots || {})
|
||||
setPlotsSchemaData(plotsSchemaResponse)
|
||||
setPlotsVariablesSchemaData(plotsVariablesSchemaResponse)
|
||||
setPlotsConfig(plotsConfigData)
|
||||
setPlotsVariablesConfig(plotsVariablesConfigData)
|
||||
|
||||
// Auto-select first plot if none selected
|
||||
if (!selectedPlotId && plotsConfigData?.plots?.length > 0) {
|
||||
setSelectedPlotId(plotsConfigData.plots[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load plot data',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// Helper function to get plot definitions from config
|
||||
const getPlotDefinitions = () => {
|
||||
return plotsConfig?.plots || []
|
||||
}
|
||||
|
||||
// Helper function to get variables for a specific plot
|
||||
const getPlotVariables = (plotId) => {
|
||||
const plotVarsConfig = plotsVariablesConfig?.variables || []
|
||||
const plotVarEntry = plotVarsConfig.find(entry => entry.plot_id === plotId)
|
||||
return plotVarEntry?.variables || []
|
||||
}
|
||||
|
||||
// Type 3 Pattern Helper Functions
|
||||
// Get filtered variables for selected plot
|
||||
const getSelectedPlotVariables = () => {
|
||||
if (!plotsVariablesConfig?.variables || !selectedPlotId) {
|
||||
return { variables: [] }
|
||||
}
|
||||
|
||||
const plotVars = plotsVariablesConfig.variables.find(v => v.plot_id === selectedPlotId)
|
||||
return plotVars || { variables: [] }
|
||||
}
|
||||
|
||||
// Update variables for selected plot
|
||||
const updateSelectedPlotVariables = (newVariableData) => {
|
||||
if (!plotsVariablesConfig?.variables || !selectedPlotId) return
|
||||
|
||||
const updatedVariables = plotsVariablesConfig.variables.map(v =>
|
||||
v.plot_id === selectedPlotId
|
||||
? { ...v, ...newVariableData }
|
||||
: v
|
||||
)
|
||||
|
||||
// If plot not found, add new entry
|
||||
if (!plotsVariablesConfig.variables.find(v => v.plot_id === selectedPlotId)) {
|
||||
updatedVariables.push({
|
||||
plot_id: selectedPlotId,
|
||||
...newVariableData
|
||||
})
|
||||
}
|
||||
|
||||
const updatedConfig = { ...plotsVariablesConfig, variables: updatedVariables }
|
||||
setPlotsVariablesConfig(updatedConfig)
|
||||
}
|
||||
|
||||
// Available plots for combo selector
|
||||
const availablePlots = plotsConfig?.plots || []
|
||||
|
||||
// Handle plot configuration updates
|
||||
const handlePlotConfigUpdate = async (plotId, newConfig) => {
|
||||
try {
|
||||
// Update the plot definition in local state
|
||||
const updatedPlots = getPlotDefinitions().map(plot =>
|
||||
plot.id === plotId ? { ...plot, ...newConfig } : plot
|
||||
)
|
||||
|
||||
const updatedConfig = { ...plotsConfig, plots: updatedPlots }
|
||||
await savePlotsConfig(updatedConfig)
|
||||
|
||||
// Reload data to get fresh state
|
||||
await loadPlotData()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update plot configuration: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle plot removal
|
||||
const handlePlotRemove = async (plotId) => {
|
||||
try {
|
||||
// Remove from plot definitions
|
||||
const updatedPlots = getPlotDefinitions().filter(plot => plot.id !== plotId)
|
||||
const updatedPlotsConfig = { ...plotsConfig, plots: updatedPlots }
|
||||
|
||||
// Remove from plot variables
|
||||
const updatedPlotVars = (plotsVariablesConfig?.variables || []).filter(
|
||||
entry => entry.plot_id !== plotId
|
||||
)
|
||||
const updatedVarsConfig = { ...plotsVariablesConfig, variables: updatedPlotVars }
|
||||
|
||||
// Save both configurations
|
||||
await Promise.all([
|
||||
savePlotsConfig(updatedPlotsConfig),
|
||||
savePlotsVariablesConfig(updatedVarsConfig)
|
||||
])
|
||||
|
||||
// Stop the plot session in backend
|
||||
try {
|
||||
await api.controlPlotSession(plotId, 'stop')
|
||||
} catch (error) {
|
||||
// Plot session may not exist, that's OK
|
||||
}
|
||||
|
||||
// Reload data
|
||||
await loadPlotData()
|
||||
|
||||
toast({
|
||||
title: '✅ Plot removed successfully',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to remove plot',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotsConfig = async (formData) => {
|
||||
try {
|
||||
setActionState('savePlots', true)
|
||||
await api.writeConfig('plot-definitions', formData)
|
||||
toast({
|
||||
title: '✅ Plot definitions saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setPlotsConfig(formData)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot definitions',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setActionState('savePlots', false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotsVariablesConfig = async (formData) => {
|
||||
try {
|
||||
setActionState('savePlotsVariables', true)
|
||||
await api.writeConfig('plot-variables', formData)
|
||||
toast({
|
||||
title: '✅ Plot variables saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setPlotsVariablesConfig(formData)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot variables',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setActionState('savePlotsVariables', false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPlotData()
|
||||
}, [loadPlotData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg}>
|
||||
<CardBody>
|
||||
<Text>Loading plot configurations...</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📈 Plot Manager</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadPlotData}>
|
||||
🔄 Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Active Plot Sessions with Real Chart.js Plots */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="md">🎛️ Active Plot Sessions</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Real-time Chart.js plots with streaming data from PLC
|
||||
</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={6} align="stretch">
|
||||
{getPlotDefinitions().map((plotDef) => (
|
||||
<PlotRealtimeSession
|
||||
key={plotDef.id}
|
||||
plotDefinition={plotDef}
|
||||
plotVariables={getPlotVariables(plotDef.id)}
|
||||
onConfigUpdate={handlePlotConfigUpdate}
|
||||
onRemove={handlePlotRemove}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* RJSF Configuration Forms */}
|
||||
<Tabs variant="enclosed" colorScheme="blue">
|
||||
<TabList>
|
||||
<Tab>📋 Plot Definitions</Tab>
|
||||
<Tab>⚙️ Plot Variables</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel p={0} pt={4}>
|
||||
{plotsSchemaData?.schema && plotsConfig && (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="md">Plot Session Definitions</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Configure plot sessions, time windows, triggers and visual settings
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
schema={plotsSchemaData.schema}
|
||||
uiSchema={plotsSchemaData.uiSchema}
|
||||
formData={plotsConfig}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => savePlotsConfig(formData)}
|
||||
onChange={({ formData }) => setPlotsConfig(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
isLoading={actionLoading.savePlots}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
💾 Save Definitions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadPlotData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Plot Variables Configuration with Combo Selector - Type 3 Pattern */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Heading size="md">Plot Variables Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Select a plot session, then configure which variables are displayed in that plot
|
||||
</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 session to configure..."
|
||||
size="md"
|
||||
>
|
||||
{availablePlots.map(plot => (
|
||||
<option key={plot.id} value={plot.id}>
|
||||
📈 {plot.name} ({plot.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{availablePlots.length === 0 && (
|
||||
<Text fontSize="sm" color="orange.500" mt={2}>
|
||||
⚠️ No plot sessions available. Configure plot definitions first in the "Plot Definitions" tab.
|
||||
</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
|
||||
const singlePlotSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
variables: {
|
||||
type: "array",
|
||||
title: "Variables",
|
||||
description: `Variables to display in plot ${selectedPlotId}`,
|
||||
items: {
|
||||
type: "object",
|
||||
title: "Plot Variable",
|
||||
properties: {
|
||||
dataset_id: {
|
||||
type: "string",
|
||||
title: "Dataset Source",
|
||||
description: "Which dataset contains this variable"
|
||||
},
|
||||
variable_name: {
|
||||
type: "string",
|
||||
title: "Variable Name",
|
||||
description: "Name of the variable to plot"
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
title: "Display Label",
|
||||
description: "Label shown in the plot legend"
|
||||
},
|
||||
color: {
|
||||
type: "string",
|
||||
title: "Line Color",
|
||||
default: "#3182CE"
|
||||
},
|
||||
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"
|
||||
}
|
||||
},
|
||||
required: ["dataset_id", "variable_name", "label"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const singlePlotUiSchema = {
|
||||
variables: {
|
||||
items: {
|
||||
"ui:layout": [[
|
||||
{ "name": "dataset_id", "width": 2 },
|
||||
{ "name": "variable_name", "width": 3 },
|
||||
{ "name": "label", "width": 2 },
|
||||
{ "name": "color", "width": 2 },
|
||||
{ "name": "line_width", "width": 1 },
|
||||
{ "name": "y_axis", "width": 2 }
|
||||
]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={singlePlotSchema}
|
||||
uiSchema={singlePlotUiSchema}
|
||||
formData={selectedPlotVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
updateSelectedPlotVariables(formData)
|
||||
// Create updated config and save it
|
||||
const updatedVariables = plotsVariablesConfig.variables?.map(v =>
|
||||
v.plot_id === selectedPlotId
|
||||
? { ...v, ...formData }
|
||||
: v
|
||||
) || []
|
||||
|
||||
// If plot not found, add new entry
|
||||
if (!plotsVariablesConfig.variables?.find(v => v.plot_id === selectedPlotId)) {
|
||||
updatedVariables.push({
|
||||
plot_id: selectedPlotId,
|
||||
...formData
|
||||
})
|
||||
}
|
||||
|
||||
const updatedConfig = { ...plotsVariablesConfig, variables: updatedVariables }
|
||||
savePlotsVariablesConfig(updatedConfig)
|
||||
}}
|
||||
onChange={({ formData }) => updateSelectedPlotVariables(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
isLoading={actionLoading.savePlotsVariables}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
💾 Save Variables for {selectedPlotId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadPlotData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!selectedPlotId && availablePlots.length > 0 && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">
|
||||
👆 Select a plot session above to configure its variables
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,449 +0,0 @@
|
|||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
Divider,
|
||||
Spacer,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Grid,
|
||||
GridItem,
|
||||
Flex,
|
||||
useToast
|
||||
} from '@chakra-ui/react'
|
||||
import { SettingsIcon } from '@chakra-ui/icons'
|
||||
import ChartjsPlot from './ChartjsPlot.jsx'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
* PlotRealtimeSession - Individual real-time Chart.js plot component
|
||||
* Mimics the functionality from the legacy plotting.js system
|
||||
*/
|
||||
export default function PlotRealtimeSession({
|
||||
plotDefinition,
|
||||
plotVariables = [],
|
||||
onRemove,
|
||||
onConfigUpdate
|
||||
}) {
|
||||
const [session, setSession] = useState({
|
||||
session_id: plotDefinition.id,
|
||||
name: plotDefinition.name,
|
||||
is_active: false,
|
||||
is_paused: false,
|
||||
variables_count: plotVariables.length
|
||||
})
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
time_window: plotDefinition.time_window || 60,
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max,
|
||||
trigger_enabled: plotDefinition.trigger_enabled || false,
|
||||
trigger_variable: plotDefinition.trigger_variable,
|
||||
trigger_on_true: plotDefinition.trigger_on_true || true
|
||||
})
|
||||
|
||||
const chartControlsRef = useRef(null)
|
||||
const intervalRef = useRef(null)
|
||||
const toast = useToast()
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Enhanced session object for ChartjsPlot - memoized to prevent recreations
|
||||
const enhancedSession = useMemo(() => ({
|
||||
session_id: plotDefinition.id,
|
||||
name: plotDefinition.name,
|
||||
is_active: session.is_active,
|
||||
is_paused: session.is_paused,
|
||||
variables_count: plotVariables.length,
|
||||
config: {
|
||||
...plotDefinition,
|
||||
...localConfig,
|
||||
variables: plotVariables
|
||||
},
|
||||
onChartReady: (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
}
|
||||
}), [
|
||||
plotDefinition.id,
|
||||
plotDefinition.name,
|
||||
plotDefinition,
|
||||
session.is_active,
|
||||
session.is_paused,
|
||||
plotVariables,
|
||||
localConfig
|
||||
])
|
||||
|
||||
// Load session status from backend (optional - session may not exist until started)
|
||||
const refreshSessionStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.getPlotSession(plotDefinition.id)
|
||||
if (response?.config) {
|
||||
setSession(prev => ({
|
||||
...prev,
|
||||
is_active: response.config.is_active || false,
|
||||
is_paused: response.config.is_paused || false
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
// Session may not exist in backend yet
|
||||
if (error.message.includes('404')) {
|
||||
// Try to create the session automatically
|
||||
await createPlotSessionFromConfig()
|
||||
} else {
|
||||
// Backend not available - use local state silently
|
||||
// This allows the component to work even when backend is offline
|
||||
}
|
||||
}
|
||||
}, [plotDefinition.id])
|
||||
|
||||
// Create plot session in backend based on static configuration
|
||||
const createPlotSessionFromConfig = useCallback(async () => {
|
||||
try {
|
||||
// Convert plotVariables array to the format expected by the API
|
||||
const variableNames = plotVariables.map(v => v.variable_name)
|
||||
|
||||
const plotConfig = {
|
||||
id: plotDefinition.id, // Use id instead of session_id
|
||||
name: plotDefinition.name,
|
||||
variables: variableNames,
|
||||
time_window: plotDefinition.time_window || 60,
|
||||
trigger_enabled: plotDefinition.trigger_enabled || false,
|
||||
trigger_variable: plotDefinition.trigger_variable,
|
||||
trigger_on_true: plotDefinition.trigger_on_true || true,
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max
|
||||
}
|
||||
|
||||
// Create the plot session
|
||||
await api.createPlot(plotConfig)
|
||||
|
||||
console.log(`✅ Created plot session: ${plotDefinition.id}`)
|
||||
} catch (error) {
|
||||
console.warn(`Could not create plot session ${plotDefinition.id}:`, error)
|
||||
}
|
||||
}, [plotDefinition, plotVariables])
|
||||
|
||||
// Control plot session (start, pause, stop, clear)
|
||||
const handleControlClick = async (action) => {
|
||||
// Send command to backend first
|
||||
try {
|
||||
// For 'start' action, create the plot session first if it doesn't exist
|
||||
if (action === 'start') {
|
||||
try {
|
||||
// Try to create the plot session with current configuration
|
||||
await api.createPlot({
|
||||
id: plotDefinition.id, // Use id instead of session_id
|
||||
name: plotDefinition.name,
|
||||
variables: plotVariables.map(v => v.variable_name), // Simplified format
|
||||
time_window: localConfig.time_window,
|
||||
trigger_enabled: localConfig.trigger_enabled,
|
||||
trigger_variable: localConfig.trigger_variable,
|
||||
trigger_on_true: localConfig.trigger_on_true,
|
||||
y_min: localConfig.y_min,
|
||||
y_max: localConfig.y_max
|
||||
})
|
||||
} catch (createError) {
|
||||
// Plot may already exist, that's OK
|
||||
console.log('Plot session may already exist:', createError.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Send control command to backend
|
||||
await api.controlPlotSession(plotDefinition.id, action)
|
||||
|
||||
// For 'start' action, verify that the session is actually active
|
||||
if (action === 'start') {
|
||||
// Wait a bit and verify the session started
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
const verifyResponse = await api.getPlotSession(plotDefinition.id)
|
||||
if (!verifyResponse?.config?.is_active) {
|
||||
// Try the control command once more if not active
|
||||
console.log('Session not active, retrying control command...')
|
||||
await api.controlPlotSession(plotDefinition.id, action)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local feedback after successful backend response
|
||||
if (chartControlsRef.current) {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
setSession(prev => ({ ...prev, is_paused: true }))
|
||||
break
|
||||
case 'start':
|
||||
case 'resume':
|
||||
chartControlsRef.current.resumeStreaming()
|
||||
setSession(prev => ({ ...prev, is_active: true, is_paused: false }))
|
||||
break
|
||||
case 'clear':
|
||||
chartControlsRef.current.clearChart()
|
||||
break
|
||||
case 'stop':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
setSession(prev => ({ ...prev, is_active: false, is_paused: false }))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh status after backend command (shorter delay)
|
||||
setTimeout(refreshSessionStatus, 200)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `❌ Failed to ${action} plot`,
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// Revert local state on error
|
||||
await refreshSessionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Apply configuration changes
|
||||
const applyConfigChanges = async () => {
|
||||
try {
|
||||
// Update backend configuration
|
||||
await onConfigUpdate?.(plotDefinition.id, localConfig)
|
||||
|
||||
// Apply changes to chart if possible
|
||||
if (chartControlsRef.current?.updateConfig) {
|
||||
chartControlsRef.current.updateConfig(localConfig)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '✅ Configuration updated',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
|
||||
setShowSettings(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to update configuration',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resetConfigChanges = () => {
|
||||
setLocalConfig({
|
||||
time_window: plotDefinition.time_window || 60,
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max,
|
||||
trigger_enabled: plotDefinition.trigger_enabled || false,
|
||||
trigger_variable: plotDefinition.trigger_variable,
|
||||
trigger_on_true: plotDefinition.trigger_on_true || true
|
||||
})
|
||||
setShowSettings(false)
|
||||
}
|
||||
|
||||
// Auto-refresh session status
|
||||
useEffect(() => {
|
||||
// Try to get session status first, if it fails, create the session
|
||||
refreshSessionStatus()
|
||||
intervalRef.current = setInterval(refreshSessionStatus, 5000)
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshSessionStatus])
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} shadow="md">
|
||||
<CardHeader pb={2}>
|
||||
<HStack align="center">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {plotDefinition.name || plotDefinition.id}</Heading>
|
||||
<Text fontSize="sm" color={muted} mt={1}>
|
||||
Variables: {plotVariables.length} |
|
||||
Status: <strong>
|
||||
{session.is_active
|
||||
? (session.is_paused ? 'Paused' : 'Active')
|
||||
: 'Stopped'
|
||||
}
|
||||
</strong>
|
||||
{localConfig.trigger_enabled && (
|
||||
<> | Trigger: {localConfig.trigger_variable}</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Settings"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => onRemove?.(plotDefinition.id)}
|
||||
>
|
||||
❌
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody pt={0}>
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<Box mb={4} p={4} bg={useColorModeValue('gray.50', 'gray.600')} borderRadius="md">
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
|
||||
<GridItem>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Time Window (seconds)</FormLabel>
|
||||
<NumberInput
|
||||
value={localConfig.time_window}
|
||||
onChange={(valueString) => setLocalConfig(prev => ({
|
||||
...prev,
|
||||
time_window: parseInt(valueString) || 60
|
||||
}))}
|
||||
min={10}
|
||||
max={3600}
|
||||
size="sm"
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Y Min (auto if empty)</FormLabel>
|
||||
<NumberInput
|
||||
value={localConfig.y_min || ''}
|
||||
onChange={(valueString) => setLocalConfig(prev => ({
|
||||
...prev,
|
||||
y_min: valueString === '' ? null : parseFloat(valueString)
|
||||
}))}
|
||||
size="sm"
|
||||
>
|
||||
<NumberInputField placeholder="Auto" />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Y Max (auto if empty)</FormLabel>
|
||||
<NumberInput
|
||||
value={localConfig.y_max || ''}
|
||||
onChange={(valueString) => setLocalConfig(prev => ({
|
||||
...prev,
|
||||
y_max: valueString === '' ? null : parseFloat(valueString)
|
||||
}))}
|
||||
size="sm"
|
||||
>
|
||||
<NumberInputField placeholder="Auto" />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Enable Trigger</FormLabel>
|
||||
<Switch
|
||||
isChecked={localConfig.trigger_enabled}
|
||||
onChange={(e) => setLocalConfig(prev => ({
|
||||
...prev,
|
||||
trigger_enabled: e.target.checked
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</FormControl>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
<Flex mt={4} gap={2}>
|
||||
<Button size="sm" colorScheme="blue" onClick={applyConfigChanges}>
|
||||
💾 Apply
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={resetConfigChanges}>
|
||||
↩️ Cancel
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Chart.js Plot */}
|
||||
<Box mb={4}>
|
||||
<ChartjsPlot session={enhancedSession} height="320px" />
|
||||
</Box>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<HStack spacing={2} justify="center">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleControlClick('start')}
|
||||
colorScheme="green"
|
||||
isDisabled={session.is_active && !session.is_paused}
|
||||
>
|
||||
▶️ Start
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleControlClick('pause')}
|
||||
colorScheme="yellow"
|
||||
isDisabled={!session.is_active || session.is_paused}
|
||||
>
|
||||
⏸️ Pause
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleControlClick('clear')}
|
||||
variant="outline"
|
||||
>
|
||||
🗑️ Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleControlClick('stop')}
|
||||
colorScheme="red"
|
||||
isDisabled={!session.is_active}
|
||||
>
|
||||
⏹️ Stop
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -1,215 +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,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon, SettingsIcon, DeleteIcon } from '@chakra-ui/icons'
|
||||
import ChartjsPlot from './ChartjsPlot.jsx'
|
||||
|
||||
export default function PlotRealtimeViewer() {
|
||||
const [sessions, setSessions] = useState(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const intervalRef = useRef(null)
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/plots')
|
||||
const data = await res.json()
|
||||
if (data && data.sessions) {
|
||||
setSessions(prev => {
|
||||
const next = new Map(prev)
|
||||
const incomingIds = new Set()
|
||||
for (const s of data.sessions) {
|
||||
incomingIds.add(s.session_id)
|
||||
const existing = next.get(s.session_id)
|
||||
if (existing) {
|
||||
// Mutate existing object to preserve reference
|
||||
existing.name = s.name
|
||||
existing.is_active = s.is_active
|
||||
existing.is_paused = s.is_paused
|
||||
existing.variables_count = s.variables_count
|
||||
} else {
|
||||
next.set(s.session_id, { ...s })
|
||||
}
|
||||
}
|
||||
// Remove sessions not present anymore
|
||||
for (const id of Array.from(next.keys())) {
|
||||
if (!incomingIds.has(id)) next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSessions(new Map())
|
||||
}
|
||||
} catch {
|
||||
setSessions(new Map())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSession = async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/plots/${sessionId}/config`)
|
||||
const data = await res.json()
|
||||
if (data && data.success && data.config) {
|
||||
setSessions(prev => {
|
||||
const n = new Map(prev)
|
||||
const existing = n.get(sessionId)
|
||||
const varsCount = Array.isArray(data.config.variables)
|
||||
? data.config.variables.length
|
||||
: (data.config.variables ? Object.keys(data.config.variables).length : (existing?.variables_count || 0))
|
||||
if (existing) {
|
||||
existing.name = data.config.name
|
||||
existing.is_active = data.config.is_active
|
||||
existing.is_paused = data.config.is_paused
|
||||
existing.variables_count = varsCount
|
||||
} else {
|
||||
n.set(sessionId, {
|
||||
session_id: sessionId,
|
||||
name: data.config.name,
|
||||
is_active: data.config.is_active,
|
||||
is_paused: data.config.is_paused,
|
||||
variables_count: varsCount,
|
||||
})
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const controlSession = async (sessionId, action) => {
|
||||
try {
|
||||
await fetch(`/api/plots/${sessionId}/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
await refreshSession(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
intervalRef.current = setInterval(loadSessions, 5000)
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [])
|
||||
|
||||
const sessionsList = useMemo(() => Array.from(sessions.values()), [sessions])
|
||||
|
||||
if (loading && sessionsList.length === 0) {
|
||||
return <Text color={muted}>Cargando sesiones de plots…</Text>
|
||||
}
|
||||
|
||||
if (sessionsList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text color={muted}>No hay sesiones de plot. Cree o edite plots en la sección superior.</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{sessionsList.map((session) => (
|
||||
<PlotRealtimeCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onControl={controlSession}
|
||||
onRefresh={refreshSession}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
function PlotRealtimeCard({ session, onControl, onRefresh }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const chartControlsRef = useRef(null)
|
||||
|
||||
const handleChartReady = (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
}
|
||||
|
||||
const enhancedSession = {
|
||||
...session,
|
||||
onChartReady: handleChartReady,
|
||||
}
|
||||
|
||||
const handleControlClick = async (action) => {
|
||||
if (chartControlsRef.current) {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
case 'start':
|
||||
case 'resume':
|
||||
chartControlsRef.current.resumeStreaming()
|
||||
break
|
||||
case 'clear':
|
||||
chartControlsRef.current.clearChart()
|
||||
break
|
||||
case 'stop':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
}
|
||||
}
|
||||
// No esperar a que el backend responda para aplicar efecto local
|
||||
onControl(session.session_id, action)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<FlexHeader session={session} muted={muted} onRefresh={() => onRefresh(session.session_id)} />
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ChartjsPlot session={enhancedSession} height="360px" />
|
||||
<HStack mt={3} spacing={2}>
|
||||
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green">▶️ Start</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow">⏸️ Pause</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑️ Clear</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red">⏹️ Stop</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FlexHeader({ session, muted, onRefresh }) {
|
||||
return (
|
||||
<HStack align="center">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {session.name || session.session_id}</Heading>
|
||||
<Text fontSize="sm" color={muted} mt={1}>
|
||||
Variables: {session.variables_count || 0} | Status: <strong>{session.is_active ? (session.is_paused ? 'Paused' : 'Active') : 'Stopped'}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
<IconButton icon={<SettingsIcon />} size="sm" variant="outline" aria-label="Refresh status" onClick={onRefresh} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
FormControl, FormLabel, FormHelperText, Select, VStack, HStack,
|
||||
Text, Badge, Box, Icon, Input, useColorModeValue, Spinner
|
||||
|
@ -14,9 +14,6 @@ export function VariableSelectorWidget(props) {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedDataset, setSelectedDataset] = useState('all')
|
||||
const [liveValue, setLiveValue] = useState(undefined)
|
||||
const [liveStatus, setLiveStatus] = useState('idle')
|
||||
const esRef = useRef(null)
|
||||
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600')
|
||||
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
|
||||
|
@ -66,58 +63,6 @@ export function VariableSelectorWidget(props) {
|
|||
return variables
|
||||
}, [datasetVariables])
|
||||
|
||||
// Subscribe to SSE for live value of selected variable
|
||||
useEffect(() => {
|
||||
// close previous stream
|
||||
if (esRef.current) {
|
||||
try { esRef.current.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
}
|
||||
|
||||
const variable = value && allVariables.find(v => v.name === value)
|
||||
const datasetId = variable?.dataset
|
||||
if (!datasetId || !value) {
|
||||
setLiveValue(undefined)
|
||||
setLiveStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0`)
|
||||
esRef.current = es
|
||||
setLiveStatus('connecting')
|
||||
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(evt.data)
|
||||
if (payload?.type === 'values' && payload.values) {
|
||||
setLiveValue(payload.values[value])
|
||||
setLiveStatus('ok')
|
||||
} else if (payload?.type === 'no_cache') {
|
||||
setLiveStatus('waiting')
|
||||
} else if (payload?.type === 'plc_disconnected' || payload?.type === 'dataset_inactive') {
|
||||
setLiveValue(undefined)
|
||||
setLiveStatus('offline')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
es.onerror = () => {
|
||||
try { es.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
setLiveStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setLiveStatus('error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (esRef.current) {
|
||||
try { esRef.current.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [value, allVariables])
|
||||
|
||||
// Filter variables based on search term and selected dataset
|
||||
const filteredVariables = useMemo(() => {
|
||||
let filtered = allVariables
|
||||
|
@ -277,11 +222,6 @@ export function VariableSelectorWidget(props) {
|
|||
PLC Address: {selectedVariable.area}{selectedVariable.db ? `${selectedVariable.db}.` : ''}{selectedVariable.offset}
|
||||
{selectedVariable.streaming ? ' • Real-time streaming enabled' : ' • Static logging only'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Live value: {liveStatus === 'ok' && liveValue !== undefined ? (
|
||||
<Text as="span" fontWeight="semibold">{String(liveValue)}</Text>
|
||||
) : liveStatus === 'waiting' ? 'waiting…' : liveStatus === 'offline' ? 'offline' : liveStatus === 'error' ? 'error' : '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
@ -39,30 +39,26 @@ export const TextWidget = ({ id, placeholder, required, readonly, disabled, labe
|
|||
)
|
||||
}
|
||||
|
||||
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [], schema, options = {} }) => {
|
||||
const min = options.min ?? schema?.minimum
|
||||
const max = options.max ?? schema?.maximum
|
||||
const step = options.step ?? schema?.multipleOf ?? 1
|
||||
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, rawErrors = [], schema }) => {
|
||||
const min = schema?.minimum
|
||||
const max = schema?.maximum
|
||||
const step = schema?.multipleOf || 1
|
||||
|
||||
return (
|
||||
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
|
||||
{label && <FormLabel htmlFor={id}>{label}</FormLabel>}
|
||||
<NumberInput
|
||||
id={id}
|
||||
value={value ?? ''}
|
||||
value={value || ''}
|
||||
onChange={(_, num) => onChange(isNaN(num) ? undefined : num)}
|
||||
onBlur={onBlur && (() => onBlur(id, value))}
|
||||
onFocus={onFocus && (() => onFocus(id, value))}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
precision={schema?.type === 'integer' ? 0 : step < 1 ? 2 : 1}
|
||||
precision={schema?.type === 'integer' ? 0 : 2}
|
||||
>
|
||||
<NumberInputField
|
||||
placeholder={placeholder}
|
||||
inputMode={step < 1 ? "decimal" : "numeric"}
|
||||
pattern={step < 1 ? "[0-9]*(\\.[0-9]+)?" : "[0-9]*"}
|
||||
/>
|
||||
<NumberInputField placeholder={placeholder} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { customWidgets } from './CustomWidgets'
|
||||
import { widgets } from '../rjsf/widgets'
|
||||
|
||||
// Comprehensive widget collection that merges all available widgets
|
||||
// for full UI schema support with layouts
|
||||
export const allWidgets = {
|
||||
// Custom application-specific widgets
|
||||
...customWidgets,
|
||||
|
||||
// Enhanced RJSF widgets with proper styling
|
||||
...widgets,
|
||||
|
||||
// Additional widget aliases for UI schema compatibility
|
||||
updown: widgets.UpDownWidget,
|
||||
text: widgets.TextWidget,
|
||||
textarea: widgets.TextareaWidget,
|
||||
select: widgets.SelectWidget,
|
||||
checkbox: widgets.CheckboxWidget,
|
||||
|
||||
// Variable selector aliases
|
||||
variableSelector: customWidgets.VariableSelectorWidget,
|
||||
'variable-selector': customWidgets.VariableSelectorWidget,
|
||||
|
||||
// PLC-specific widget aliases (if available)
|
||||
plcArea: widgets.PlcAreaWidget,
|
||||
plcDataType: widgets.PlcDataTypeWidget,
|
||||
plcNumber: widgets.PlcNumberWidget,
|
||||
plcStreaming: widgets.PlcStreamingWidget,
|
||||
plcVariableName: widgets.PlcVariableNameWidget,
|
||||
}
|
||||
|
||||
export default allWidgets
|
|
@ -1,70 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { Select } from '@chakra-ui/react'
|
||||
import * as api from '../../services/api'
|
||||
|
||||
// Custom Variable Selector Widget for RJSF
|
||||
export function VariableSelectorWidget(props) {
|
||||
const { value, onChange, options, placeholder, disabled, readonly } = props
|
||||
const [variables, setVariables] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadVariables = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// Try to get variables from plot variables API
|
||||
const plotVariables = await api.getPlotVariables()
|
||||
// Extract the available_variables array from the response
|
||||
if (plotVariables && plotVariables.available_variables && Array.isArray(plotVariables.available_variables)) {
|
||||
setVariables(plotVariables.available_variables)
|
||||
} else {
|
||||
console.warn('Unexpected plot variables response format:', plotVariables)
|
||||
setVariables([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load variables:', error)
|
||||
setVariables([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadVariables()
|
||||
}, [])
|
||||
|
||||
const handleChange = (event) => {
|
||||
onChange(event.target.value === '' ? undefined : event.target.value)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Select placeholder="Loading variables..." disabled>
|
||||
<option value="">Loading...</option>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder || 'Select a variable...'}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<option value="">-- Select Variable --</option>
|
||||
{Array.isArray(variables) && variables.map((variable, index) => (
|
||||
<option key={variable.name || index} value={variable.name || variable}>
|
||||
{variable.display_name || variable.name || variable}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// Export widgets object for RJSF
|
||||
export const customWidgets = {
|
||||
VariableSelectorWidget,
|
||||
variableSelector: VariableSelectorWidget // Alternative name for UI schemas
|
||||
}
|
||||
|
||||
export default customWidgets
|
|
@ -1,16 +1,13 @@
|
|||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Box, Container, Flex, Grid, GridItem, HStack, VStack, Heading, Text, Button, Badge,
|
||||
Table, Thead, Tbody, Tr, Th, Td, Alert, AlertIcon, Card, CardBody, CardHeader,
|
||||
useColorModeValue, useToast, Tabs, TabList, TabPanels, Tab, TabPanel,
|
||||
Accordion, AccordionItem, AccordionButton, AccordionPanel, AccordionIcon,
|
||||
Spacer, IconButton, Divider
|
||||
} from '@chakra-ui/react'
|
||||
import { Box, Container, Flex, Grid, GridItem, HStack, Heading, Text, Button, Badge, Table, Thead, Tbody, Tr, Th, Td, Alert, AlertIcon, Card, CardBody, useColorModeValue } from '@chakra-ui/react'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { widgets } from '../components/rjsf/widgets.jsx'
|
||||
import DatasetCompleteManager from '../components/DatasetCompleteManager.jsx'
|
||||
import PlotCompleteManager from '../components/PlotCompleteManager.jsx'
|
||||
import PLCConfigManager from '../components/PLCConfigManager.jsx'
|
||||
import {
|
||||
getStatus,
|
||||
getEvents,
|
||||
|
@ -22,69 +19,15 @@ import {
|
|||
disconnectPlc,
|
||||
startUdpStreaming,
|
||||
stopUdpStreaming,
|
||||
activateDataset,
|
||||
deactivateDataset,
|
||||
} from '../services/api.js'
|
||||
|
||||
function StatusBar({ status, onRefresh }) {
|
||||
function StatusBar({ status }) {
|
||||
const plcConnected = !!status?.plc_connected
|
||||
const streaming = !!status?.streaming
|
||||
const csvRecording = !!status?.csv_recording
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const toast = useToast()
|
||||
|
||||
const handleConnectPlc = async () => {
|
||||
try {
|
||||
const result = await connectPlc()
|
||||
if (result.success) {
|
||||
toast({ title: 'PLC Connected', status: 'success', duration: 3000 })
|
||||
onRefresh?.()
|
||||
} else {
|
||||
toast({ title: 'Connection Failed', description: result.message, status: 'error', duration: 5000 })
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Connection Error', description: error.message, status: 'error', duration: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnectPlc = async () => {
|
||||
try {
|
||||
const result = await disconnectPlc()
|
||||
if (result.success) {
|
||||
toast({ title: 'PLC Disconnected', status: 'info', duration: 3000 })
|
||||
onRefresh?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Disconnect Error', description: error.message, status: 'error', duration: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartStreaming = async () => {
|
||||
try {
|
||||
const result = await startUdpStreaming()
|
||||
if (result.success) {
|
||||
toast({ title: 'UDP Streaming Started', status: 'success', duration: 3000 })
|
||||
onRefresh?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Streaming Error', description: error.message, status: 'error', duration: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopStreaming = async () => {
|
||||
try {
|
||||
const result = await stopUdpStreaming()
|
||||
if (result.success) {
|
||||
toast({ title: 'UDP Streaming Stopped', status: 'info', duration: 3000 })
|
||||
onRefresh?.()
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Stop Error', description: error.message, status: 'error', duration: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={3} mb={4}>
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={3} mb={3}>
|
||||
<Card><CardBody display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Text fontWeight="semibold">🔌 PLC: {plcConnected ? 'Connected' : 'Disconnected'}</Text>
|
||||
|
@ -95,9 +38,9 @@ function StatusBar({ status, onRefresh }) {
|
|||
)}
|
||||
</Box>
|
||||
{plcConnected ? (
|
||||
<Button size="sm" variant="outline" colorScheme="red" onClick={handleDisconnectPlc}>❌ Disconnect</Button>
|
||||
<Button size="sm" variant="outline" colorScheme="red" onClick={disconnectPlc}>❌ Disconnect</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" colorScheme="blue" onClick={handleConnectPlc}>🔗 Connect</Button>
|
||||
<Button size="sm" variant="outline" colorScheme="blue" onClick={connectPlc}>🔗 Connect</Button>
|
||||
)}
|
||||
</CardBody></Card>
|
||||
|
||||
|
@ -106,9 +49,9 @@ function StatusBar({ status, onRefresh }) {
|
|||
<Text fontWeight="semibold">📡 UDP Streaming: {streaming ? 'Active' : 'Inactive'}</Text>
|
||||
</Box>
|
||||
{streaming ? (
|
||||
<Button size="sm" variant="outline" colorScheme="red" onClick={handleStopStreaming}>⏹️ Stop</Button>
|
||||
<Button size="sm" variant="outline" colorScheme="red" onClick={stopUdpStreaming}>⏹️ Stop</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" colorScheme="blue" onClick={handleStartStreaming}>▶️ Start</Button>
|
||||
<Button size="sm" variant="outline" colorScheme="blue" onClick={startUdpStreaming}>▶️ Start</Button>
|
||||
)}
|
||||
</CardBody></Card>
|
||||
|
||||
|
@ -297,7 +240,7 @@ export default function DashboardPage() {
|
|||
<Flex wrap="wrap" gap={2} align="center" mb={3}>
|
||||
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
|
||||
</Flex>
|
||||
<DatasetCompleteManager status={status} />
|
||||
<DatasetCompleteManager />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
@ -310,10 +253,6 @@ export default function DashboardPage() {
|
|||
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
|
||||
</Flex>
|
||||
<PlotCompleteManager />
|
||||
<Box mt={4}>
|
||||
<Text fontWeight="semibold" mb={2}>🔴 Real-time plots</Text>
|
||||
<PlotRealtimeViewer />
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
@ -1,880 +0,0 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
Heading,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Flex,
|
||||
Spacer,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Spinner,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Divider,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Code,
|
||||
Select
|
||||
} from '@chakra-ui/react'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import PlotManager from '../components/PlotManager'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// StatusBar Component - Real-time PLC status with action buttons
|
||||
function StatusBar({ status, onRefresh }) {
|
||||
const plcConnected = !!status?.plc_connected
|
||||
const streaming = !!status?.streaming
|
||||
const csvRecording = !!status?.csv_recording
|
||||
const [actionLoading, setActionLoading] = useState({})
|
||||
const toast = useToast()
|
||||
|
||||
const setLoading = (action, loading) => {
|
||||
setActionLoading(prev => ({ ...prev, [action]: loading }))
|
||||
}
|
||||
|
||||
const handleConnectPlc = async () => {
|
||||
setLoading('connect', true)
|
||||
try {
|
||||
const result = await api.connectPlc()
|
||||
toast({
|
||||
title: '🔗 PLC Connection',
|
||||
description: result.message || 'Connection initiated',
|
||||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to connect PLC',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading('connect', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnectPlc = async () => {
|
||||
setLoading('disconnect', true)
|
||||
try {
|
||||
const result = await api.disconnectPlc()
|
||||
toast({
|
||||
title: '❌ PLC Disconnection',
|
||||
description: result.message || 'Disconnection initiated',
|
||||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to disconnect PLC',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading('disconnect', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartStreaming = async () => {
|
||||
setLoading('startStream', true)
|
||||
try {
|
||||
const result = await api.startUdpStreaming()
|
||||
toast({
|
||||
title: '📡 UDP Streaming started',
|
||||
description: result.message || 'Streaming initiated',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to start streaming',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading('startStream', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopStreaming = async () => {
|
||||
setLoading('stopStream', true)
|
||||
try {
|
||||
const result = await api.stopUdpStreaming()
|
||||
toast({
|
||||
title: '⏹️ UDP Streaming stopped',
|
||||
description: result.message || 'Streaming stopped',
|
||||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to stop streaming',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading('stopStream', false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>🔌 PLC Connection</StatLabel>
|
||||
<StatNumber fontSize="lg" color={plcConnected ? 'green.500' : 'red.500'}>
|
||||
{plcConnected ? 'Connected' : 'Disconnected'}
|
||||
</StatNumber>
|
||||
{status?.plc_reconnection?.enabled && (
|
||||
<StatHelpText>
|
||||
🔄 Auto-reconnection: {status?.plc_reconnection?.active ? 'reconnecting…' : 'enabled'}
|
||||
</StatHelpText>
|
||||
)}
|
||||
<Box mt={2}>
|
||||
{plcConnected ? (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={handleDisconnectPlc}
|
||||
isLoading={actionLoading.disconnect}
|
||||
loadingText="Disconnecting..."
|
||||
>
|
||||
❌ Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handleConnectPlc}
|
||||
isLoading={actionLoading.connect}
|
||||
loadingText="Connecting..."
|
||||
>
|
||||
🔗 Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>📡 UDP Streaming</StatLabel>
|
||||
<StatNumber fontSize="lg" color={streaming ? 'green.500' : 'gray.500'}>
|
||||
{streaming ? 'Active' : 'Inactive'}
|
||||
</StatNumber>
|
||||
<Box mt={2}>
|
||||
{streaming ? (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={handleStopStreaming}
|
||||
isLoading={actionLoading.stopStream}
|
||||
loadingText="Stopping..."
|
||||
>
|
||||
⏹️ Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handleStartStreaming}
|
||||
isLoading={actionLoading.startStream}
|
||||
loadingText="Starting..."
|
||||
>
|
||||
▶️ Start
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>💾 CSV Recording</StatLabel>
|
||||
<StatNumber fontSize="lg" color={csvRecording ? 'green.500' : 'gray.500'}>
|
||||
{csvRecording ? 'Recording' : 'Inactive'}
|
||||
</StatNumber>
|
||||
{status?.disk_space_info && (
|
||||
<StatHelpText>
|
||||
💽 {status.disk_space_info.free_space} free<br/>
|
||||
⏱️ ~{status.disk_space_info.recording_time_left}
|
||||
</StatHelpText>
|
||||
)}
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
||||
|
||||
// PLC Configuration Panel - Fixed to PLC & UDP settings only
|
||||
function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving, message }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
if (!schemaData?.schema || !formData) {
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<Text>Loading PLC configuration...</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Box>
|
||||
<Heading size="md">🔧 PLC & UDP Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Configure PLC connection settings and UDP streaming parameters
|
||||
</Text>
|
||||
</Box>
|
||||
{message && (
|
||||
<Alert status="success" mt={2}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
schema={schemaData.schema}
|
||||
uiSchema={schemaData.uiSchema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData }) => onFormChange(formData)}
|
||||
onSubmit={({ formData }) => onSave(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
isLoading={saving}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
💾 Save Configuration
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Dataset Manager - Type 3 Form Pattern implementation
|
||||
function DatasetManager() {
|
||||
const [datasetsConfig, setDatasetsConfig] = useState(null)
|
||||
const [variablesConfig, setVariablesConfig] = useState(null)
|
||||
const [datasetsSchemaData, setDatasetsSchemaData] = useState(null)
|
||||
const [variablesSchemaData, setVariablesSchemaData] = useState(null)
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const toast = useToast()
|
||||
|
||||
const loadDatasetData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [datasetsData, variablesData, datasetsSchemaResponse, variablesSchemaResponse] = await Promise.all([
|
||||
api.readConfig('dataset-definitions'),
|
||||
api.readConfig('dataset-variables'),
|
||||
api.getSchema('dataset-definitions'),
|
||||
api.getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
setDatasetsConfig(datasetsData)
|
||||
setVariablesConfig(variablesData)
|
||||
setDatasetsSchemaData(datasetsSchemaResponse)
|
||||
setVariablesSchemaData(variablesSchemaResponse)
|
||||
|
||||
// Auto-select first dataset if none selected
|
||||
if (!selectedDatasetId && datasetsData?.datasets?.length > 0) {
|
||||
setSelectedDatasetId(datasetsData.datasets[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load dataset data',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('dataset-definitions', formData)
|
||||
toast({
|
||||
title: '✅ Dataset definitions saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setDatasetsConfig(formData)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save datasets',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const saveVariables = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('dataset-variables', formData)
|
||||
toast({
|
||||
title: '✅ Dataset variables saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setVariablesConfig(formData)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save variables',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get filtered variables for selected dataset
|
||||
const getSelectedDatasetVariables = () => {
|
||||
if (!variablesConfig?.variables || !selectedDatasetId) {
|
||||
return { variables: [] }
|
||||
}
|
||||
|
||||
const datasetVars = variablesConfig.variables.find(v => v.dataset_id === selectedDatasetId)
|
||||
return datasetVars || { variables: [] }
|
||||
}
|
||||
|
||||
// Update variables for selected dataset
|
||||
const updateSelectedDatasetVariables = (newVariableData) => {
|
||||
if (!variablesConfig?.variables || !selectedDatasetId) return
|
||||
|
||||
const updatedVariables = variablesConfig.variables.map(v =>
|
||||
v.dataset_id === selectedDatasetId
|
||||
? { ...v, ...newVariableData }
|
||||
: v
|
||||
)
|
||||
|
||||
// If dataset not found, add new entry
|
||||
if (!variablesConfig.variables.find(v => v.dataset_id === selectedDatasetId)) {
|
||||
updatedVariables.push({
|
||||
dataset_id: selectedDatasetId,
|
||||
...newVariableData
|
||||
})
|
||||
}
|
||||
|
||||
const updatedConfig = { ...variablesConfig, variables: updatedVariables }
|
||||
setVariablesConfig(updatedConfig)
|
||||
}
|
||||
|
||||
// Available datasets for combo selector
|
||||
const availableDatasets = datasetsConfig?.datasets || []
|
||||
|
||||
useEffect(() => {
|
||||
loadDatasetData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner mr={3} />
|
||||
<Text>Loading dataset configurations...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📊 Dataset Manager</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadDatasetData}>
|
||||
🔄 Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="blue">
|
||||
<TabList>
|
||||
<Tab>📋 Dataset Definitions</Tab>
|
||||
<Tab>⚙️ Dataset Variables</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel p={0} pt={4}>
|
||||
{datasetsSchemaData?.schema && datasetsConfig && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">Dataset Metadata Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Configure dataset names, prefixes, sampling intervals and enable/disable datasets
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
schema={datasetsSchemaData.schema}
|
||||
uiSchema={datasetsSchemaData.uiSchema}
|
||||
formData={datasetsConfig}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => saveDatasets(formData)}
|
||||
onChange={({ formData }) => setDatasetsConfig(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
💾 Save Definitions
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadDatasetData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Dataset Variables Configuration with Combo Selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">Dataset Variables Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Select a dataset, then configure its PLC variables and streaming settings
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* Step 1: Dataset Selector (Combo) */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
🎯 Select Dataset
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Choose a dataset to configure..."
|
||||
size="md"
|
||||
>
|
||||
{availableDatasets.map(dataset => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
📊 {dataset.name} ({dataset.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{availableDatasets.length === 0 && (
|
||||
<Text fontSize="sm" color="orange.500" mt={2}>
|
||||
⚠️ No datasets available. Configure datasets first in the "Dataset Definitions" tab.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Variables Configuration Form */}
|
||||
{selectedDatasetId && (
|
||||
<Box>
|
||||
<Divider mb={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
⚙️ Configure Variables for Dataset "{selectedDatasetId}"
|
||||
</Text>
|
||||
|
||||
{/* Simplified schema for selected dataset variables */}
|
||||
{(() => {
|
||||
const selectedDatasetVars = getSelectedDatasetVariables()
|
||||
|
||||
// Schema for this dataset's variables
|
||||
const singleDatasetSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
variables: {
|
||||
type: "array",
|
||||
title: "Variables",
|
||||
description: `PLC variables to record in dataset ${selectedDatasetId}`,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", title: "Variable Name" },
|
||||
area: {
|
||||
type: "string",
|
||||
title: "Memory Area",
|
||||
enum: ["db", "mw", "m", "pew", "pe", "paw", "pa", "e", "a", "mb"],
|
||||
default: "db"
|
||||
},
|
||||
db: { type: "integer", title: "DB Number", minimum: 1, maximum: 9999 },
|
||||
offset: { type: "integer", title: "Offset", minimum: 0, maximum: 8191 },
|
||||
bit: { type: "integer", title: "Bit Position", minimum: 0, maximum: 7 },
|
||||
type: {
|
||||
type: "string",
|
||||
title: "Data Type",
|
||||
enum: ["real", "int", "dint", "bool", "word", "byte"],
|
||||
default: "real"
|
||||
},
|
||||
streaming: { type: "boolean", title: "Stream to UDP", default: false }
|
||||
},
|
||||
required: ["name", "area", "offset", "type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const singleDatasetUiSchema = {
|
||||
variables: {
|
||||
items: {
|
||||
"ui:layout": [[
|
||||
{ "name": "name", "width": 3 },
|
||||
{ "name": "area", "width": 2 },
|
||||
{ "name": "db", "width": 1 },
|
||||
{ "name": "offset", "width": 2 },
|
||||
{ "name": "type", "width": 2 },
|
||||
{ "name": "streaming", "width": 2 }
|
||||
]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={singleDatasetSchema}
|
||||
uiSchema={singleDatasetUiSchema}
|
||||
formData={selectedDatasetVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
updateSelectedDatasetVariables(formData)
|
||||
saveVariables(variablesConfig)
|
||||
}}
|
||||
onChange={({ formData }) => updateSelectedDatasetVariables(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
💾 Save Variables for {selectedDatasetId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadDatasetData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!selectedDatasetId && availableDatasets.length > 0 && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">
|
||||
👆 Select a dataset above to configure its variables
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Events Display Component
|
||||
function EventsDisplay({ events, loading, onRefresh }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card bg={cardBg}>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={4}>
|
||||
<Spinner mr={3} />
|
||||
<Text>Loading events...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Flex align="center">
|
||||
<Heading size="md">📋 Recent Events</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={onRefresh}>
|
||||
🔄 Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Time</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Message</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{events?.map((event, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>
|
||||
<Code fontSize="xs">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</Code>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
colorScheme={
|
||||
event.level === 'ERROR' ? 'red' :
|
||||
event.level === 'WARNING' ? 'orange' :
|
||||
event.level === 'INFO' ? 'blue' : 'gray'
|
||||
}
|
||||
>
|
||||
{event.level}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{event.message}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{(!events || events.length === 0) && (
|
||||
<Text textAlign="center" py={4} color="gray.500">
|
||||
No events found
|
||||
</Text>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Dashboard Component - PLC S7-31x Streamer & Logger
|
||||
export default function Dashboard() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [statusLoading, setStatusLoading] = useState(true)
|
||||
const [statusError, setStatusError] = useState('')
|
||||
|
||||
const [schemaData, setSchemaData] = useState(null)
|
||||
const [formData, setFormData] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const [events, setEvents] = useState([])
|
||||
const [eventsLoading, setEventsLoading] = useState(false)
|
||||
|
||||
// Load status once
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
setStatusLoading(true)
|
||||
setStatusError('')
|
||||
const statusData = await api.getStatus()
|
||||
setStatus(statusData)
|
||||
} catch (error) {
|
||||
setStatusError(error.message)
|
||||
} finally {
|
||||
setStatusLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Real-time status updates via polling
|
||||
const subscribeSSE = useCallback(() => {
|
||||
// Use polling for real-time updates (every 5 seconds)
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusData = await api.getStatus()
|
||||
setStatus(statusData)
|
||||
setStatusError('')
|
||||
} catch (error) {
|
||||
console.error('Status polling error:', error)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load PLC config
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const [schemaResponse, configData] = await Promise.all([
|
||||
api.getSchema('plc'),
|
||||
api.readConfig('plc')
|
||||
])
|
||||
setSchemaData(schemaResponse)
|
||||
setFormData(configData)
|
||||
setMessage('')
|
||||
} catch (error) {
|
||||
console.error('Failed to load PLC config:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save config
|
||||
const saveConfig = useCallback(async (data) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
await api.writeConfig('plc', data)
|
||||
setMessage(`✅ PLC configuration saved successfully`)
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
setFormData(data)
|
||||
} catch (error) {
|
||||
setMessage(`❌ Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load events
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setEventsLoading(true)
|
||||
const eventsData = await api.getEvents(50)
|
||||
setEvents(eventsData.events || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load events:', error)
|
||||
} finally {
|
||||
setEventsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
loadConfig()
|
||||
loadEvents()
|
||||
|
||||
const cleanup = subscribeSSE()
|
||||
return cleanup
|
||||
}, [loadStatus, loadConfig, loadEvents, subscribeSSE])
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<Container maxW="container.xl" py={6}>
|
||||
<Flex align="center" justify="center" minH="200px">
|
||||
<Spinner size="xl" mr={4} />
|
||||
<Text fontSize="lg">Loading dashboard...</Text>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="container.xl" py={6}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Flex align="center" mb={4}>
|
||||
<Heading size="xl">🏭 PLC S7-31x Streamer & Logger</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadStatus}>
|
||||
🔄 Refresh Status
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{statusError && (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Failed to load status: {statusError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<StatusBar status={status} onRefresh={loadStatus} />
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="blue">
|
||||
<TabList>
|
||||
<Tab>🔧 Configuration</Tab>
|
||||
<Tab>📊 Datasets</Tab>
|
||||
<Tab>📈 Plotting</Tab>
|
||||
<Tab>📋 Events</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel p={0} pt={4}>
|
||||
<ConfigurationPanel
|
||||
schemaData={schemaData}
|
||||
formData={formData}
|
||||
onFormChange={setFormData}
|
||||
onSave={saveConfig}
|
||||
saving={saving}
|
||||
message={message}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
<DatasetManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
<PlotManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
<EventsDisplay
|
||||
events={events}
|
||||
loading={eventsLoading}
|
||||
onRefresh={loadEvents}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -604,8 +604,8 @@ function PlotSessionPanel({ session, onControl, onRemove, onEdit, onUpdateStatus
|
|||
}
|
||||
}
|
||||
|
||||
// Then handle backend control (no await to avoid UI lag)
|
||||
onControl(session.session_id, action)
|
||||
// Then handle backend control
|
||||
await onControl(session.session_id, action)
|
||||
}
|
||||
|
||||
const handleChartReady = (controls) => {
|
||||
|
|
|
@ -8,26 +8,8 @@ function toJsonOrThrow(res) {
|
|||
}
|
||||
|
||||
export async function getStatus() {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
} catch (err) {
|
||||
// Fallback: try health endpoint and synthesize a minimal status to keep UI usable
|
||||
try {
|
||||
const healthRes = await fetch(`${BASE_URL}/api/health`, { headers: { 'Accept': 'application/json' } })
|
||||
if (healthRes.ok) {
|
||||
const health = await healthRes.json()
|
||||
return {
|
||||
plc_connected: false,
|
||||
streaming: false,
|
||||
csv_recording: false,
|
||||
disk_space_info: null,
|
||||
health,
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
throw err
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getEvents(limit = 50) {
|
||||
|
@ -60,27 +42,17 @@ export async function stopUdpStreaming() {
|
|||
// Config schemas and data
|
||||
export async function listSchemas() {
|
||||
const res = await fetch(`${BASE_URL}/api/config/schemas`, { headers: { 'Accept': 'application/json' } })
|
||||
const response = await toJsonOrThrow(res)
|
||||
// The API returns { success: true, schemas: [...] }
|
||||
return response
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getSchema(schemaId) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/schema/${encodeURIComponent(schemaId)}`, { headers: { 'Accept': 'application/json' } })
|
||||
const response = await toJsonOrThrow(res)
|
||||
// The API returns { success: true, schema: {...}, ui_schema?: {...} }
|
||||
// Return both schema and uiSchema for RJSF
|
||||
return {
|
||||
schema: response.schema || response,
|
||||
uiSchema: response.ui_schema || {}
|
||||
}
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function readConfig(configId) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { headers: { 'Accept': 'application/json' } })
|
||||
const response = await toJsonOrThrow(res)
|
||||
// The API might return { success: true, data: {...} } or just the data
|
||||
return response.data || response
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function writeConfig(configId, data) {
|
||||
|
@ -116,92 +88,4 @@ export async function putJson(path, body) {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Datasets activation control
|
||||
export async function activateDataset(datasetId) {
|
||||
const res = await fetch(`${BASE_URL}/api/datasets/${encodeURIComponent(datasetId)}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function deactivateDataset(datasetId) {
|
||||
const res = await fetch(`${BASE_URL}/api/datasets/${encodeURIComponent(datasetId)}/deactivate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Plot management
|
||||
export async function getPlots() {
|
||||
const res = await fetch(`${BASE_URL}/api/plots`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function createPlot(plotConfig) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(plotConfig),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function deletePlot(sessionId) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function controlPlot(sessionId, action) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/${encodeURIComponent(sessionId)}/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getPlotData(sessionId) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/${encodeURIComponent(sessionId)}/data`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getPlotConfig(sessionId) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/${encodeURIComponent(sessionId)}/config`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function updatePlotConfig(sessionId, config) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/${encodeURIComponent(sessionId)}/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getPlotVariables() {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/variables`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Plot session status and control (aliases for existing functions)
|
||||
export async function getPlotSession(sessionId) {
|
||||
// Use existing getPlotConfig to get session info
|
||||
return await getPlotConfig(sessionId)
|
||||
}
|
||||
|
||||
export async function controlPlotSession(sessionId, action) {
|
||||
// Use existing controlPlot function
|
||||
return await controlPlot(sessionId, action)
|
||||
}
|
||||
|
||||
|
||||
|
|
150
main.py
150
main.py
|
@ -12,7 +12,6 @@ from datetime import datetime
|
|||
import os
|
||||
import sys
|
||||
from core import PLCDataStreamer
|
||||
from utils.json_manager import JSONManager, SchemaManager
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(
|
||||
|
@ -51,10 +50,8 @@ def project_path(*parts: str) -> str:
|
|||
return os.path.join(base_dir, *parts)
|
||||
|
||||
|
||||
# Global instances
|
||||
# Global streamer instance (will be initialized in main)
|
||||
streamer = None
|
||||
json_manager = JSONManager()
|
||||
schema_manager = SchemaManager()
|
||||
|
||||
|
||||
def check_streamer_initialized():
|
||||
|
@ -151,83 +148,76 @@ def serve_react_index(path: str = ""):
|
|||
|
||||
|
||||
# ==============================
|
||||
# Unified JSON Configuration API
|
||||
# Config Schemas & Editor API
|
||||
# ==============================
|
||||
|
||||
|
||||
@app.route("/api/config/schemas", methods=["GET"])
|
||||
def list_config_schemas():
|
||||
"""List all available configuration schemas."""
|
||||
"""Listar esquemas disponibles (plc, datasets, plots)."""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
schemas = schema_manager.list_available_schemas()
|
||||
return jsonify({"success": True, "schemas": schemas})
|
||||
info = streamer.schema_manager.list_schemas()
|
||||
return jsonify({"success": True, **info})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config/schema/<schema_id>", methods=["GET"])
|
||||
def get_config_schema(schema_id):
|
||||
"""Get a specific JSON schema with optional UI schema."""
|
||||
"""Obtener un esquema específico en formato JSON Schema."""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
# Get main schema
|
||||
schema = schema_manager.get_schema(schema_id)
|
||||
if not schema:
|
||||
return (
|
||||
jsonify({"success": False, "error": f"Schema '{schema_id}' not found"}),
|
||||
404,
|
||||
)
|
||||
|
||||
# Get optional UI schema
|
||||
ui_schema = schema_manager.get_ui_schema(schema_id)
|
||||
|
||||
response = {"success": True, "schema": schema}
|
||||
if ui_schema:
|
||||
response["ui_schema"] = ui_schema
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
schema = streamer.schema_manager.get_schema(schema_id)
|
||||
ui_schema = None
|
||||
# Try load optional UI schema
|
||||
try:
|
||||
ui_schema = streamer.schema_manager.get_ui_schema(schema_id)
|
||||
except Exception:
|
||||
ui_schema = None
|
||||
resp = {"success": True, "schema": schema}
|
||||
if ui_schema is not None:
|
||||
resp["ui_schema"] = ui_schema
|
||||
return jsonify(resp)
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config/<config_id>", methods=["GET"])
|
||||
def read_config(config_id):
|
||||
"""Read configuration data from JSON file."""
|
||||
"""Leer configuración actual (plc/datasets/plots)."""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = json_manager.read_json(config_id)
|
||||
data = streamer.schema_manager.read_config(config_id)
|
||||
return jsonify({"success": True, "data": data})
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
return jsonify({"success": False, "error": str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config/<config_id>", methods=["PUT"])
|
||||
def write_config(config_id):
|
||||
"""Write configuration data to JSON file."""
|
||||
"""Sobrescribir configuración a partir del cuerpo JSON."""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
payload = request.get_json(force=True, silent=False)
|
||||
if not payload:
|
||||
return jsonify({"success": False, "error": "No JSON data provided"}), 400
|
||||
|
||||
# Write the data
|
||||
json_manager.write_json(config_id, payload)
|
||||
|
||||
# Notify backend to reload if it's PLC config
|
||||
if config_id == "plc" and streamer:
|
||||
try:
|
||||
streamer.config_manager.load_configuration()
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the save operation
|
||||
print(f"Warning: Could not reload config in backend: {e}")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Configuration '{config_id}' saved successfully",
|
||||
}
|
||||
)
|
||||
|
||||
result = streamer.schema_manager.write_config(config_id, payload)
|
||||
return jsonify({"success": True, **result})
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
except Exception as e:
|
||||
|
@ -236,57 +226,25 @@ def write_config(config_id):
|
|||
|
||||
@app.route("/api/config/<config_id>/export", methods=["GET"])
|
||||
def export_config(config_id):
|
||||
"""Export configuration as downloadable JSON file."""
|
||||
try:
|
||||
data = json_manager.read_json(config_id)
|
||||
|
||||
# Prepare download response
|
||||
content = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
filename = f"{config_id}_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
|
||||
response = Response(content, mimetype="application/json")
|
||||
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config/<config_id>/reload", methods=["POST"])
|
||||
def reload_config(config_id):
|
||||
"""Notify backend to reload configuration from JSON files."""
|
||||
"""Exportar configuración como descarga JSON."""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
if config_id == "plc":
|
||||
streamer.config_manager.load_configuration()
|
||||
elif config_id in ["dataset-definitions", "dataset-variables"]:
|
||||
# Reload dataset configuration
|
||||
streamer.load_datasets()
|
||||
elif config_id in ["plot-definitions", "plot-variables"]:
|
||||
# Reload plot configuration if needed
|
||||
pass
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Configuration '{config_id}' reloaded successfully",
|
||||
}
|
||||
)
|
||||
|
||||
data = streamer.schema_manager.read_config(config_id)
|
||||
# Preparar respuesta con cabeceras de descarga
|
||||
content = json.dumps(data, indent=2)
|
||||
filename = f"{config_id}_export.json"
|
||||
resp = Response(content, mimetype="application/json")
|
||||
resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
|
||||
return resp
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================
|
||||
# Operational API (PLC Control, Streaming, etc.)
|
||||
# ==============================
|
||||
|
||||
|
||||
@app.route("/api/plc/config", methods=["POST"])
|
||||
def update_plc_config():
|
||||
"""Update PLC configuration"""
|
||||
|
@ -314,9 +272,8 @@ def update_udp_config():
|
|||
data = request.get_json()
|
||||
host = data.get("host", "127.0.0.1")
|
||||
port = int(data.get("port", 9870))
|
||||
sampling_interval = data.get("sampling_interval") # Optional, can be None
|
||||
|
||||
streamer.update_udp_config(host, port, sampling_interval)
|
||||
streamer.update_udp_config(host, port)
|
||||
return jsonify({"success": True, "message": "UDP configuration updated"})
|
||||
|
||||
except Exception as e:
|
||||
|
@ -1270,7 +1227,7 @@ def set_current_dataset():
|
|||
|
||||
if dataset_id and dataset_id in streamer.datasets:
|
||||
streamer.current_dataset_id = dataset_id
|
||||
# Note: No need to save - this is just changing current selection in memory
|
||||
streamer.save_datasets()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
|
@ -1542,7 +1499,6 @@ def create_plot():
|
|||
|
||||
# Crear configuración de la sesión
|
||||
config = {
|
||||
"id": data.get("id"), # Use the plot ID from the definition
|
||||
"name": data.get(
|
||||
"name", f"Plot {len(streamer.data_streamer.plot_manager.sessions) + 1}"
|
||||
),
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
# ==============================
|
||||
# LEGACY ROUTES TO REMOVE/COMMENT
|
||||
# ==============================
|
||||
|
||||
# Legacy templates route (replaced by React SPA)
|
||||
# @app.route("/legacy")
|
||||
# def serve_legacy_index():
|
||||
# """Serve legacy HTML template for backward compatibility."""
|
||||
# try:
|
||||
# return render_template("index.html")
|
||||
# except Exception as e:
|
||||
# return f"Error loading legacy template: {str(e)}", 500
|
||||
|
||||
# These routes can be removed after full migration to React:
|
||||
# All routes serving /templates/index.html
|
||||
# Static file serving for legacy JS/CSS
|
||||
# Any jQuery-based endpoints
|
||||
|
||||
# Essential APIs to keep:
|
||||
# - /api/status (SSE)
|
||||
# - /api/health
|
||||
# - /api/events
|
||||
# - /api/config/* (schemas and CRUD)
|
||||
# - /api/plc/connect, /api/plc/disconnect
|
||||
# - /api/udp/streaming/*
|
||||
# - /api/plots/* (for chart functionality)
|
||||
# - /api/datasets/* (if still needed)
|
||||
# - /api/variables/* (if still needed)
|
||||
|
||||
# React SPA routes to keep:
|
||||
# - / (React app)
|
||||
# - /app (React app)
|
||||
# - /app/<path:path> (React app routing)
|
||||
# - /assets/* (Vite build assets)
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -4,10 +4,9 @@
|
|||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"Fast",
|
||||
"DAR",
|
||||
"Test"
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-14T11:34:34.761474"
|
||||
"last_update": "2025-08-13T00:09:19.091418"
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
"""
|
||||
Unified JSON handling utilities for the application.
|
||||
Simple CRUD operations for configuration files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
|
||||
class JSONManager:
|
||||
"""Simplified JSON file manager for configuration data."""
|
||||
|
||||
def __init__(self, base_path: str = "config/data"):
|
||||
self.base_path = base_path
|
||||
self.config_files = {
|
||||
"plc": "plc_config.json",
|
||||
"dataset-definitions": "dataset_definitions.json",
|
||||
"dataset-variables": "dataset_variables.json",
|
||||
"plot-definitions": "plot_definitions.json",
|
||||
"plot-variables": "plot_variables.json",
|
||||
}
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs(self.base_path, exist_ok=True)
|
||||
|
||||
def get_file_path(self, config_id: str) -> str:
|
||||
"""Get full file path for a config ID."""
|
||||
if config_id not in self.config_files:
|
||||
raise ValueError(f"Unknown config ID: {config_id}")
|
||||
return os.path.join(self.base_path, self.config_files[config_id])
|
||||
|
||||
def read_json(self, config_id: str) -> Dict[str, Any]:
|
||||
"""Read JSON data from file."""
|
||||
file_path = self.get_file_path(config_id)
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return self._get_default_data(config_id)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in {file_path}: {str(e)}")
|
||||
|
||||
def write_json(self, config_id: str, data: Dict[str, Any]) -> None:
|
||||
"""Write JSON data to file."""
|
||||
file_path = self.get_file_path(config_id)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def _get_default_data(self, config_id: str) -> Dict[str, Any]:
|
||||
"""Get default data structure for each config type."""
|
||||
defaults = {
|
||||
"plc": {
|
||||
"csv_config": {
|
||||
"max_days": 30,
|
||||
"max_size_mb": 1000,
|
||||
"records_directory": "records",
|
||||
"rotation_enabled": True,
|
||||
},
|
||||
"plc_config": {"ip": "192.168.1.100", "rack": 0, "slot": 2},
|
||||
"udp_config": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9870,
|
||||
"sampling_interval": 1.0,
|
||||
},
|
||||
},
|
||||
"dataset-definitions": {"datasets": []},
|
||||
"dataset-variables": {"dataset_variables": []},
|
||||
"plot-definitions": {"plots": []},
|
||||
"plot-variables": {"plot_variables": []},
|
||||
}
|
||||
return defaults.get(config_id, {})
|
||||
|
||||
def list_available_configs(self) -> List[str]:
|
||||
"""List all available config IDs."""
|
||||
return list(self.config_files.keys())
|
||||
|
||||
def file_exists(self, config_id: str) -> bool:
|
||||
"""Check if config file exists."""
|
||||
try:
|
||||
file_path = self.get_file_path(config_id)
|
||||
return os.path.exists(file_path)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class SchemaManager:
|
||||
"""Simple schema file manager."""
|
||||
|
||||
def __init__(self, schema_path: str = "config/schema"):
|
||||
self.schema_path = schema_path
|
||||
self.ui_schema_path = os.path.join(schema_path, "ui")
|
||||
|
||||
def get_schema(self, schema_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get JSON schema by ID."""
|
||||
schema_file = os.path.join(self.schema_path, f"{schema_id}.schema.json")
|
||||
|
||||
try:
|
||||
with open(schema_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def get_ui_schema(self, schema_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get UI schema by ID."""
|
||||
ui_schema_file = os.path.join(self.ui_schema_path, f"{schema_id}.uischema.json")
|
||||
|
||||
try:
|
||||
with open(ui_schema_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
def list_available_schemas(self) -> List[Dict[str, str]]:
|
||||
"""List all available schemas."""
|
||||
schemas = []
|
||||
|
||||
if not os.path.exists(self.schema_path):
|
||||
return schemas
|
||||
|
||||
for filename in os.listdir(self.schema_path):
|
||||
if filename.endswith(".schema.json"):
|
||||
schema_id = filename.replace(".schema.json", "")
|
||||
|
||||
# Try to get title from schema
|
||||
schema = self.get_schema(schema_id)
|
||||
title = (
|
||||
schema.get("title", schema_id.title())
|
||||
if schema
|
||||
else schema_id.title()
|
||||
)
|
||||
description = schema.get("description", "") if schema else ""
|
||||
|
||||
schemas.append(
|
||||
{"id": schema_id, "title": title, "description": description}
|
||||
)
|
||||
|
||||
return sorted(schemas, key=lambda x: x["id"])
|
|
@ -1,41 +0,0 @@
|
|||
import json
|
||||
import jsonschema
|
||||
|
||||
# Cargar esquema y datos para dataset-definitions
|
||||
with open("config/schema/dataset-definitions.schema.json", "r") as f:
|
||||
schema = json.load(f)
|
||||
|
||||
with open("config/data/dataset_definitions.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Validar
|
||||
try:
|
||||
jsonschema.validate(data, schema)
|
||||
print("✅ Dataset definitions validation successful!")
|
||||
except jsonschema.ValidationError as e:
|
||||
print("❌ Dataset definitions validation error:")
|
||||
print(f'Property path: {".".join(str(x) for x in e.absolute_path)}')
|
||||
print(f"Message: {e.message}")
|
||||
print(f"Failed value: {e.instance}")
|
||||
except Exception as e:
|
||||
print(f"❌ Other error: {e}")
|
||||
|
||||
print("\n" + "=" * 50 + "\n")
|
||||
|
||||
# También validar PLC config
|
||||
with open("config/schema/plc.schema.json", "r") as f:
|
||||
plc_schema = json.load(f)
|
||||
|
||||
with open("config/data/plc_config.json", "r") as f:
|
||||
plc_data = json.load(f)
|
||||
|
||||
try:
|
||||
jsonschema.validate(plc_data, plc_schema)
|
||||
print("✅ PLC config validation successful!")
|
||||
except jsonschema.ValidationError as e:
|
||||
print("❌ PLC config validation error:")
|
||||
print(f'Property path: {".".join(str(x) for x in e.absolute_path)}')
|
||||
print(f"Message: {e.message}")
|
||||
print(f"Failed value: {e.instance}")
|
||||
except Exception as e:
|
||||
print(f"❌ Other error: {e}")
|
Loading…
Reference in New Issue