Compare commits

..

13 Commits

Author SHA1 Message Date
Miguel 09263d39f8 Update application event logging, refine plot session management, and enhance configuration handling. Removed obsolete session_id fields, added new application start events, and improved session creation logic for better user experience. 2025-08-14 11:36:22 +02:00
Miguel 9618984a2b Update application events and system state. Removed obsolete events, added new application start and dataset activation events, and updated last update timestamp in system state. 2025-08-14 11:27:50 +02:00
Miguel 2845d71efe Refactor dataset and plot management to support new array format
- Updated dataset definitions to use a sampling interval of 0.5 seconds.
- Changed plot definitions to reduce the time window to 25 seconds and added a new plot for "Brix".
- Removed deprecated variable configurations from plot variables.
- Refactored ConfigManager to load datasets and variables from new array format, eliminating legacy save methods.
- Updated PLCDataStreamer and PlotManager to reflect changes in dataset and plot management, removing automatic save calls.
- Enhanced ChartjsPlot component to handle variable configurations and session management more efficiently.
- Improved PlotRealtimeSession to ensure backend commands are verified before applying local state changes.
- Adjusted system state to reflect active datasets and connection status.
2025-08-14 11:16:52 +02:00
Miguel 748e8d5b0e Update application event logging, refine PLC configuration, and enhance PlotManager functionality. Added multiple application start events, corrected PLC rack configuration, and introduced PlotRealtimeSession for improved real-time plotting capabilities. 2025-08-14 00:06:43 +02:00
Miguel bb275dd279 Update application event logging and dataset variable configurations. Removed obsolete PLC connection event, added new application start event, and refined variable definitions for clarity and consistency. 2025-08-13 23:42:34 +02:00
Miguel 2ed5acf039 Refactor configuration management and schema handling
- Updated JSON schema for plot variables to improve clarity and structure.
- Modified UI schema for dataset variables to enhance user experience and layout.
- Revamped plot definitions UI schema for better organization and usability.
- Enhanced dataset manager to support filtered variable configuration based on selected datasets.
- Implemented a unified JSON manager for streamlined CRUD operations on configuration files.
- Improved error handling and validation for JSON schema loading and configuration management.
- Updated main application logic to utilize new JSON and schema managers for configuration operations.
- Added validation for dataset definitions and PLC configuration schemas.
2025-08-13 23:31:47 +02:00
Miguel 276efb117d Refactor configuration schemas and UI schemas for datasets and plots
- Changed dataset_variables schema from object to array format, adding dataset_id property.
- Updated plc schema by removing cleanup_interval_hours and max_hours properties, and added sampling_interval to udp_config.
- Modified plot_variables schema from object to array format, adding plot_id property.
- Enhanced UI schemas for dataset definitions and variables, improving layout and descriptions.
- Updated PLC configuration UI schema to reflect changes in the underlying schema.
- Improved error handling and logging in the ConfigManager for better diagnostics.
- Added helper methods in PLCClient for reconnection status and connection info.
- Unified API endpoints for configuration management, allowing direct file access and validation against schemas.
- Introduced a validation script for schema compliance checks.
2025-08-13 22:12:11 +02:00
Miguel e115bd7d55 Enhance UI schema layout support across components. Updated PlotManager and DashboardNew to utilize a comprehensive widget collection and layout templates. Created AllWidgets for improved widget management and added demo schemas showcasing new layout capabilities. 2025-08-13 16:29:38 +02:00
Miguel 8656b4a6a6 Update application event logging and configuration schemas. Added multiple entries for application start events in application_events.json, updated last_updated and total_entries fields. Adjusted sampling_interval in plc_config.json and removed schema references in dataset and plot schema files for consistency. 2025-08-13 16:18:46 +02:00
Miguel e6ccb19fd2 Enhance event logging in application_events.json and update last_updated and total_entries fields. Refactor PlotManager and Dashboard components to utilize schemaData and uiSchema, improving configuration management. Introduce custom widgets for RJSF, including a VariableSelectorWidget for dynamic variable selection. 2025-08-13 16:09:43 +02:00
Miguel 4af442e3e8 Primera version RJSF limpia 2025-08-13 15:45:29 +02:00
Miguel 972a965335 Update system state configuration, add cursor ignore file, and implement PlotRealtimeViewer component for real-time plot management 2025-08-13 15:15:25 +02:00
Miguel 771cf6cba6 Implementación de la gestión de configuración en ChartjsPlot, permitiendo la carga de configuraciones desde la sesión o desde el backend. Se añadió un nuevo estado para manejar la configuración resuelta y se optimizó la inicialización del gráfico. Además, se mejoró el manejo de errores durante la carga de la configuración, asegurando una experiencia más robusta y fluida para el usuario. 2025-08-13 00:24:58 +02:00
52 changed files with 6515 additions and 56126 deletions

3
.cursorignore Normal file
View File

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

View File

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

236
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,236 @@
# 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

View File

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

View File

@ -0,0 +1,129 @@
# 🏭 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: ✅

View File

@ -0,0 +1,115 @@
# 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

View File

@ -1,18 +1,27 @@
{
"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
}
"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
}
]
}

View File

@ -1,52 +1,37 @@
{
"dataset_variables": {
"DAR": {
"variables": {
"UR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"streaming": true,
"type": "real"
},
"UR29_ma": {
"area": "db",
"db": 1011,
"offset": 1296,
"streaming": true,
"type": "real"
},
"fUR29_Brix": {
"area": "db",
"db": 1011,
"offset": 1322,
"streaming": false,
"type": "real"
}
},
"streaming_variables": [
"UR29_Brix",
"UR29_ma"
]
"variables": [
{
"dataset_id": "DAR",
"variables": [
{
"area": "db",
"db": 1011,
"name": "UR29_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": []
{
"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"
}
]
},
{
"dataset_id": "Fast",
"variables": []
}
]
}

View File

@ -1,21 +1,18 @@
{
"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"
}
"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
}
}

View File

@ -1,14 +1,21 @@
{
"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"
}
"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
}
]
}

View File

@ -1,28 +1,19 @@
{
"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
}
}
"variables": [
{
"plot_id": "plot_1",
"variables": [
{
"color": "#3498db",
"enabled": true,
"variable_name": "UR29_Brix"
},
{
"color": "#e74c3c",
"enabled": true,
"variable_name": "UR29_ma"
}
]
}
]
}

View File

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

View File

@ -1,24 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "dataset-variables.schema.json",
"title": "Dataset Variables",
"description": "Schema for variables assigned to each dataset",
"type": "object",
"additionalProperties": false,
"properties": {
"dataset_variables": {
"type": "object",
"title": "Variables by Dataset",
"description": "Variables organized by dataset ID",
"additionalProperties": {
"variables": {
"type": "array",
"title": "Dataset Variables Collection",
"description": "Array of dataset variable configurations",
"items": {
"type": "object",
"properties": {
"dataset_id": {
"type": "string",
"title": "Dataset ID",
"description": "Unique identifier for the dataset"
},
"variables": {
"type": "object",
"title": "Dataset Variables",
"additionalProperties": {
"type": "array",
"title": "Variables",
"description": "Array of PLC variables for this dataset",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Variable Name",
"description": "Human-readable name for the variable"
},
"area": {
"type": "string",
"title": "Memory Area",
@ -78,33 +89,27 @@
"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": [
"variables",
"streaming_variables"
"dataset_id",
"variables"
]
}
}
},
"required": [
"dataset_variables"
"variables"
]
}

View File

@ -0,0 +1,63 @@
{
"$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
}

View File

@ -1,6 +1,5 @@
{
"$id": "plc.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"dependencies": {},
"description": "Schema to edit plc_config.json",
@ -9,31 +8,12 @@
"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,
@ -59,7 +39,6 @@
}
},
"required": [
"cleanup_interval_hours",
"records_directory",
"rotation_enabled"
],
@ -77,18 +56,11 @@
"type": "string"
},
"rack": {
"default": 0,
"description": "Rack of PLC",
"maximum": 7,
"minimum": 0,
"title": "Rack",
"type": "integer"
"type": "integer",
"minimum": 0
},
"slot": {
"default": 2,
"description": "Normally 2",
"maximum": 31,
"minimum": 0,
"title": "Slot",
"type": "integer"
}
@ -101,14 +73,6 @@
"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": {},
@ -124,21 +88,30 @@
"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"
"port",
"sampling_interval"
],
"title": "UDP Configuration",
"title": "UDP Streaming Configuration",
"type": "object"
}
},
"required": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
],
"title": "PLC & UDP Configuration",

View File

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

View File

@ -1,29 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "plot-variables.schema.json",
"title": "Plot Variables",
"description": "Schema for variables assigned to each plot session",
"type": "object",
"additionalProperties": false,
"properties": {
"plot_variables": {
"type": "object",
"title": "Variables by Plot",
"description": "Variables organized by plot ID",
"additionalProperties": {
"variables": {
"type": "array",
"title": "Plot Variables Collection",
"description": "Array of plot variable configurations",
"items": {
"type": "object",
"properties": {
"plot_id": {
"type": "string",
"title": "Plot ID",
"description": "Unique identifier for the plot session"
},
"variables": {
"type": "object",
"title": "Plot Variables",
"description": "Variables configuration for plotting with colors",
"additionalProperties": {
"type": "array",
"title": "Variables",
"description": "Array of variables for this plot with visualization settings",
"items": {
"type": "object",
"properties": {
"variable_name": {
"type": "string",
"title": "Variable Name",
"description": "Select a variable from available dataset variables"
"description": "Name of the variable to plot (must exist in dataset variables)"
},
"color": {
"type": "string",
@ -41,19 +46,19 @@
},
"required": [
"variable_name",
"color",
"enabled"
"color"
]
}
}
},
"required": [
"plot_id",
"variables"
]
}
}
},
"required": [
"plot_variables"
"variables"
]
}

View File

@ -1,14 +1,25 @@
{
"ui:order": ["datasets"],
"datasets": {
"additionalProperties": {
"created": {
"ui:help": "Timestamp when this dataset was created",
"ui:readonly": true,
"ui:widget": "text"
},
"enabled": {
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
"ui:widget": "checkbox"
"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"
},
"name": {
"ui:help": "Human-readable name for this dataset",
@ -18,60 +29,21 @@
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
"ui:placeholder": "e.g., temp, line_a, sensors"
},
"sampling_interval": {
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval.",
"ui:placeholder": "Leave empty to use global interval",
"ui:widget": "updown"
"enabled": {
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
"ui:widget": "checkbox"
},
"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
"sampling_interval": {
"ui:help": "Custom sampling interval in seconds (0.0110). 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 }
},
"created": {
"ui:help": "Timestamp when this dataset was created",
"ui:readonly": true,
"ui:widget": "text"
}
}
},
"ui:order": [
"datasets"
]
}
}
}

View File

@ -1,16 +1,21 @@
{
"dataset_variables": {
"variables": {
"ui:description": "⚙️ Configure PLC variables for each dataset - specify memory areas, data types, and streaming options",
"ui:options": {
"addable": true,
"orderable": false,
"orderable": true,
"removable": true
},
"additionalProperties": {
"items": {
"ui:order": [
"variables",
"streaming_variables"
"dataset_id",
"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",
@ -19,8 +24,9 @@
"orderable": true,
"removable": true
},
"additionalProperties": {
"items": {
"ui:order": [
"name",
"area",
"db",
"offset",
@ -30,9 +36,13 @@
],
"ui:layout": [
[
{
"name": "name",
"width": 4
},
{
"name": "area",
"width": 3
"width": 2
},
{
"name": "db",
@ -40,10 +50,6 @@
},
{
"name": "offset",
"width": 3
},
{
"name": "bit",
"width": 2
},
{
@ -52,12 +58,21 @@
}
],
[
{
"name": "bit",
"width": 3
},
{
"name": "streaming",
"width": 12
"width": 9
}
]
],
"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.)",
@ -172,15 +187,10 @@
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
}
}
},
"streaming_variables": {
"ui:widget": "checkboxes",
"ui:description": "📡 Streaming Variables",
"ui:help": "Variables that are streamed in real-time to PlotJuggler. This list is automatically updated when you enable/disable streaming on individual variables above."
}
}
},
"ui:order": [
"dataset_variables"
"variables"
]
}

View File

@ -0,0 +1,78 @@
{
"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"
}
}

View File

@ -1,81 +1,62 @@
{
"csv_config": {
"cleanup_interval_hours": {
"ui:widget": "updown"
},
"last_cleanup": {},
"max_days": {
"ui:widget": "updown"
},
"max_hours": {
"ui:column": 3,
"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:layout": [
[
{
"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": 2
}
],
[
{
"name": "records_directory",
"width": 10
},
{
"name": "rotation_enabled",
"width": 2
}
]
],
"ui:order": [
"cleanup_interval_hours",
"last_cleanup",
"max_days",
"max_hours",
"max_size_mb",
"records_directory",
"rotation_enabled"
],
"ui:layout": [
[
{
"name": "max_days",
"width": 3
},
{
"name": "max_size_mb",
"width": 3
},
{
"name": "rotation_enabled",
"width": 3
},
{
"name": "records_directory",
"width": 3
}
]
]
},
"plc_config": {
"ip": {
"ui:placeholder": "192.168.1.100",
"ui:column": 6
"ui:column": 6,
"ui:placeholder": "192.168.1.100"
},
"rack": {
"ui:widget": "updown",
"ui:column": 3
"ui:column": 3,
"ui:widget": "updown"
},
"slot": {
"ui:widget": "updown",
"ui:column": 3
"ui:column": 3,
"ui:widget": "updown"
},
"ui:column": 12,
"ui:layout": [
[
{
@ -98,59 +79,66 @@
"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": 6
"width": 4
},
{
"name": "port",
"width": 6
"width": 4
},
{
"name": "sampling_interval",
"width": 4
}
]
],
"ui:order": [
"host",
"port"
"port",
"sampling_interval"
]
},
"ui:order": [
"csv_config",
"plc_config",
"udp_config"
],
"ui:layout": [
[
{
"name": "plc_config",
"width": 6
},
"width": 12
}
],
[
{
"name": "udp_config",
"width": 6
"width": 12
}
],
[
{
"name": "csv_config",
"width": 10
},
{
"name": "sampling_interval",
"width": 2
"width": 12
}
]
],
"ui:order": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
]
}

View File

@ -1,77 +1,104 @@
{
"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"
},
"trigger_enabled": {
"ui:widget": "checkbox"
},
"trigger_on_true": {
"ui:widget": "checkbox"
},
"y_max": {
"ui:widget": "updown"
"ui:widget": "updown",
"ui:help": "⏱️ Time window in seconds (5-3600)"
},
"y_min": {
"ui:widget": "updown"
"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)"
},
"trigger_enabled": {
"ui:widget": "checkbox",
"ui:help": "✅ Enable trigger-based recording"
},
"trigger_on_true": {
"ui:widget": "checkbox",
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
}
},
"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"
]
}

View File

@ -1,15 +1,21 @@
{
"plot_variables": {
"variables": {
"ui:description": "📊 Configure plot variables with colors and settings for real-time visualization",
"ui:options": {
"addable": true,
"orderable": false,
"orderable": true,
"removable": true
},
"additionalProperties": {
"items": {
"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",
@ -18,38 +24,32 @@
"orderable": true,
"removable": true
},
"additionalProperties": {
"items": {
"ui:order": [
"variable_name",
"enabled",
"color"
"color",
"enabled"
],
"ui:layout": [
[
{
"name": "variable_name",
"width": 12
}
],
[
{
"name": "enabled",
"width": 6
},
{
"name": "color",
"width": 6
"width": 3
},
{
"name": "enabled",
"width": 3
}
]
],
"variable_name": {
"ui:widget": "VariableSelectorWidget",
"ui:help": "🔍 Select a variable from the available dataset variables",
"ui:description": "Choose from existing PLC variables defined in your datasets"
},
"enabled": {
"ui:widget": "checkbox",
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
"ui:widget": "text",
"ui:placeholder": "UR29_Brix",
"ui:help": "<22> Name of the variable to plot (must exist in dataset variables)"
},
"color": {
"ui:widget": "color",
@ -71,12 +71,16 @@
"#16a085"
]
}
},
"enabled": {
"ui:widget": "checkbox",
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
}
}
}
}
},
"ui:order": [
"plot_variables"
"variables"
]
}

View File

@ -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}
self.sampling_interval = 0.1
self.udp_config = {"host": "127.0.0.1", "port": 9870, "sampling_interval": 1.0}
self.sampling_interval = 0.1 # Legacy fallback
# CSV recording configuration
self.csv_config = {
@ -50,9 +50,6 @@ 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
@ -88,10 +85,27 @@ class ConfigManager:
with open(self.config_file, "r") as f:
config = json.load(f)
self.plc_config = config.get("plc_config", self.plc_config)
self.udp_config = config.get("udp_config", self.udp_config)
self.sampling_interval = config.get(
# 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(
"sampling_interval", self.sampling_interval
)
self.csv_config = {
**self.csv_config,
**config.get("csv_config", {}),
@ -109,10 +123,13 @@ 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:
@ -156,40 +173,81 @@ class ConfigManager:
self.logger.error(f"Error loading datasets: {e}")
def _load_datasets_separated(self):
"""Load datasets from separated definition and variable files"""
"""Load datasets from separated definition and variable files (new array format)"""
try:
# Load definitions
# Load definitions (new array format: {"datasets": [array]})
with open(self.dataset_definitions_file, "r") as f:
definitions_data = json.load(f)
# Load variables
# Load variables (new array format: {"variables": [array]})
with open(self.dataset_variables_file, "r") as f:
variables_data = json.load(f)
# Merge data back to legacy format for compatibility
# Convert new array format to internal dictionary format for compatibility
self.datasets = {}
dataset_defs = definitions_data.get("datasets", {})
dataset_vars = variables_data.get("dataset_variables", {})
for dataset_id, definition in dataset_defs.items():
variables_info = dataset_vars.get(dataset_id, {})
self.datasets[dataset_id] = {
**definition,
"variables": variables_info.get("variables", {}),
"streaming_variables": variables_info.get(
"streaming_variables", []
),
}
# 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
# Calculate active_datasets automatically from enabled field
self.active_datasets = set()
for dataset_id, definition in dataset_defs.items():
for dataset_id, definition in self.datasets.items():
if definition.get("enabled", False):
self.active_datasets.add(dataset_id)
# current_dataset_id is optional for UI, use first available if not set
self.current_dataset_id = definitions_data.get("current_dataset_id")
if not self.current_dataset_id and self.datasets:
self.current_dataset_id = None
if self.datasets:
self.current_dataset_id = next(iter(self.datasets.keys()))
if self.logger:
@ -213,8 +271,7 @@ class ConfigManager:
self.active_datasets = set(legacy_data.get("active_datasets", []))
self.current_dataset_id = legacy_data.get("current_dataset_id")
# Save to new separated format
self.save_datasets()
# Note: Migration complete - data now managed by frontend via RJSF
if self.logger:
self.logger.info(
@ -227,53 +284,9 @@ class ConfigManager:
self.logger.error(f"Error migrating legacy datasets: {e}")
raise
def save_datasets(self):
"""Save datasets configuration to separated JSON files"""
try:
# timestamp removed as we don't save static fields anymore
# Prepare definitions data - only datasets, no static fields
definitions_data = {
"datasets": {},
}
# Prepare variables data - only variables, no static fields
variables_data = {
"dataset_variables": {},
}
# Split datasets into definitions and variables
for dataset_id, dataset_info in self.datasets.items():
# Extract definition (metadata only)
definition = {
key: value
for key, value in dataset_info.items()
if key not in ["variables", "streaming_variables"]
}
definitions_data["datasets"][dataset_id] = definition
# Extract variables
variables_data["dataset_variables"][dataset_id] = {
"variables": dataset_info.get("variables", {}),
"streaming_variables": dataset_info.get("streaming_variables", []),
}
# Save both files
with open(self.dataset_definitions_file, "w") as f:
json.dump(definitions_data, f, indent=4)
with open(self.dataset_variables_file, "w") as f:
json.dump(variables_data, f, indent=4)
if self.logger:
self.logger.info(
f"Datasets configuration saved to separated files: "
f"{self.dataset_definitions_file} and {self.dataset_variables_file}"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error saving datasets: {e}")
# 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 sync_streaming_variables(self):
"""Synchronize streaming variables configuration"""
@ -306,9 +319,12 @@ class ConfigManager:
)
if sync_needed:
self.save_datasets()
# Note: Configuration is now managed by frontend via RJSF
# No automatic save needed - frontend will save when user makes changes
if self.logger:
self.logger.info("Streaming variables configuration synchronized")
self.logger.info(
"Streaming variables configuration synchronized in memory"
)
except Exception as e:
if self.logger:
@ -358,6 +374,33 @@ 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"""
@ -366,17 +409,37 @@ class ConfigManager:
self.save_configuration()
return {"old_config": old_config, "new_config": self.plc_config}
def update_udp_config(self, host: str, port: int):
"""Update UDP configuration"""
def update_udp_config(self, host: str, port: int, sampling_interval: float = None):
"""Update UDP configuration including sampling interval"""
old_config = self.udp_config.copy()
self.udp_config = {"host": host, "port": port}
# 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.save_configuration()
return {"old_config": old_config, "new_config": self.udp_config}
def update_sampling_interval(self, interval: float):
"""Update sampling interval"""
old_interval = self.sampling_interval
"""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
self.sampling_interval = interval
self.save_configuration()
return {"old_interval": old_interval, "new_interval": interval}
@ -391,8 +454,6 @@ class ConfigManager:
"rotation_enabled",
"max_size_mb",
"max_days",
"max_hours",
"cleanup_interval_hours",
}
for key, value in kwargs.items():
@ -402,16 +463,11 @@ 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", "cleanup_interval_hours"]:
elif key in ["max_size_mb", "max_days"]:
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
@ -429,27 +485,12 @@ 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 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
"""Check if cleanup should be performed"""
return self.csv_config["rotation_enabled"]
def mark_cleanup_performed(self):
"""Mark that cleanup was performed"""
self.csv_config["last_cleanup"] = datetime.now().isoformat()
self.save_configuration()
"""Mark that cleanup was performed (simplified - no persistent tracking)"""
pass
# Dataset Management Methods
def create_dataset(
@ -475,7 +516,7 @@ class ConfigManager:
if not self.current_dataset_id:
self.current_dataset_id = dataset_id
self.save_datasets()
# Note: Dataset changes now saved via frontend RJSF
return new_dataset
def delete_dataset(self, dataset_id: str):
@ -495,7 +536,7 @@ class ConfigManager:
next(iter(self.datasets.keys())) if self.datasets else None
)
self.save_datasets()
# Note: Dataset deletion now saved via frontend RJSF
return dataset_info
def get_current_dataset(self):
@ -511,15 +552,14 @@ class ConfigManager:
return {}
def get_dataset_sampling_interval(self, dataset_id: str):
"""Get sampling interval for a dataset (falls back to global if not set)"""
"""Get sampling interval for a dataset (falls back to UDP config sampling interval if not set)"""
if dataset_id in self.datasets:
dataset_interval = self.datasets[dataset_id].get("sampling_interval")
return (
dataset_interval
if dataset_interval is not None
else self.sampling_interval
)
return self.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)
def add_variable_to_dataset(
self,
@ -577,7 +617,7 @@ class ConfigManager:
if name not in self.datasets[dataset_id]["streaming_variables"]:
self.datasets[dataset_id]["streaming_variables"].append(name)
self.save_datasets()
# Note: Variable addition now saved via frontend RJSF
return var_config
def remove_variable_from_dataset(self, dataset_id: str, name: str):
@ -595,7 +635,7 @@ class ConfigManager:
if name in self.datasets[dataset_id]["streaming_variables"]:
self.datasets[dataset_id]["streaming_variables"].remove(name)
self.save_datasets()
# Note: Variable removal now saved via frontend RJSF
return var_config
def toggle_variable_streaming(self, dataset_id: str, name: str, enabled: bool):
@ -617,7 +657,7 @@ class ConfigManager:
if name in self.datasets[dataset_id]["streaming_variables"]:
self.datasets[dataset_id]["streaming_variables"].remove(name)
self.save_datasets()
# Note: Streaming toggle now saved via frontend RJSF
def activate_dataset(self, dataset_id: str):
"""Mark a dataset as active"""
@ -626,7 +666,7 @@ class ConfigManager:
self.datasets[dataset_id]["enabled"] = True
self._update_active_datasets()
self.save_datasets()
# Note: Dataset activation now saved via frontend RJSF
def deactivate_dataset(self, dataset_id: str):
"""Mark a dataset as inactive"""
@ -635,7 +675,7 @@ class ConfigManager:
self.datasets[dataset_id]["enabled"] = False
self._update_active_datasets()
self.save_datasets()
# Note: Dataset deactivation now saved via frontend RJSF
def _update_active_datasets(self):
"""Update active_datasets based on enabled field of each dataset"""
@ -647,14 +687,15 @@ class ConfigManager:
def get_status(self):
"""Get configuration status"""
total_variables = sum(
len(dataset["variables"]) for dataset in self.datasets.values()
len(self.get_dataset_variables(dataset_id))
for dataset_id in self.datasets.keys()
)
# Count only variables that are in streaming_variables list AND have streaming=true
total_streaming_vars = 0
for dataset in self.datasets.values():
for dataset_id, dataset in self.datasets.items():
streaming_vars = dataset.get("streaming_variables", [])
variables_config = dataset.get("variables", {})
variables_config = self.get_dataset_variables(dataset_id)
active_streaming_vars = [
var
for var in streaming_vars
@ -676,12 +717,12 @@ class ConfigManager:
dataset_id: {
"name": info["name"],
"prefix": info["prefix"],
"variables_count": len(info["variables"]),
"variables_count": len(self.get_dataset_variables(dataset_id)),
"streaming_count": len(
[
var
for var in info.get("streaming_variables", [])
if info.get("variables", {})
if self.get_dataset_variables(dataset_id)
.get(var, {})
.get("streaming", False)
]

View File

@ -44,6 +44,7 @@ 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()
@ -133,6 +134,7 @@ 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}...")
@ -173,6 +175,31 @@ 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:

View File

@ -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(dataset["variables"])
for dataset in self.config_manager.datasets.values()
len(self.config_manager.get_dataset_variables(dataset_id))
for dataset_id in self.config_manager.datasets.keys()
),
"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
self.config_manager.save_datasets()
# Note: Dataset changes now saved via frontend RJSF
@property
def connected(self):
@ -598,6 +598,6 @@ class PLCDataStreamer:
"""Get streaming status (backward compatibility)"""
return self.data_streamer.is_streaming()
def save_datasets(self):
"""Save datasets (backward compatibility)"""
self.config_manager.save_datasets()
# 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

View File

@ -284,8 +284,10 @@ class PlotManager:
def create_session(self, config: Dict[str, Any]) -> str:
"""Crear una nueva sesión de plotting"""
with self.lock:
session_id = f"plot_{self.session_counter}"
self.session_counter += 1
# 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 = PlotSession(session_id, config)
# 🔑 CAMBIO: Crear sesiones en modo activo por defecto para mejor UX
@ -294,8 +296,7 @@ class PlotManager:
self.sessions[session_id] = session
# Guardar automáticamente la configuración
self.save_plots()
# Note: Plot session configuration now saved via frontend RJSF
if self.logger:
self.logger.info(
@ -338,8 +339,7 @@ class PlotManager:
del self.sessions[session_id]
# Guardar automáticamente después de eliminar
self.save_plots()
# Note: Plot session removal now saved via frontend RJSF
return True
return False
@ -446,26 +446,57 @@ class PlotManager:
self.session_counter = 0
def _load_plots_separated(self):
"""Load plots from separated definition and variable files"""
"""Load plots from separated definition and variable files (new array format)"""
try:
# Load definitions
# Load definitions (new array format: {"plots": [array]})
with open(self.plot_definitions_file, "r", encoding="utf-8") as f:
definitions_data = json.load(f)
# Load variables
# Load variables (new array format: {"variables": [array]})
with open(self.plot_variables_file, "r", encoding="utf-8") as f:
variables_data = json.load(f)
# Merge data back for session creation
plots_data = definitions_data.get("plots", {})
plot_vars = variables_data.get("plot_variables", {})
# Convert new array format to internal dictionary format for compatibility
plots_array = definitions_data.get("plots", [])
plot_vars_array = variables_data.get("variables", [])
for session_id, plot_config in plots_data.items():
# Add variables to config
variables_info = plot_vars.get(session_id, {})
# 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
full_config = {
**plot_config,
"variables": variables_info.get("variables", []),
"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, {}),
}
# Create session with full configuration
@ -478,16 +509,16 @@ class PlotManager:
# Update counter to avoid duplicate IDs
try:
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
if session_id.startswith("plot_"):
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
except (IndexError, ValueError):
pass
# Load counter from definitions
saved_counter = definitions_data.get("session_counter", len(self.sessions))
if saved_counter > self.session_counter:
self.session_counter = saved_counter
# Ensure session counter is at least the number of sessions
if len(self.sessions) >= self.session_counter:
self.session_counter = len(self.sessions)
if self.logger and self.sessions:
self.logger.info(
@ -527,8 +558,7 @@ class PlotManager:
if saved_counter > self.session_counter:
self.session_counter = saved_counter
# Save to new separated format
self.save_plots()
# Note: Migration complete - data now managed by frontend via RJSF
if self.logger:
self.logger.info(
@ -540,53 +570,9 @@ class PlotManager:
self.logger.error(f"Error migrating legacy plots: {e}")
raise
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}")
# 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 update_session_config(self, session_id: str, config: Dict[str, Any]) -> bool:
"""Actualizar configuración de una sesión existente"""
@ -632,8 +618,7 @@ class PlotManager:
old_data = list(session.data[var])
session.data[var] = deque(old_data, maxlen=max_points)
# Guardar cambios
self.save_plots()
# Note: Plot session configuration changes now saved via frontend RJSF
if self.logger:
self.logger.info(f"Updated plot session '{session.name}' configuration")

View File

@ -156,7 +156,18 @@ class ConfigSchemaManager:
path = self.config_files[config_id]
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(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 default structure for each type
if config_id == "dataset-definitions":
return {
@ -242,20 +253,69 @@ class ConfigSchemaManager:
if config_id == "dataset-definitions":
# Manejar solo datasets individuales, calcular active_datasets automáticamente
datasets = data.get("datasets", {})
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")
# Actualizar datasets en ConfigManager
self.config_manager.datasets.update(datasets)
# Calcular active_datasets automáticamente desde enabled field
self.config_manager._update_active_datasets()
# 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
# current_dataset_id es opcional para UI
current = data.get("current_dataset_id")
if current and current in datasets:
self.config_manager.current_dataset_id = current
self.config_manager.save_datasets()
# Note: Data is now persisted directly via frontend RJSF
return {"success": True}
if config_id == "plot-definitions":
@ -282,8 +342,7 @@ class ConfigSchemaManager:
else:
self.plot_manager.session_counter = 0
# Save to separated files
self.plot_manager.save_plots()
# Note: Data is now persisted directly via frontend RJSF
except Exception as e:
if self.logger:
@ -316,9 +375,11 @@ class ConfigSchemaManager:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.read_config("plc"), f, indent=2)
elif config_id == "datasets":
self.config_manager.save_datasets() # Now saves to separated files
# Note: Datasets now managed via separated files by frontend RJSF
pass
elif config_id == "plots":
self.plot_manager.save_plots() # Now saves to separated files
# Note: Plots now managed via separated files by frontend RJSF
pass
elif config_id in [
"dataset-definitions",
"dataset-variables",

View File

@ -683,7 +683,9 @@ class DataStreamer:
f"Dataset activated: {dataset_info['name']}",
{
"dataset_id": dataset_id,
"variables_count": len(dataset_info["variables"]),
"variables_count": len(
self.config_manager.get_dataset_variables(dataset_id)
),
"streaming_count": len(dataset_info["streaming_variables"]),
"prefix": dataset_info["prefix"],
},

View File

@ -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/Dashboard.jsx'
import DashboardPage from './pages/DashboardNew.jsx'
import DatasetManager from './components/DatasetManager.jsx'
function Home() {

View File

@ -13,12 +13,16 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
insertNaNOnNextIngest: false,
isPaused: false,
isRealTimeMode: true,
refreshRate: 1000
refreshRate: 1000,
userOverrideUntil: 0,
userPaused: false,
sessionId: null
});
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
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');
@ -52,6 +56,17 @@ 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),
@ -81,7 +96,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
}, [getColor]);
const createStreamingChart = useCallback(async () => {
if (!canvasRef.current || !session?.config) return;
const cfg = resolvedConfigRef.current || session?.config;
if (!canvasRef.current || !cfg) return;
console.log(`🔧 Creating chart for session ${session?.session_id}...`);
try {
// Ensure Chart.js and plugins are loaded
@ -91,15 +109,32 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const Chart = window.Chart;
// Register zoom plugin if available
// Ensure zoom plugin is registered only if available to avoid plugin errors
let zoomAvailable = false;
try {
if (window.ChartZoom && !Chart.registry.plugins.get('zoom')) {
Chart.register(window.ChartZoom);
if (Chart.registry?.plugins?.get?.('zoom')) {
zoomAvailable = true;
} else {
const maybeZoom = (window.ChartZoom || window.chartjsPluginZoom || window.chartjs_plugin_zoom || window.chartjsPluginZoomDefault || (window['chartjs-plugin-zoom'] && window['chartjs-plugin-zoom'].default));
if (maybeZoom) {
Chart.register(maybeZoom);
zoomAvailable = true;
}
}
} catch (e) {
console.warn('⚠️ Zoom plugin registration failed:', e);
}
// Ensure realtime scale is available (no fallback)
try {
const hasRealtimeScale = !!(Chart.registry?.scales?.get && Chart.registry.scales.get('realtime'));
if (!hasRealtimeScale) {
throw new Error('chartjs-plugin-streaming (realtime scale) not loaded');
}
} catch (e) {
throw new Error('Realtime scale not available. Ensure chartjs-plugin-streaming v2.x is loaded after Chart.js.');
}
const ctx = canvasRef.current.getContext('2d');
// Destroy existing chart
@ -108,7 +143,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
chartRef.current = null;
}
const config = session.config;
const config = cfg;
const enabledVariables = getEnabledVariables(config.variables);
const datasets = enabledVariables.map((variableInfo, index) => {
@ -133,6 +168,9 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionDataRef.current.datasetIndex.set(variableInfo.name, index);
});
const yMinInitial = (typeof config.y_min === 'number' && isFinite(config.y_min)) ? config.y_min : undefined;
const yMaxInitial = (typeof config.y_max === 'number' && isFinite(config.y_max)) ? config.y_max : undefined;
const chartConfig = {
type: 'line',
data: { datasets },
@ -179,8 +217,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
display: true,
text: 'Valor'
},
min: config.y_min,
max: config.y_max
min: yMinInitial,
max: yMaxInitial
}
},
plugins: {
@ -192,17 +230,13 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
mode: 'index',
intersect: false
},
zoom: {
pan: {
enabled: true,
mode: 'x'
},
...(zoomAvailable ? {
zoom: {
pinch: { enabled: true },
wheel: { enabled: true },
mode: 'x'
// Evita listeners wheel/touch no-passive del plugin; usa drag + pan con modificador
pan: { enabled: true, mode: 'x', modifierKey: 'shift' },
zoom: { drag: { enabled: true }, wheel: { enabled: false }, pinch: { enabled: false }, mode: 'x' }
}
}
} : {})
},
interaction: {
mode: 'nearest',
@ -222,40 +256,14 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
}
};
// Try to create chart with realtime scale
try {
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = true;
console.log(`✅ Plot ${session.session_id}: Real-time Streaming enabled`);
} catch (e) {
console.warn(`⚠️ Plot ${session.session_id}: Real-time scale not available. Falling back to time scale.`, e);
// Fallback configuration without realtime
chartConfig.options.scales.x = {
type: 'time',
time: {
unit: 'second',
displayFormats: {
second: 'HH:mm:ss'
}
},
ticks: {
display: true,
autoSkip: true,
maxRotation: 0
},
title: {
display: true,
text: 'Tiempo'
}
};
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = false;
// Start manual refresh for fallback mode
startManualRefresh();
}
chartRef.current = new Chart(ctx, chartConfig);
sessionDataRef.current.isRealTimeMode = true;
sessionDataRef.current.noDataCycles = 0;
// Sync ingest pause state with initial chart pause
const initialPaused = !session?.is_active || session?.is_paused;
sessionDataRef.current.ingestPaused = initialPaused;
sessionDataRef.current.isPaused = initialPaused;
console.log(`✅ Plot ${session?.session_id}: Real-time Streaming enabled`);
setIsLoading(false);
setError(null);
@ -265,10 +273,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
setError(error.message);
setIsLoading(false);
}
}, [session, getEnabledVariables, getColor]);
}, []);
const onStreamingRefresh = useCallback(async (chart) => {
if (!session?.session_id) return;
const sessionId = sessionDataRef.current.sessionId;
if (!sessionId) return;
try {
const now = Date.now();
@ -282,28 +291,75 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionDataRef.current.lastDataFetch = now;
// Fetch data from backend
const response = await fetch(`/api/plots/${session.session_id}/data`);
const response = await fetch(`/api/plots/${sessionId}/data`);
if (!response.ok) return;
const plotData = await response.json();
// Add new data to chart
addNewDataToStreaming(plotData, now);
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;
}
}
} catch (error) {
console.error(`📈 Error in streaming refresh for ${session.session_id}:`, error);
console.error(`📈 Error in streaming refresh for ${sessionDataRef.current.sessionId}:`, error);
}
}, [session?.session_id]);
}, []);
const addNewDataToStreaming = useCallback((plotData, timestamp) => {
if (!chartRef.current || !plotData) return;
if (!chartRef.current || !plotData) return 0;
const chart = chartRef.current;
const sessionData = sessionDataRef.current;
// Check if paused
if (sessionData.ingestPaused) return;
if (sessionData.ingestPaused) return 0;
// Helpers to parse flexible payloads
const getYValue = (point) => {
const candidate = (point?.y !== undefined) ? point.y : (point?.value !== undefined ? point.value : point?.v);
const yNum = typeof candidate === 'number' ? candidate : Number(candidate);
return Number.isFinite(yNum) ? yNum : null;
};
const getXValueMs = (point) => {
let raw = (point?.x !== undefined) ? point.x : (point?.ts !== undefined ? point.ts : (point?.timestamp !== undefined ? point.timestamp : point?.t));
if (typeof raw === 'string') {
const asNum = Number(raw);
if (Number.isFinite(asNum)) raw = asNum; else {
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) raw = parsed; else return null;
}
}
if (typeof raw !== 'number' || !Number.isFinite(raw)) return null;
// Normalize seconds to milliseconds
const xMs = raw < 1e12 ? raw * 1000 : raw;
return xMs;
};
let pointsAdded = 0;
chart.data.datasets.forEach((chartDataset, datasetIndex) => {
@ -318,12 +374,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const newPoints = [];
for (let i = 0; i < backendDataset.data.length; i++) {
const p = backendDataset.data[i];
const yNum = typeof p.y === 'number' ? p.y : Number(p.y);
if (!isFinite(yNum)) continue;
const yNum = getYValue(p);
if (yNum === null) continue;
let xNum = typeof p.x === 'number' ? p.x : Number(p.x);
if (!isFinite(xNum)) continue;
if (xNum < 1e12) xNum = xNum * 1000; // seconds -> ms
const xNum = getXValueMs(p);
if (xNum === null) continue;
if (xNum > lastPushedX) newPoints.push({ x: xNum, y: yNum });
}
@ -354,10 +409,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
});
// Update chart
if (!sessionData.isRealTimeMode) {
cleanupOldDataFallback();
chart.update('quiet');
} else if (pointsAdded > 0) {
if (pointsAdded > 0) {
chart.update('quiet');
}
@ -365,22 +417,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (pointsAdded > 0 && sessionData.insertNaNOnNextIngest) {
sessionData.insertNaNOnNextIngest = false;
}
return pointsAdded;
}, []);
const cleanupOldDataFallback = useCallback(() => {
if (!chartRef.current || !session?.config) return;
const chart = chartRef.current;
const timeWindow = (session.config.time_window || 60) * 1000;
const now = Date.now();
const cutoffTime = now - timeWindow;
chart.data.datasets.forEach((dataset) => {
if (dataset.data && dataset.data.length > 0) {
dataset.data = dataset.data.filter(point => point.x > cutoffTime);
}
});
}, [session?.config]);
// Fallback cleanup removed per requirement: always realtime; we pause instead when no data
const updatePointsCounter = useCallback((plotData) => {
const totalPoints = plotData.data_points_count || 0;
@ -408,11 +448,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = true;
const rt = chart.options?.scales?.x?.realtime;
if (rt) {
rt.pause = true;
}
chart.update('quiet');
chart.update('none');
} else {
if (sessionData.manualInterval) {
clearInterval(sessionData.manualInterval);
@ -422,6 +462,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionData.ingestPaused = true;
sessionData.isPaused = true;
sessionData.userOverrideUntil = Date.now() + 3000;
}, []);
const resumeStreaming = useCallback(() => {
@ -430,11 +471,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (sessionData.isRealTimeMode) {
const chart = chartRef.current;
const xScale = chart.scales?.x;
if (xScale?.realtime) {
xScale.realtime.pause = false;
const rt = chart.options?.scales?.x?.realtime;
if (rt) {
rt.pause = false;
}
chart.update('quiet');
chart.update('none');
} else {
if (!sessionData.manualInterval) {
startManualRefresh();
@ -444,6 +485,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
sessionData.insertNaNOnNextIngest = true;
sessionData.ingestPaused = false;
sessionData.isPaused = false;
sessionData.userOverrideUntil = Date.now() + 3000;
}, [startManualRefresh]);
const clearChart = useCallback(() => {
@ -462,6 +504,9 @@ 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,
@ -469,36 +514,52 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
clearChart
});
}
}, [pauseStreaming, resumeStreaming, clearChart, session]);
}, [pauseStreaming, resumeStreaming, clearChart, session?.session_id, session?.onChartReady]);
// 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
// Initialize chart when config is resolved - simplified approach
useEffect(() => {
if (session?.config) {
createStreamingChart();
}
return () => {
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
// 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();
}
}
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 (sessionDataRef.current.manualInterval) {
clearInterval(sessionDataRef.current.manualInterval);
}
};
}, [createStreamingChart]);
}, [session?.session_id]);
if (isLoading) {
return (
@ -554,7 +615,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
style={{
width: '100%',
height: '100%',
borderRadius: '6px'
borderRadius: '6px',
touchAction: 'none',
WebkitUserSelect: 'none',
userSelect: 'none'
}}
/>
<Box

View File

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

View File

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

View File

@ -0,0 +1,533 @@
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>
)
}

View File

@ -0,0 +1,449 @@
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>
)
}

View File

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

View File

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

View File

@ -39,26 +39,30 @@ export const TextWidget = ({ id, placeholder, required, readonly, disabled, labe
)
}
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
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
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 : 2}
precision={schema?.type === 'integer' ? 0 : step < 1 ? 2 : 1}
>
<NumberInputField placeholder={placeholder} />
<NumberInputField
placeholder={placeholder}
inputMode={step < 1 ? "decimal" : "numeric"}
pattern={step < 1 ? "[0-9]*(\\.[0-9]+)?" : "[0-9]*"}
/>
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />

View File

@ -0,0 +1,32 @@
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

View File

@ -0,0 +1,70 @@
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

View File

@ -1,13 +1,16 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
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 {
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 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,
@ -19,15 +22,69 @@ import {
disconnectPlc,
startUdpStreaming,
stopUdpStreaming,
activateDataset,
deactivateDataset,
} from '../services/api.js'
function StatusBar({ status }) {
function StatusBar({ status, onRefresh }) {
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={3}>
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={3} mb={4}>
<Card><CardBody display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Text fontWeight="semibold">🔌 PLC: {plcConnected ? 'Connected' : 'Disconnected'}</Text>
@ -38,9 +95,9 @@ function StatusBar({ status }) {
)}
</Box>
{plcConnected ? (
<Button size="sm" variant="outline" colorScheme="red" onClick={disconnectPlc}> Disconnect</Button>
<Button size="sm" variant="outline" colorScheme="red" onClick={handleDisconnectPlc}> Disconnect</Button>
) : (
<Button size="sm" variant="outline" colorScheme="blue" onClick={connectPlc}>🔗 Connect</Button>
<Button size="sm" variant="outline" colorScheme="blue" onClick={handleConnectPlc}>🔗 Connect</Button>
)}
</CardBody></Card>
@ -49,9 +106,9 @@ function StatusBar({ status }) {
<Text fontWeight="semibold">📡 UDP Streaming: {streaming ? 'Active' : 'Inactive'}</Text>
</Box>
{streaming ? (
<Button size="sm" variant="outline" colorScheme="red" onClick={stopUdpStreaming}> Stop</Button>
<Button size="sm" variant="outline" colorScheme="red" onClick={handleStopStreaming}> Stop</Button>
) : (
<Button size="sm" variant="outline" colorScheme="blue" onClick={startUdpStreaming}> Start</Button>
<Button size="sm" variant="outline" colorScheme="blue" onClick={handleStartStreaming}> Start</Button>
)}
</CardBody></Card>
@ -240,7 +297,7 @@ export default function DashboardPage() {
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
</Flex>
<DatasetCompleteManager />
<DatasetCompleteManager status={status} />
</CardBody>
</Card>
)}
@ -253,6 +310,10 @@ export default function DashboardPage() {
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
</Flex>
<PlotCompleteManager />
<Box mt={4}>
<Text fontWeight="semibold" mb={2}>🔴 Real-time plots</Text>
<PlotRealtimeViewer />
</Box>
</CardBody>
</Card>
)}

View File

@ -0,0 +1,880 @@
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>
)
}

View File

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

View File

@ -8,8 +8,26 @@ function toJsonOrThrow(res) {
}
export async function getStatus() {
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
try {
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
} catch (err) {
// Fallback: try health endpoint and synthesize a minimal status to keep UI usable
try {
const healthRes = await fetch(`${BASE_URL}/api/health`, { headers: { 'Accept': 'application/json' } })
if (healthRes.ok) {
const health = await healthRes.json()
return {
plc_connected: false,
streaming: false,
csv_recording: false,
disk_space_info: null,
health,
}
}
} catch { /* ignore */ }
throw err
}
}
export async function getEvents(limit = 50) {
@ -42,17 +60,27 @@ 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' } })
return toJsonOrThrow(res)
const response = await toJsonOrThrow(res)
// The API returns { success: true, schemas: [...] }
return response
}
export async function getSchema(schemaId) {
const res = await fetch(`${BASE_URL}/api/config/schema/${encodeURIComponent(schemaId)}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
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 || {}
}
}
export async function readConfig(configId) {
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
const response = await toJsonOrThrow(res)
// The API might return { success: true, data: {...} } or just the data
return response.data || response
}
export async function writeConfig(configId, data) {
@ -88,4 +116,92 @@ 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
View File

@ -12,6 +12,7 @@ from datetime import datetime
import os
import sys
from core import PLCDataStreamer
from utils.json_manager import JSONManager, SchemaManager
app = Flask(__name__)
CORS(
@ -50,8 +51,10 @@ def project_path(*parts: str) -> str:
return os.path.join(base_dir, *parts)
# Global streamer instance (will be initialized in main)
# Global instances
streamer = None
json_manager = JSONManager()
schema_manager = SchemaManager()
def check_streamer_initialized():
@ -148,76 +151,83 @@ def serve_react_index(path: str = ""):
# ==============================
# Config Schemas & Editor API
# Unified JSON Configuration API
# ==============================
@app.route("/api/config/schemas", methods=["GET"])
def list_config_schemas():
"""Listar esquemas disponibles (plc, datasets, plots)."""
error_response = check_streamer_initialized()
if error_response:
return error_response
"""List all available configuration schemas."""
try:
info = streamer.schema_manager.list_schemas()
return jsonify({"success": True, **info})
schemas = schema_manager.list_available_schemas()
return jsonify({"success": True, "schemas": schemas})
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):
"""Obtener un esquema específico en formato JSON Schema."""
error_response = check_streamer_initialized()
if error_response:
return error_response
"""Get a specific JSON schema with optional UI schema."""
try:
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
# 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)
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):
"""Leer configuración actual (plc/datasets/plots)."""
error_response = check_streamer_initialized()
if error_response:
return error_response
"""Read configuration data from JSON file."""
try:
data = streamer.schema_manager.read_config(config_id)
data = json_manager.read_json(config_id)
return jsonify({"success": True, "data": data})
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 404
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>", methods=["PUT"])
def write_config(config_id):
"""Sobrescribir configuración a partir del cuerpo JSON."""
error_response = check_streamer_initialized()
if error_response:
return error_response
"""Write configuration data to JSON file."""
try:
payload = request.get_json(force=True, silent=False)
result = streamer.schema_manager.write_config(config_id, payload)
return jsonify({"success": True, **result})
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",
}
)
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e:
@ -226,25 +236,57 @@ def write_config(config_id):
@app.route("/api/config/<config_id>/export", methods=["GET"])
def export_config(config_id):
"""Exportar configuración como descarga JSON."""
"""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."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
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
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",
}
)
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"""
@ -272,8 +314,9 @@ 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)
streamer.update_udp_config(host, port, sampling_interval)
return jsonify({"success": True, "message": "UDP configuration updated"})
except Exception as e:
@ -1227,7 +1270,7 @@ def set_current_dataset():
if dataset_id and dataset_id in streamer.datasets:
streamer.current_dataset_id = dataset_id
streamer.save_datasets()
# Note: No need to save - this is just changing current selection in memory
return jsonify(
{
"success": True,
@ -1499,6 +1542,7 @@ 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}"
),

34
main_cleanup_notes.py Normal file
View File

@ -0,0 +1,34 @@
# ==============================
# 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

View File

@ -4,9 +4,10 @@
"should_stream": false,
"active_datasets": [
"Fast",
"DAR"
"DAR",
"Test"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-13T00:09:19.091418"
"last_update": "2025-08-14T11:34:34.761474"
}

143
utils/json_manager.py Normal file
View File

@ -0,0 +1,143 @@
"""
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"])

41
validate_schema.py Normal file
View File

@ -0,0 +1,41 @@
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}")