Implementación de un sistema de plotting en tiempo real con soporte para múltiples sesiones y un sistema de triggers basado en variables booleanas. Se añadieron nuevos endpoints API para gestionar sesiones de plotting, así como mejoras en la interfaz de usuario para facilitar la creación y control de plots. Se actualizaron los estilos CSS y se integraron nuevas funcionalidades en el frontend para una experiencia de usuario más intuitiva. Además, se realizaron ajustes en los archivos de configuración y estado del sistema para reflejar estos cambios.
This commit is contained in:
parent
5138a2e7cd
commit
a13baed5c6
|
@ -5,7 +5,7 @@
|
|||
## Functional Description of the Application
|
||||
|
||||
This application is a web server (created with the Flask framework in Python) that acts as an intermediary to monitor and record data in CSV format from a PLC Siemens S7 with the SNAP7 library to be used on a low-resource PC connected to the PLC.
|
||||
It must be as simple as possible to allow the pack using PyInstaller
|
||||
It must be as simple as possible to allow the pack using PyInstaller and must be capable of running completelly offline from internet.
|
||||
|
||||
#### Its key functions are:
|
||||
|
||||
|
@ -30,6 +30,88 @@ The application is designed to be persistent across restarts, restoring the prev
|
|||
|
||||
### Latest Modifications (Current Session)
|
||||
|
||||
#### Real-Time Plotting System Implementation
|
||||
**Decision**: Implementar un sistema completo de plotting en tiempo real con trigger de variables boolean y uso exclusivo del cache del recording.
|
||||
|
||||
**Rationale**: Los usuarios necesitaban visualización en tiempo real de variables del PLC sin agregar carga adicional al sistema. El sistema de plotting debe ser independiente del UDP streaming existente y usar solo los datos del cache para mantener la eficiencia.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
**Backend Architecture** (`core/plot_manager.py`):
|
||||
- **PlotSession Class**: Maneja sesiones individuales de plotting con configuración específica
|
||||
- **PlotManager Class**: Gestiona todas las sesiones con thread safety
|
||||
- **Trigger System**: Variables boolean que reinician automáticamente el trace
|
||||
- **Cache Integration**: Usa exclusivamente datos del cache del recording (sin carga PLC adicional)
|
||||
|
||||
**Key Features**:
|
||||
- **Boolean Trigger**: Variables bool pueden reiniciar traces automáticamente
|
||||
- **Multiple Sessions**: Múltiples plot sessions independientes simultáneas
|
||||
- **Performance Optimized**: Solo usa cache del recording, no lecturas PLC adicionales
|
||||
- **Flexible Configuration**: Ventana de tiempo, rango Y, trigger configurable
|
||||
|
||||
**Frontend Implementation** (`static/js/plotting.js`):
|
||||
- **Chart.js Integration**: Gráficos modernos y responsivos
|
||||
- **Auto-update System**: Actualización automática cada 500ms
|
||||
- **Modal Interface**: Creación intuitiva de nuevas sesiones
|
||||
- **Real-time Controls**: Start/Stop/Pause/Clear individual por sesión
|
||||
|
||||
**API Endpoints** (`main.py`):
|
||||
- **GET /api/plots**: Estado de todas las sesiones
|
||||
- **POST /api/plots**: Crear nueva sesión
|
||||
- **DELETE /api/plots/<id>**: Eliminar sesión
|
||||
- **POST /api/plots/<id>/control**: Control de sesión (start/stop/pause/clear)
|
||||
- **GET /api/plots/<id>/data**: Datos para Chart.js
|
||||
- **GET /api/plots/variables**: Variables disponibles (solo datasets activos)
|
||||
|
||||
**Tab System Integration**:
|
||||
- **New Tab Interface**: Sistema de tabs para organizar funcionalidades
|
||||
- **Plotting Tab**: Dedicado al sistema de plotting
|
||||
- **Events Tab**: Para logs y eventos del sistema
|
||||
- **Responsive Design**: Adaptable a diferentes tamaños de pantalla
|
||||
|
||||
**Technical Benefits**:
|
||||
- **Zero PLC Load**: No agrega lecturas adicionales al PLC
|
||||
- **Cache Efficiency**: Reutiliza datos del sistema de recording
|
||||
- **Thread Safety**: Operaciones concurrentes seguras
|
||||
- **Memory Management**: Deques con tamaño limitado para evitar memory leaks
|
||||
|
||||
**User Experience**:
|
||||
- **Intuitive Interface**: Creación fácil de plots con modal
|
||||
- **Real-time Feedback**: Estadísticas en tiempo real de cada sesión
|
||||
- **Visual Controls**: Botones claros para controlar sesiones
|
||||
- **Error Handling**: Manejo robusto de errores y feedback al usuario
|
||||
|
||||
**Trigger System Details**:
|
||||
- **Boolean Variables Only**: Solo variables de tipo BOOL pueden ser triggers
|
||||
- **Configurable Logic**: Trigger en True o False según configuración
|
||||
- **Automatic Restart**: Limpia datos y reinicia trace cuando se activa
|
||||
- **State Tracking**: Mantiene estado del trigger para detectar cambios
|
||||
|
||||
**Integration with Existing System**:
|
||||
- **DataStreamer Enhancement**: Integración automática con el cache existente
|
||||
- **No Breaking Changes**: Compatible con sistema existente
|
||||
- **Performance Neutral**: No afecta rendimiento del sistema principal
|
||||
- **Event Logging**: Logs detallados de operaciones de plotting
|
||||
|
||||
**Configuration Options**:
|
||||
- **Time Window**: 10-3600 segundos configurable
|
||||
- **Y-Axis Range**: Automático o manual (min/max)
|
||||
- **Variable Selection**: Solo variables de datasets activos
|
||||
- **Trigger Configuration**: Variable bool + lógica (True/False)
|
||||
|
||||
**Industrial Benefits**:
|
||||
- **Process Monitoring**: Visualización en tiempo real de variables críticas
|
||||
- **Trigger Analysis**: Análisis de eventos específicos con reinicio automático
|
||||
- **Multiple Views**: Diferentes perspectivas del mismo proceso
|
||||
- **Historical Context**: Mantiene contexto temporal con ventanas configurables
|
||||
|
||||
**Dependencies Added**:
|
||||
- **Chart.js**: Librería de gráficos (CDN)
|
||||
- **Flask-SocketIO**: Para futuras mejoras con WebSockets
|
||||
- **Date-fns**: Para manejo de fechas en Chart.js
|
||||
|
||||
Esta implementación proporciona una herramienta poderosa para monitoreo en tiempo real sin comprometer el rendimiento del sistema, manteniendo la arquitectura existente y agregando funcionalidad significativa para análisis de procesos industriales.
|
||||
|
||||
#### Critical Fix: CSV Recording vs UDP Streaming Separation and Thread Join Error Resolution
|
||||
**Issue**: El sistema tenía un error crítico `RuntimeError: cannot join current thread` al detener streaming, y había confusión entre CSV recording (que debe ser automático) y UDP streaming (que debe ser manual). Al detener streaming UDP se detenía también el recording CSV, violando el diseño del sistema.
|
||||
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
# 📈 Sistema de Plotting en Tiempo Real
|
||||
|
||||
## Descripción General
|
||||
|
||||
El sistema de plotting implementado permite crear gráficos interactivos en tiempo real usando los datos del cache de recording del sistema. Esto significa que **no agrega carga adicional al PLC** ya que utiliza los mismos datos que se están grabando automáticamente en CSV.
|
||||
|
||||
## Características Principales
|
||||
|
||||
### 🎯 Sistema de Trigger
|
||||
- **Variables Boolean**: Usa variables boolean como trigger para reiniciar automáticamente el trace
|
||||
- **Configuración Flexible**: Puede trigger en True o False según la configuración
|
||||
- **Reinicio Automático**: Cuando se activa el trigger, el gráfico se limpia y comienza un nuevo trace
|
||||
|
||||
### ⚡ Performance Optimizada
|
||||
- **Cache del Recording**: Utiliza exclusivamente los datos del cache del sistema de recording
|
||||
- **Sin Carga PLC**: No realiza lecturas adicionales al PLC
|
||||
- **Actualización Eficiente**: Actualización automática cada 500ms para plots activos
|
||||
|
||||
### 📊 Múltiples Sesiones
|
||||
- **Sesiones Independientes**: Puede crear múltiples plot sessions simultáneamente
|
||||
- **Configuración Individual**: Cada sesión tiene su propia configuración de variables, tiempo y trigger
|
||||
- **Control Granular**: Start/Stop/Pause/Clear individual para cada sesión
|
||||
|
||||
### 🎨 Interfaz Intuitiva
|
||||
- **Chart.js**: Gráficos modernos y responsivos
|
||||
- **Controles Visuales**: Botones claros para controlar cada sesión
|
||||
- **Información en Tiempo Real**: Muestra estadísticas de cada sesión
|
||||
|
||||
## Arquitectura del Sistema
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
#### `core/plot_manager.py`
|
||||
- **PlotSession**: Clase que maneja una sesión individual de plotting
|
||||
- **PlotManager**: Clase principal que gestiona todas las sesiones
|
||||
- **Integración con DataStreamer**: Se actualiza automáticamente con los datos del cache
|
||||
|
||||
#### Características Técnicas:
|
||||
- **Thread Safety**: Uso de locks para operaciones concurrentes
|
||||
- **Memory Management**: Deques con tamaño limitado para evitar memory leaks
|
||||
- **Error Handling**: Manejo robusto de errores y logging
|
||||
|
||||
### Frontend (JavaScript)
|
||||
|
||||
#### `static/js/plotting.js`
|
||||
- **PlotManager Class**: Maneja la interfaz de usuario y comunicación con el backend
|
||||
- **Chart.js Integration**: Configuración optimizada para datos en tiempo real
|
||||
- **Modal System**: Interfaz para crear nuevas sesiones de plotting
|
||||
|
||||
#### Características Técnicas:
|
||||
- **Auto-update**: Actualización automática cada 500ms
|
||||
- **Responsive Design**: Adaptable a diferentes tamaños de pantalla
|
||||
- **Error Recovery**: Manejo de errores de red y reconexión automática
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET `/api/plots`
|
||||
Obtiene el estado de todas las sesiones de plotting activas.
|
||||
|
||||
### POST `/api/plots`
|
||||
Crea una nueva sesión de plotting.
|
||||
|
||||
**Parámetros:**
|
||||
```json
|
||||
{
|
||||
"name": "Temperature Monitoring",
|
||||
"variables": ["temp1", "temp2", "pressure"],
|
||||
"time_window": 60,
|
||||
"y_min": 0,
|
||||
"y_max": 100,
|
||||
"trigger_enabled": true,
|
||||
"trigger_variable": "start_cycle",
|
||||
"trigger_on_true": true
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/plots/<session_id>`
|
||||
Elimina una sesión de plotting específica.
|
||||
|
||||
### POST `/api/plots/<session_id>/control`
|
||||
Controla una sesión de plotting.
|
||||
|
||||
**Parámetros:**
|
||||
```json
|
||||
{
|
||||
"action": "start|stop|pause|resume|clear"
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/plots/<session_id>/data`
|
||||
Obtiene los datos de una sesión específica para Chart.js.
|
||||
|
||||
### GET `/api/plots/variables`
|
||||
Obtiene las variables disponibles para plotting (solo de datasets activos).
|
||||
|
||||
## Configuración de Sesiones
|
||||
|
||||
### Parámetros Básicos
|
||||
- **name**: Nombre descriptivo de la sesión
|
||||
- **variables**: Lista de variables a graficar (solo de datasets activos)
|
||||
- **time_window**: Ventana de tiempo en segundos (10-3600)
|
||||
|
||||
### Configuración de Eje Y
|
||||
- **y_min**: Valor mínimo del eje Y (opcional, automático si no se especifica)
|
||||
- **y_max**: Valor máximo del eje Y (opcional, automático si no se especifica)
|
||||
|
||||
### Sistema de Trigger
|
||||
- **trigger_enabled**: Habilita/deshabilita el sistema de trigger
|
||||
- **trigger_variable**: Variable boolean a usar como trigger
|
||||
- **trigger_on_true**: Si es true, trigger en True; si es false, trigger en False
|
||||
|
||||
## Flujo de Datos
|
||||
|
||||
1. **DataStreamer** lee variables del PLC y actualiza el cache
|
||||
2. **PlotManager** detecta sesiones activas y actualiza sus datos
|
||||
3. **Frontend** solicita datos cada 500ms y actualiza Chart.js
|
||||
4. **Trigger System** verifica cambios en variables boolean y reinicia traces
|
||||
|
||||
## Variables Disponibles
|
||||
|
||||
### Para Plotting
|
||||
- Solo variables de **datasets activos**
|
||||
- Todos los tipos de datos soportados (REAL, INT, BOOL, etc.)
|
||||
|
||||
### Para Trigger
|
||||
- Solo variables de tipo **BOOL**
|
||||
- De datasets activos únicamente
|
||||
|
||||
## Control de Sesiones
|
||||
|
||||
### Estados de Sesión
|
||||
- **Active**: Sesión activa y recibiendo datos
|
||||
- **Paused**: Sesión pausada (no recibe nuevos datos)
|
||||
- **Stopped**: Sesión detenida (no visible en interfaz)
|
||||
|
||||
### Acciones Disponibles
|
||||
- **Start**: Inicia o reanuda la sesión
|
||||
- **Pause**: Pausa la sesión (mantiene datos)
|
||||
- **Clear**: Limpia todos los datos (reinicia trace)
|
||||
- **Stop**: Detiene la sesión completamente
|
||||
- **Remove**: Elimina la sesión
|
||||
|
||||
## Integración con el Sistema Existente
|
||||
|
||||
### Compatibilidad
|
||||
- **Sin Cambios**: No requiere modificaciones al sistema de recording existente
|
||||
- **Cache Sharing**: Utiliza el mismo cache que CSV recording y UDP streaming
|
||||
- **Performance**: No afecta el rendimiento del sistema principal
|
||||
|
||||
### Dependencias
|
||||
- **Chart.js**: Librería de gráficos (CDN)
|
||||
- **Flask-SocketIO**: Para futuras mejoras con WebSockets
|
||||
- **Date-fns**: Para manejo de fechas en Chart.js
|
||||
|
||||
## Limitaciones y Consideraciones
|
||||
|
||||
### Limitaciones Actuales
|
||||
- **Variables Activas**: Solo variables de datasets activos
|
||||
- **Tiempo Real**: Actualización cada 500ms (no instantánea)
|
||||
- **Memoria**: Máximo 10 puntos por segundo por variable
|
||||
|
||||
### Consideraciones de Performance
|
||||
- **Múltiples Sesiones**: Cada sesión consume memoria adicional
|
||||
- **Variables Boolean**: Limitadas a variables de tipo BOOL
|
||||
- **Cache Dependencia**: Requiere que el recording esté activo
|
||||
|
||||
## Futuras Mejoras
|
||||
|
||||
### WebSockets
|
||||
- Actualización en tiempo real sin polling
|
||||
- Mejor performance y menor latencia
|
||||
|
||||
### Configuración Avanzada
|
||||
- Múltiples triggers por sesión
|
||||
- Filtros de datos más sofisticados
|
||||
- Exportación de datos de gráficos
|
||||
|
||||
### Interfaz Mejorada
|
||||
- Zoom y pan en gráficos
|
||||
- Múltiples escalas Y
|
||||
- Templates de configuración
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problemas Comunes
|
||||
|
||||
#### No se ven datos en el gráfico
|
||||
1. Verificar que el PLC esté conectado
|
||||
2. Verificar que los datasets estén activos
|
||||
3. Verificar que las variables existan en datasets activos
|
||||
|
||||
#### Error al crear sesión
|
||||
1. Verificar que las variables seleccionadas existan
|
||||
2. Verificar que el trigger variable sea de tipo BOOL
|
||||
3. Verificar que el time window esté entre 10-3600 segundos
|
||||
|
||||
#### Gráfico no se actualiza
|
||||
1. Verificar que la sesión esté activa (no pausada)
|
||||
2. Verificar conexión de red
|
||||
3. Verificar que el recording esté funcionando
|
||||
|
||||
### Logs y Debugging
|
||||
- Los logs del sistema incluyen información de plotting
|
||||
- Console del navegador muestra errores de JavaScript
|
||||
- Network tab muestra requests a la API
|
|
@ -3084,8 +3084,197 @@
|
|||
"udp_port": 9870,
|
||||
"datasets_available": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:49:17.370512",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:49:17.434505",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "dar",
|
||||
"variables_count": 6,
|
||||
"streaming_count": 4,
|
||||
"prefix": "dar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:49:17.439505",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 1 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 1,
|
||||
"total_datasets": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:49:17.449529",
|
||||
"level": "info",
|
||||
"event_type": "udp_streaming_started",
|
||||
"message": "UDP streaming to PlotJuggler started",
|
||||
"details": {
|
||||
"udp_host": "127.0.0.1",
|
||||
"udp_port": 9870,
|
||||
"datasets_available": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:50:20.235616",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'Test' created",
|
||||
"details": {
|
||||
"session_id": "plot_0",
|
||||
"variables": [
|
||||
"UR29_Brix"
|
||||
],
|
||||
"time_window": 60,
|
||||
"trigger_variable": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:51:02.883495",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_removed",
|
||||
"message": "Plot session 'Test' removed",
|
||||
"details": {
|
||||
"session_id": "plot_0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:51:16.267327",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'test' created",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_Brix_Digital",
|
||||
"UR62_Brix"
|
||||
],
|
||||
"time_window": 60,
|
||||
"trigger_variable": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-20T23:52:19.663479",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'test' created",
|
||||
"details": {
|
||||
"session_id": "plot_2",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR62_Brix"
|
||||
],
|
||||
"time_window": 60,
|
||||
"trigger_variable": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:24.720368",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:37.488725",
|
||||
"level": "info",
|
||||
"event_type": "config_change",
|
||||
"message": "PLC configuration updated: 10.1.33.249:0/2",
|
||||
"details": {
|
||||
"old_config": {
|
||||
"ip": "10.1.33.249",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
},
|
||||
"new_config": {
|
||||
"ip": "10.1.33.249",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:43.453084",
|
||||
"level": "info",
|
||||
"event_type": "config_change",
|
||||
"message": "PLC configuration updated: 10.1.33.11:0/2",
|
||||
"details": {
|
||||
"old_config": {
|
||||
"ip": "10.1.33.249",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
},
|
||||
"new_config": {
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:45.846722",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "dar",
|
||||
"variables_count": 6,
|
||||
"streaming_count": 4,
|
||||
"prefix": "dar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:45.854021",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 1 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 1,
|
||||
"total_datasets": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:21:45.857409",
|
||||
"level": "info",
|
||||
"event_type": "plc_connection",
|
||||
"message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 1 datasets",
|
||||
"details": {
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2,
|
||||
"auto_started_recording": true,
|
||||
"recording_datasets": 1,
|
||||
"dataset_names": [
|
||||
"DAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-07-21T09:25:26.290146",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'Cond' created",
|
||||
"details": {
|
||||
"session_id": "plot_0",
|
||||
"variables": [
|
||||
"CTS306_PV"
|
||||
],
|
||||
"time_window": 10,
|
||||
"trigger_variable": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-07-20T23:25:10.215131",
|
||||
"total_entries": 294
|
||||
"last_updated": "2025-07-21T09:25:26.290146",
|
||||
"total_entries": 309
|
||||
}
|
|
@ -0,0 +1,345 @@
|
|||
import threading
|
||||
import time
|
||||
import json
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List, Set
|
||||
import logging
|
||||
|
||||
|
||||
class PlotSession:
|
||||
"""Representa una sesión de plotting individual con configuración específica"""
|
||||
|
||||
def __init__(self, session_id: str, config: Dict[str, Any]):
|
||||
self.session_id = session_id
|
||||
self.name = config.get("name", f"Plot {session_id}")
|
||||
self.variables = config.get("variables", []) # Lista de nombres de variables
|
||||
self.time_window = config.get(
|
||||
"time_window", 60
|
||||
) # Ventana de tiempo en segundos
|
||||
self.y_min = config.get("y_min") # Mínimo Y (None = automático)
|
||||
self.y_max = config.get("y_max") # Máximo Y (None = automático)
|
||||
|
||||
# Configuración de trigger
|
||||
self.trigger_variable = config.get(
|
||||
"trigger_variable"
|
||||
) # Variable bool para trigger
|
||||
self.trigger_on_true = config.get(
|
||||
"trigger_on_true", True
|
||||
) # Trigger en True o False
|
||||
self.trigger_enabled = config.get(
|
||||
"trigger_enabled", False
|
||||
) # Si está habilitado el trigger
|
||||
|
||||
# Estado de la sesión
|
||||
self.is_active = True
|
||||
self.is_paused = False
|
||||
self.last_trigger_state = None # Último estado del trigger
|
||||
|
||||
# Datos de la sesión
|
||||
self.data = {} # variable_name -> deque of (timestamp, value)
|
||||
self.session_start_time = time.time()
|
||||
self.last_data_update = None
|
||||
|
||||
# Inicializar deques para cada variable (máximo 10 muestras por segundo)
|
||||
max_points = int(self.time_window * 10)
|
||||
for var in self.variables:
|
||||
self.data[var] = deque(maxlen=max_points)
|
||||
|
||||
# Colores para las variables
|
||||
self.colors = [
|
||||
"#FF6384",
|
||||
"#36A2EB",
|
||||
"#FFCE56",
|
||||
"#4BC0C0",
|
||||
"#9966FF",
|
||||
"#FF9F40",
|
||||
"#FF6384",
|
||||
"#C9CBCF",
|
||||
"#4BC0C0",
|
||||
"#FF6384",
|
||||
]
|
||||
|
||||
def add_data_point(self, variable: str, timestamp: float, value: Any) -> bool:
|
||||
"""Agregar un punto de datos a la sesión"""
|
||||
if not self.is_active or self.is_paused or variable not in self.data:
|
||||
return False
|
||||
|
||||
self.data[variable].append((timestamp, value))
|
||||
self.last_data_update = timestamp
|
||||
return True
|
||||
|
||||
def check_trigger(self, trigger_value: bool) -> bool:
|
||||
"""Verificar si debe reiniciar el trace basado en el trigger"""
|
||||
if not self.trigger_enabled or self.trigger_variable is None:
|
||||
return False
|
||||
|
||||
# Detectar cambio de estado
|
||||
if self.last_trigger_state is None:
|
||||
self.last_trigger_state = trigger_value
|
||||
return False
|
||||
|
||||
# Verificar si el cambio coincide con la configuración
|
||||
if self.last_trigger_state != trigger_value and (
|
||||
(self.trigger_on_true and trigger_value)
|
||||
or (not self.trigger_on_true and not trigger_value)
|
||||
):
|
||||
|
||||
self.last_trigger_state = trigger_value
|
||||
return True
|
||||
|
||||
self.last_trigger_state = trigger_value
|
||||
return False
|
||||
|
||||
def clear_data(self):
|
||||
"""Limpiar todos los datos de la sesión (reiniciar trace)"""
|
||||
for var in self.data:
|
||||
self.data[var].clear()
|
||||
self.session_start_time = time.time()
|
||||
|
||||
def get_plot_data(self) -> Dict[str, Any]:
|
||||
"""Obtener datos formateados para Chart.js"""
|
||||
datasets = []
|
||||
|
||||
for i, variable in enumerate(self.variables):
|
||||
if variable in self.data and self.data[variable]:
|
||||
# Convertir datos a formato Chart.js
|
||||
data_points = []
|
||||
for timestamp, value in self.data[variable]:
|
||||
data_points.append(
|
||||
{
|
||||
"x": timestamp * 1000, # Chart.js espera milisegundos
|
||||
"y": value,
|
||||
}
|
||||
)
|
||||
|
||||
color_index = i % len(self.colors)
|
||||
datasets.append(
|
||||
{
|
||||
"label": variable,
|
||||
"data": data_points,
|
||||
"borderColor": self.colors[color_index],
|
||||
"backgroundColor": self.colors[color_index]
|
||||
+ "20", # 20 = 12% opacity
|
||||
"fill": False,
|
||||
"tension": 0.1,
|
||||
}
|
||||
)
|
||||
|
||||
# Calcular Y min/max automático si no están definidos
|
||||
y_min = self.y_min
|
||||
y_max = self.y_max
|
||||
|
||||
if y_min is None or y_max is None:
|
||||
all_values = []
|
||||
for var_data in self.data.values():
|
||||
all_values.extend(
|
||||
[point[1] for point in var_data if point[1] is not None]
|
||||
)
|
||||
|
||||
if all_values:
|
||||
if y_min is None:
|
||||
y_min = min(all_values) - (max(all_values) - min(all_values)) * 0.1
|
||||
if y_max is None:
|
||||
y_max = max(all_values) + (max(all_values) - min(all_values)) * 0.1
|
||||
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"name": self.name,
|
||||
"datasets": datasets,
|
||||
"y_min": y_min,
|
||||
"y_max": y_max,
|
||||
"time_window": self.time_window,
|
||||
"is_active": self.is_active,
|
||||
"is_paused": self.is_paused,
|
||||
"trigger_variable": self.trigger_variable,
|
||||
"trigger_enabled": self.trigger_enabled,
|
||||
"trigger_on_true": self.trigger_on_true,
|
||||
"last_update": self.last_data_update,
|
||||
"data_points_count": sum(len(data) for data in self.data.values()),
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Obtener estado de la sesión"""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"name": self.name,
|
||||
"is_active": self.is_active,
|
||||
"is_paused": self.is_paused,
|
||||
"variables_count": len(self.variables),
|
||||
"data_points_count": sum(len(data) for data in self.data.values()),
|
||||
"trigger_variable": self.trigger_variable,
|
||||
"trigger_enabled": self.trigger_enabled,
|
||||
"last_update": self.last_data_update,
|
||||
}
|
||||
|
||||
|
||||
class PlotManager:
|
||||
"""Maneja todas las sesiones de plotting del sistema"""
|
||||
|
||||
def __init__(self, event_logger=None, logger=None):
|
||||
self.sessions: Dict[str, PlotSession] = {}
|
||||
self.session_counter = 0
|
||||
self.lock = threading.Lock()
|
||||
self.event_logger = event_logger
|
||||
self.logger = logger
|
||||
|
||||
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
|
||||
|
||||
session = PlotSession(session_id, config)
|
||||
self.sessions[session_id] = session
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Created plot session '{session.name}' with {len(session.variables)} variables"
|
||||
)
|
||||
|
||||
if self.event_logger:
|
||||
self.event_logger.log_event(
|
||||
"info",
|
||||
"plot_session_created",
|
||||
f"Plot session '{session.name}' created",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"variables": session.variables,
|
||||
"time_window": session.time_window,
|
||||
"trigger_variable": session.trigger_variable,
|
||||
},
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
def remove_session(self, session_id: str) -> bool:
|
||||
"""Eliminar una sesión de plotting"""
|
||||
with self.lock:
|
||||
if session_id in self.sessions:
|
||||
session = self.sessions[session_id]
|
||||
session.is_active = False
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"Removed plot session '{session.name}'")
|
||||
|
||||
if self.event_logger:
|
||||
self.event_logger.log_event(
|
||||
"info",
|
||||
"plot_session_removed",
|
||||
f"Plot session '{session.name}' removed",
|
||||
{"session_id": session_id},
|
||||
)
|
||||
|
||||
del self.sessions[session_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def control_session(self, session_id: str, action: str) -> bool:
|
||||
"""Controlar una sesión (start/stop/pause/resume/clear)"""
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
return False
|
||||
|
||||
session = self.sessions[session_id]
|
||||
|
||||
if action == "start":
|
||||
session.is_active = True
|
||||
session.is_paused = False
|
||||
if self.logger:
|
||||
self.logger.info(f"Started plot session '{session.name}'")
|
||||
|
||||
elif action == "stop":
|
||||
session.is_active = False
|
||||
if self.logger:
|
||||
self.logger.info(f"Stopped plot session '{session.name}'")
|
||||
|
||||
elif action == "pause":
|
||||
session.is_paused = True
|
||||
if self.logger:
|
||||
self.logger.info(f"Paused plot session '{session.name}'")
|
||||
|
||||
elif action == "resume":
|
||||
session.is_paused = False
|
||||
if self.logger:
|
||||
self.logger.info(f"Resumed plot session '{session.name}'")
|
||||
|
||||
elif action == "clear":
|
||||
session.clear_data()
|
||||
if self.logger:
|
||||
self.logger.info(f"Cleared data for plot session '{session.name}'")
|
||||
|
||||
return True
|
||||
|
||||
def update_data(self, dataset_id: str, variables_data: Dict[str, Any]) -> None:
|
||||
"""Actualizar datos de todas las sesiones activas usando cache del recording"""
|
||||
with self.lock:
|
||||
for session in self.sessions.values():
|
||||
if not session.is_active:
|
||||
continue
|
||||
|
||||
# Verificar trigger primero
|
||||
if session.trigger_enabled and session.trigger_variable:
|
||||
if session.trigger_variable in variables_data:
|
||||
trigger_value = variables_data[session.trigger_variable]
|
||||
if isinstance(trigger_value, bool) and session.check_trigger(
|
||||
trigger_value
|
||||
):
|
||||
# Reiniciar trace
|
||||
session.clear_data()
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Trigger activated for plot session '{session.name}' - trace restarted"
|
||||
)
|
||||
|
||||
# Actualizar datos de variables
|
||||
for variable in session.variables:
|
||||
if variable in variables_data:
|
||||
value = variables_data[variable]
|
||||
timestamp = time.time()
|
||||
session.add_data_point(variable, timestamp, value)
|
||||
|
||||
def get_session_data(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Obtener datos de una sesión específica"""
|
||||
with self.lock:
|
||||
if session_id in self.sessions:
|
||||
return self.sessions[session_id].get_plot_data()
|
||||
return None
|
||||
|
||||
def get_all_sessions_status(self) -> List[Dict[str, Any]]:
|
||||
"""Obtener estado de todas las sesiones"""
|
||||
with self.lock:
|
||||
return [session.get_status() for session in self.sessions.values()]
|
||||
|
||||
def get_active_sessions_count(self) -> int:
|
||||
"""Obtener número de sesiones activas"""
|
||||
with self.lock:
|
||||
return sum(1 for session in self.sessions.values() if session.is_active)
|
||||
|
||||
def get_available_variables(
|
||||
self, active_datasets: Set[str], datasets_config: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
"""Obtener lista de variables disponibles para plotting (solo de datasets activos)"""
|
||||
available_vars = set()
|
||||
|
||||
for dataset_id in active_datasets:
|
||||
if dataset_id in datasets_config:
|
||||
dataset_vars = datasets_config[dataset_id].get("variables", {})
|
||||
available_vars.update(dataset_vars.keys())
|
||||
|
||||
return sorted(list(available_vars))
|
||||
|
||||
def get_boolean_variables(
|
||||
self, active_datasets: Set[str], datasets_config: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
"""Obtener lista de variables boolean disponibles para trigger"""
|
||||
boolean_vars = []
|
||||
|
||||
for dataset_id in active_datasets:
|
||||
if dataset_id in datasets_config:
|
||||
dataset_vars = datasets_config[dataset_id].get("variables", {})
|
||||
for var_name, var_config in dataset_vars.items():
|
||||
if var_config.get("var_type", "").upper() == "BOOL":
|
||||
boolean_vars.append(var_name)
|
||||
|
||||
return sorted(boolean_vars)
|
|
@ -8,6 +8,7 @@ import sys
|
|||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, Set
|
||||
from pathlib import Path
|
||||
from .plot_manager import PlotManager
|
||||
|
||||
|
||||
def resource_path(relative_path):
|
||||
|
@ -62,6 +63,9 @@ class DataStreamer:
|
|||
self.last_read_timestamps = {} # dataset_id -> timestamp
|
||||
self.last_read_errors = {} # dataset_id -> {var_name: error_message}
|
||||
|
||||
# 📈 PLOT MANAGER - Real-time plotting system
|
||||
self.plot_manager = PlotManager(event_logger, logger)
|
||||
|
||||
def setup_udp_socket(self) -> bool:
|
||||
"""Setup UDP socket for PlotJuggler communication"""
|
||||
try:
|
||||
|
@ -499,6 +503,10 @@ class DataStreamer:
|
|||
if streaming_data:
|
||||
self.send_to_plotjuggler(streaming_data)
|
||||
|
||||
# 📈 PLOT MANAGER: Update all active plot sessions with cache data
|
||||
if self.plot_manager.get_active_sessions_count() > 0:
|
||||
self.plot_manager.update_data(dataset_id, all_data)
|
||||
|
||||
# Log data
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
if self.logger:
|
||||
|
|
197
main.py
197
main.py
|
@ -1205,6 +1205,203 @@ def stop_udp_streaming():
|
|||
return jsonify({"success": True, "message": "UDP streaming to PlotJuggler stopped"})
|
||||
|
||||
|
||||
# 📈 PLOT MANAGER API ENDPOINTS
|
||||
@app.route("/api/plots", methods=["GET"])
|
||||
def get_plots():
|
||||
"""Get all plot sessions status"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
sessions = streamer.data_streamer.plot_manager.get_all_sessions_status()
|
||||
return jsonify({"sessions": sessions})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots", methods=["POST"])
|
||||
def create_plot():
|
||||
"""Create a new plot session"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# Validar datos requeridos
|
||||
if not data.get("variables"):
|
||||
return jsonify({"error": "At least one variable is required"}), 400
|
||||
|
||||
if not data.get("time_window"):
|
||||
return jsonify({"error": "Time window is required"}), 400
|
||||
|
||||
# Validar que las variables existen en datasets activos
|
||||
available_vars = streamer.data_streamer.plot_manager.get_available_variables(
|
||||
streamer.data_streamer.get_active_datasets(),
|
||||
streamer.config_manager.datasets,
|
||||
)
|
||||
|
||||
invalid_vars = [var for var in data["variables"] if var not in available_vars]
|
||||
if invalid_vars:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": f"Variables not available: {', '.join(invalid_vars)}",
|
||||
"available_variables": available_vars,
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
# Validar trigger si está habilitado
|
||||
if data.get("trigger_enabled") and data.get("trigger_variable"):
|
||||
boolean_vars = streamer.data_streamer.plot_manager.get_boolean_variables(
|
||||
streamer.data_streamer.get_active_datasets(),
|
||||
streamer.config_manager.datasets,
|
||||
)
|
||||
|
||||
if data["trigger_variable"] not in boolean_vars:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": f"Trigger variable '{data['trigger_variable']}' is not a boolean variable",
|
||||
"boolean_variables": boolean_vars,
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
# Crear configuración de la sesión
|
||||
config = {
|
||||
"name": data.get(
|
||||
"name", f"Plot {len(streamer.data_streamer.plot_manager.sessions) + 1}"
|
||||
),
|
||||
"variables": data["variables"],
|
||||
"time_window": int(data["time_window"]),
|
||||
"y_min": data.get("y_min"),
|
||||
"y_max": data.get("y_max"),
|
||||
"trigger_variable": data.get("trigger_variable"),
|
||||
"trigger_enabled": data.get("trigger_enabled", False),
|
||||
"trigger_on_true": data.get("trigger_on_true", True),
|
||||
}
|
||||
|
||||
# Convertir valores numéricos si están presentes
|
||||
if config["y_min"] is not None:
|
||||
config["y_min"] = float(config["y_min"])
|
||||
if config["y_max"] is not None:
|
||||
config["y_max"] = float(config["y_max"])
|
||||
|
||||
session_id = streamer.data_streamer.plot_manager.create_session(config)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"message": f"Plot session '{config['name']}' created successfully",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/<session_id>", methods=["DELETE"])
|
||||
def remove_plot(session_id):
|
||||
"""Remove a plot session"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
success = streamer.data_streamer.plot_manager.remove_session(session_id)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "Plot session removed"})
|
||||
else:
|
||||
return jsonify({"error": "Plot session not found"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/<session_id>/control", methods=["POST"])
|
||||
def control_plot(session_id):
|
||||
"""Control a plot session (start/stop/pause/resume/clear)"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
action = data.get("action")
|
||||
|
||||
if action not in ["start", "stop", "pause", "resume", "clear"]:
|
||||
return (
|
||||
jsonify(
|
||||
{"error": "Invalid action. Use: start, stop, pause, resume, clear"}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
success = streamer.data_streamer.plot_manager.control_session(
|
||||
session_id, action
|
||||
)
|
||||
if success:
|
||||
return jsonify({"success": True, "message": f"Plot session {action}ed"})
|
||||
else:
|
||||
return jsonify({"error": "Plot session not found"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/<session_id>/data", methods=["GET"])
|
||||
def get_plot_data(session_id):
|
||||
"""Get plot data for a specific session"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
plot_data = streamer.data_streamer.plot_manager.get_session_data(session_id)
|
||||
if plot_data:
|
||||
return jsonify(plot_data)
|
||||
else:
|
||||
return jsonify({"error": "Plot session not found"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/variables", methods=["GET"])
|
||||
def get_plot_variables():
|
||||
"""Get available variables for plotting"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
available_vars = streamer.data_streamer.plot_manager.get_available_variables(
|
||||
streamer.data_streamer.get_active_datasets(),
|
||||
streamer.config_manager.datasets,
|
||||
)
|
||||
|
||||
boolean_vars = streamer.data_streamer.plot_manager.get_boolean_variables(
|
||||
streamer.data_streamer.get_active_datasets(),
|
||||
streamer.config_manager.datasets,
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"available_variables": available_vars,
|
||||
"boolean_variables": boolean_vars,
|
||||
"active_datasets_count": len(
|
||||
streamer.data_streamer.get_active_datasets()
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def get_status():
|
||||
"""Get current status"""
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"plc_config": {
|
||||
"ip": "10.1.33.249",
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2
|
||||
},
|
||||
|
@ -16,6 +16,6 @@
|
|||
"max_days": 30,
|
||||
"max_hours": null,
|
||||
"cleanup_interval_hours": 24,
|
||||
"last_cleanup": "2025-07-19T23:30:11.005072"
|
||||
"last_cleanup": "2025-07-20T23:49:17.540633"
|
||||
}
|
||||
}
|
|
@ -70,5 +70,5 @@
|
|||
],
|
||||
"current_dataset_id": "dar",
|
||||
"version": "1.0",
|
||||
"last_update": "2025-07-20T23:25:07.495201"
|
||||
"last_update": "2025-07-21T09:21:45.845720"
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
Flask==2.3.3
|
||||
python-snap7==1.3
|
||||
psutil==5.9.5
|
||||
psutil==5.9.5
|
||||
flask-socketio==5.3.6
|
|
@ -517,4 +517,280 @@ textarea {
|
|||
background-color: var(--pico-color-red-100);
|
||||
color: var(--pico-color-red-800);
|
||||
border: 1px solid var(--pico-color-red-300);
|
||||
}
|
||||
|
||||
/* 📈 PLOT SYSTEM STYLES */
|
||||
.plot-session {
|
||||
background: var(--pico-card-background-color);
|
||||
border: var(--pico-border-width) solid var(--pico-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plot-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--pico-muted-background-color);
|
||||
border-bottom: var(--pico-border-width) solid var(--pico-border-color);
|
||||
}
|
||||
|
||||
.plot-header h4 {
|
||||
margin: 0;
|
||||
color: var(--pico-h4-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.plot-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plot-controls .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.plot-info {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-bottom: var(--pico-border-width) solid var(--pico-border-color);
|
||||
font-size: 0.85rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.plot-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plot-canvas {
|
||||
padding: 1rem;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plot-canvas canvas {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Modal de creación de plots */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--pico-card-background-color);
|
||||
margin: 5% auto;
|
||||
padding: 2rem;
|
||||
border: var(--pico-border-width) solid var(--pico-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h4 {
|
||||
margin-top: 0;
|
||||
color: var(--pico-h4-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--pico-form-element-label-color);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: var(--pico-border-width) solid var(--pico-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
background: var(--pico-form-element-background-color);
|
||||
color: var(--pico-form-element-color);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: var(--pico-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.variable-checkbox {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.variable-checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.variable-checkbox input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.range-inputs input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range-inputs span {
|
||||
color: var(--pico-muted-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: var(--pico-border-width) solid var(--pico-border-color);
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
#trigger-config {
|
||||
padding: 1rem;
|
||||
background: var(--pico-muted-background-color);
|
||||
border: var(--pico-border-width) solid var(--pico-border-color);
|
||||
border-radius: var(--pico-border-radius);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
#trigger-config .form-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#trigger-config .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Responsive design para plots */
|
||||
@media (max-width: 768px) {
|
||||
.plot-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plot-controls {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plot-stats {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 10% auto;
|
||||
width: 95%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* TAB SYSTEM STYLES */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: var(--pico-border-width) solid var(--pico-border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 1rem 1.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--pico-muted-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--pico-primary);
|
||||
background: var(--pico-muted-background-color);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--pico-primary);
|
||||
border-bottom-color: var(--pico-primary);
|
||||
background: var(--pico-card-background-color);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive tabs */
|
||||
@media (max-width: 768px) {
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
border-bottom: none;
|
||||
border-right: var(--pico-border-width) solid var(--pico-border-color);
|
||||
border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-bottom: none;
|
||||
border-right: 3px solid transparent;
|
||||
text-align: left;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-right-color: var(--pico-primary);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* 📈 Real-Time Plotting System
|
||||
* Maneja sesiones de plotting con Chart.js y comunicación con el backend
|
||||
*/
|
||||
|
||||
class PlotManager {
|
||||
constructor() {
|
||||
this.sessions = new Map(); // session_id -> Chart instance
|
||||
this.updateInterval = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Colores para las variables
|
||||
this.colors = [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||||
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
|
||||
];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Cargar sesiones existentes al inicializar
|
||||
this.loadExistingSessions();
|
||||
|
||||
// Iniciar actualización automática
|
||||
this.startAutoUpdate();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('📈 Plot Manager initialized');
|
||||
}
|
||||
|
||||
async loadExistingSessions() {
|
||||
try {
|
||||
const response = await fetch('/api/plots');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.sessions) {
|
||||
for (const session of data.sessions) {
|
||||
if (session.is_active) {
|
||||
this.createPlotSession(session.session_id, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing plot sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
// Actualizar datos cada 500ms para plots activos
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.updateAllSessions();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAllSessions() {
|
||||
const activeSessions = Array.from(this.sessions.keys());
|
||||
|
||||
for (const sessionId of activeSessions) {
|
||||
await this.updateSessionData(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSessionData(sessionId) {
|
||||
try {
|
||||
const response = await fetch(`/api/plots/${sessionId}/data`);
|
||||
const plotData = await response.json();
|
||||
|
||||
if (plotData.datasets) {
|
||||
this.updateChart(sessionId, plotData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
createPlotSession(sessionId, config) {
|
||||
// Crear contenedor para el plot
|
||||
const container = document.createElement('div');
|
||||
container.className = 'plot-session';
|
||||
container.id = `plot-${sessionId}`;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="plot-header">
|
||||
<h4>📈 ${config.name || `Plot ${sessionId}`}</h4>
|
||||
<div class="plot-controls">
|
||||
<button class="btn btn-sm" onclick="plotManager.controlPlot('${sessionId}', 'start')" title="Start">
|
||||
▶️ Start
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick="plotManager.controlPlot('${sessionId}', 'pause')" title="Pause">
|
||||
⏸️ Pause
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick="plotManager.controlPlot('${sessionId}', 'clear')" title="Clear">
|
||||
🗑️ Clear
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick="plotManager.controlPlot('${sessionId}', 'stop')" title="Stop">
|
||||
⏹️ Stop
|
||||
</button>
|
||||
<button class="btn btn-sm" onclick="plotManager.removePlot('${sessionId}')" title="Remove">
|
||||
❌ Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plot-info">
|
||||
<span class="plot-stats">
|
||||
Variables: ${config.variables_count || 0} |
|
||||
Data Points: <span id="points-${sessionId}">0</span> |
|
||||
${config.trigger_enabled ? `Trigger: ${config.trigger_variable} (${config.trigger_on_true ? 'True' : 'False'})` : 'No Trigger'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plot-canvas">
|
||||
<canvas id="chart-${sessionId}"></canvas>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('plot-sessions-container').appendChild(container);
|
||||
|
||||
// Crear Chart.js
|
||||
const ctx = document.getElementById(`chart-${sessionId}`).getContext('2d');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: []
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 0 // Sin animación para mejor performance
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'second',
|
||||
displayFormats: {
|
||||
second: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, chart);
|
||||
|
||||
// Actualizar datos iniciales
|
||||
this.updateSessionData(sessionId);
|
||||
|
||||
console.log(`📈 Created plot session: ${sessionId}`);
|
||||
}
|
||||
|
||||
updateChart(sessionId, plotData) {
|
||||
const chart = this.sessions.get(sessionId);
|
||||
if (!chart) return;
|
||||
|
||||
// Actualizar datasets
|
||||
chart.data.datasets = plotData.datasets;
|
||||
|
||||
// Actualizar escalas Y si están definidas
|
||||
if (plotData.y_min !== undefined || plotData.y_max !== undefined) {
|
||||
chart.options.scales.y.min = plotData.y_min;
|
||||
chart.options.scales.y.max = plotData.y_max;
|
||||
}
|
||||
|
||||
// Actualizar contador de puntos
|
||||
const pointsElement = document.getElementById(`points-${sessionId}`);
|
||||
if (pointsElement) {
|
||||
pointsElement.textContent = plotData.data_points_count || 0;
|
||||
}
|
||||
|
||||
// Actualizar chart
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
async controlPlot(sessionId, action) {
|
||||
try {
|
||||
const response = await fetch(`/api/plots/${sessionId}/control`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action: action })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Actualizar UI según la acción
|
||||
if (action === 'stop') {
|
||||
this.removeChart(sessionId);
|
||||
} else {
|
||||
// Actualizar datos inmediatamente
|
||||
await this.updateSessionData(sessionId);
|
||||
}
|
||||
|
||||
showNotification(result.message, 'success');
|
||||
} else {
|
||||
showNotification(result.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error controlling plot ${sessionId}:`, error);
|
||||
showNotification('Error controlling plot session', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async removePlot(sessionId) {
|
||||
try {
|
||||
const response = await fetch(`/api/plots/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.removeChart(sessionId);
|
||||
showNotification('Plot session removed', 'success');
|
||||
} else {
|
||||
showNotification(result.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error removing plot ${sessionId}:`, error);
|
||||
showNotification('Error removing plot session', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
removeChart(sessionId) {
|
||||
// Destruir Chart.js
|
||||
const chart = this.sessions.get(sessionId);
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
// Remover contenedor
|
||||
const container = document.getElementById(`plot-${sessionId}`);
|
||||
if (container) {
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async createNewPlot(config) {
|
||||
try {
|
||||
const response = await fetch('/api/plots', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Crear la sesión visual
|
||||
const sessionConfig = {
|
||||
name: config.name,
|
||||
variables_count: config.variables.length,
|
||||
trigger_enabled: config.trigger_enabled,
|
||||
trigger_variable: config.trigger_variable,
|
||||
trigger_on_true: config.trigger_on_true
|
||||
};
|
||||
|
||||
this.createPlotSession(result.session_id, sessionConfig);
|
||||
showNotification(result.message, 'success');
|
||||
|
||||
return result.session_id;
|
||||
} else {
|
||||
showNotification(result.error, 'error');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating new plot:', error);
|
||||
showNotification('Error creating plot session', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getColor(variable, alpha = 1.0) {
|
||||
const index = this.hashCode(variable) % this.colors.length;
|
||||
return this.colors[index];
|
||||
}
|
||||
|
||||
hashCode(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopAutoUpdate();
|
||||
|
||||
// Destruir todos los charts
|
||||
for (const [sessionId, chart] of this.sessions) {
|
||||
chart.destroy();
|
||||
}
|
||||
this.sessions.clear();
|
||||
|
||||
console.log('📈 Plot Manager destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para el modal de creación de plots
|
||||
let plotModal = null;
|
||||
let availableVariables = [];
|
||||
let booleanVariables = [];
|
||||
|
||||
async function showNewPlotModal() {
|
||||
if (!plotModal) {
|
||||
plotModal = document.getElementById('new-plot-modal');
|
||||
}
|
||||
|
||||
// Cargar variables disponibles
|
||||
await loadPlotVariables();
|
||||
|
||||
// Limpiar formulario
|
||||
document.getElementById('new-plot-form').reset();
|
||||
|
||||
// Mostrar modal
|
||||
plotModal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeNewPlotModal() {
|
||||
if (plotModal) {
|
||||
plotModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlotVariables() {
|
||||
try {
|
||||
const response = await fetch('/api/plots/variables');
|
||||
const data = await response.json();
|
||||
|
||||
availableVariables = data.available_variables || [];
|
||||
booleanVariables = data.boolean_variables || [];
|
||||
|
||||
// Actualizar selector de variables
|
||||
updateVariableSelector();
|
||||
|
||||
// Actualizar selector de trigger
|
||||
updateTriggerSelector();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading plot variables:', error);
|
||||
showNotification('Error loading available variables', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateVariableSelector() {
|
||||
const container = document.getElementById('plot-variables-selector');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (availableVariables.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">No variables available. Activate datasets first.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const variable of availableVariables) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'variable-checkbox';
|
||||
div.innerHTML = `
|
||||
<label>
|
||||
<input type="checkbox" name="plot_variables" value="${variable}">
|
||||
<span>${variable}</span>
|
||||
</label>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTriggerSelector() {
|
||||
const triggerSelect = document.getElementById('plot-trigger-variable');
|
||||
const triggerContainer = document.getElementById('trigger-config');
|
||||
|
||||
if (booleanVariables.length === 0) {
|
||||
triggerContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
triggerContainer.style.display = 'block';
|
||||
triggerSelect.innerHTML = '<option value="">No trigger</option>';
|
||||
|
||||
for (const variable of booleanVariables) {
|
||||
const option = document.createElement('option');
|
||||
option.value = variable;
|
||||
option.textContent = variable;
|
||||
triggerSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTriggerConfig() {
|
||||
const triggerEnabled = document.getElementById('plot-trigger-enabled');
|
||||
const triggerConfig = document.getElementById('trigger-config');
|
||||
|
||||
triggerConfig.style.display = triggerEnabled.checked ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function createNewPlot() {
|
||||
const form = document.getElementById('new-plot-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Obtener variables seleccionadas
|
||||
const selectedVariables = Array.from(document.querySelectorAll('input[name="plot_variables"]:checked'))
|
||||
.map(cb => cb.value);
|
||||
|
||||
if (selectedVariables.length === 0) {
|
||||
showNotification('Please select at least one variable', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtener configuración
|
||||
const config = {
|
||||
name: document.getElementById('plot-name').value || `Plot ${Date.now()}`,
|
||||
variables: selectedVariables,
|
||||
time_window: parseInt(document.getElementById('plot-time-window').value) || 60,
|
||||
y_min: document.getElementById('plot-y-min').value || null,
|
||||
y_max: document.getElementById('plot-y-max').value || null,
|
||||
trigger_enabled: document.getElementById('plot-trigger-enabled').checked,
|
||||
trigger_variable: document.getElementById('plot-trigger-variable').value || null,
|
||||
trigger_on_true: document.getElementById('plot-trigger-on-true').checked
|
||||
};
|
||||
|
||||
// Convertir valores numéricos
|
||||
if (config.y_min) config.y_min = parseFloat(config.y_min);
|
||||
if (config.y_max) config.y_max = parseFloat(config.y_max);
|
||||
|
||||
// Crear plot
|
||||
const sessionId = await plotManager.createNewPlot(config);
|
||||
|
||||
if (sessionId) {
|
||||
closeNewPlotModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Inicialización
|
||||
let plotManager = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Inicializar Plot Manager
|
||||
plotManager = new PlotManager();
|
||||
|
||||
// Event listeners para el modal
|
||||
document.getElementById('new-plot-btn').addEventListener('click', showNewPlotModal);
|
||||
document.getElementById('new-plot-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
createNewPlot();
|
||||
});
|
||||
|
||||
// Cerrar modal con Escape
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && plotModal && plotModal.style.display === 'block') {
|
||||
closeNewPlotModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Cerrar modal haciendo clic fuera
|
||||
window.addEventListener('click', function (e) {
|
||||
if (e.target === plotModal) {
|
||||
closeNewPlotModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Función de utilidad para notificaciones
|
||||
function showNotification(message, type = 'info') {
|
||||
// Implementar según tu sistema de notificaciones
|
||||
console.log(`${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// Si tienes un sistema de notificaciones, úsalo aquí
|
||||
if (typeof showAlert === 'function') {
|
||||
showAlert(message, type);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Tab System Management
|
||||
* Maneja la navegación entre tabs en la aplicación
|
||||
*/
|
||||
|
||||
class TabManager {
|
||||
constructor() {
|
||||
this.currentTab = 'datasets';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Event listeners para los botones de tab
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const tabName = e.target.dataset.tab;
|
||||
this.switchTab(tabName);
|
||||
});
|
||||
});
|
||||
|
||||
// Inicializar con el tab activo por defecto
|
||||
this.switchTab(this.currentTab);
|
||||
|
||||
console.log('📑 Tab Manager initialized');
|
||||
}
|
||||
|
||||
switchTab(tabName) {
|
||||
// Remover clase active de todos los tabs
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Activar el tab seleccionado
|
||||
const activeBtn = document.querySelector(`[data-tab="${tabName}"]`);
|
||||
const activeContent = document.getElementById(`${tabName}-tab`);
|
||||
|
||||
if (activeBtn && activeContent) {
|
||||
activeBtn.classList.add('active');
|
||||
activeContent.classList.add('active');
|
||||
this.currentTab = tabName;
|
||||
|
||||
// Eventos específicos por tab
|
||||
this.handleTabSpecificEvents(tabName);
|
||||
|
||||
console.log(`📑 Switched to tab: ${tabName}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleTabSpecificEvents(tabName) {
|
||||
switch (tabName) {
|
||||
case 'plotting':
|
||||
// Inicializar plotting si no está inicializado
|
||||
if (typeof plotManager !== 'undefined' && !plotManager.isInitialized) {
|
||||
plotManager.init();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'events':
|
||||
// Cargar eventos si no están cargados
|
||||
if (typeof loadEvents === 'function') {
|
||||
loadEvents();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'datasets':
|
||||
// Actualizar datasets si es necesario
|
||||
if (typeof loadDatasets === 'function') {
|
||||
loadDatasets();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTab() {
|
||||
return this.currentTab;
|
||||
}
|
||||
}
|
||||
|
||||
// Inicialización
|
||||
let tabManager = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
tabManager = new TabManager();
|
||||
});
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"last_state": {
|
||||
"should_connect": true,
|
||||
"should_stream": true,
|
||||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"dar"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-07-20T23:25:10.215131"
|
||||
"last_update": "2025-07-21T09:21:45.854021"
|
||||
}
|
|
@ -123,367 +123,383 @@
|
|||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Integrated Dataset & Variables Management -->
|
||||
<article>
|
||||
<header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📊 Dataset & Variables Management</span>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<select id="dataset-selector" style="min-width: 200px;">
|
||||
<option value="">Select a dataset...</option>
|
||||
</select>
|
||||
<button type="button" id="new-dataset-btn" class="outline">➕ New</button>
|
||||
<button type="button" id="delete-dataset-btn" class="secondary">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="datasets">📊 Datasets & Variables</button>
|
||||
<button class="tab-btn" data-tab="plotting">📈 Real-Time Plotting</button>
|
||||
<button class="tab-btn" data-tab="events">📋 Events & Logs</button>
|
||||
</nav>
|
||||
|
||||
<!-- Dataset Status Bar -->
|
||||
<div id="dataset-status-bar" style="display: none; margin-bottom: 1rem;">
|
||||
<div class="info-section" style="margin-bottom: 0;">
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
|
||||
<span><strong>📂 Name:</strong> <span id="dataset-name"></span></span>
|
||||
<span><strong>🏷️ Prefix:</strong> <span id="dataset-prefix"></span></span>
|
||||
<span><strong>⏱️ Sampling:</strong> <span id="dataset-sampling"></span></span>
|
||||
<span><strong>📊 Variables:</strong> <span id="dataset-var-count"></span></span>
|
||||
<span><strong>📡 Streaming:</strong> <span id="dataset-stream-count"></span></span>
|
||||
</div>
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content active" id="datasets-tab">
|
||||
<!-- Integrated Dataset & Variables Management -->
|
||||
<article>
|
||||
<header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📊 Dataset & Variables Management</span>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span id="dataset-status" class="status-item"></span>
|
||||
<button type="button" id="activate-dataset-btn" class="outline">▶️ Activate</button>
|
||||
<button type="button" id="deactivate-dataset-btn" class="secondary">⏹️ Deactivate</button>
|
||||
<select id="dataset-selector" style="min-width: 200px;">
|
||||
<option value="">Select a dataset...</option>
|
||||
</select>
|
||||
<button type="button" id="new-dataset-btn" class="outline">➕ New</button>
|
||||
<button type="button" id="delete-dataset-btn" class="secondary">🗑️ Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dataset Status Bar -->
|
||||
<div id="dataset-status-bar" style="display: none; margin-bottom: 1rem;">
|
||||
<div class="info-section" style="margin-bottom: 0;">
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
|
||||
<span><strong>📂 Name:</strong> <span id="dataset-name"></span></span>
|
||||
<span><strong>🏷️ Prefix:</strong> <span id="dataset-prefix"></span></span>
|
||||
<span><strong>⏱️ Sampling:</strong> <span id="dataset-sampling"></span></span>
|
||||
<span><strong>📊 Variables:</strong> <span id="dataset-var-count"></span></span>
|
||||
<span><strong>📡 Streaming:</strong> <span id="dataset-stream-count"></span></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span id="dataset-status" class="status-item"></span>
|
||||
<button type="button" id="activate-dataset-btn" class="outline">▶️ Activate</button>
|
||||
<button type="button" id="deactivate-dataset-btn" class="secondary">⏹️
|
||||
Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variables Management Section -->
|
||||
<div id="variables-management" style="display: none;">
|
||||
<!-- Real-time Variable Monitoring Info -->
|
||||
<div
|
||||
style="margin-bottom: 1rem; padding: 1rem; background: var(--pico-card-background-color); border-radius: var(--pico-border-radius); border: var(--pico-border-width) solid var(--pico-border-color);">
|
||||
<!-- Variables Management Section -->
|
||||
<div id="variables-management" style="display: none;">
|
||||
<!-- Real-time Variable Monitoring Info -->
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<strong>📊 Automatic Variable Monitoring</strong>
|
||||
<br>
|
||||
<small style="color: var(--pico-muted-color);">
|
||||
Variables are automatically monitored and recorded when PLC is connected and dataset is
|
||||
active
|
||||
</small>
|
||||
style="margin-bottom: 1rem; padding: 1rem; background: var(--pico-card-background-color); border-radius: var(--pico-border-radius); border: var(--pico-border-width) solid var(--pico-border-color);">
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<strong>📊 Automatic Variable Monitoring</strong>
|
||||
<br>
|
||||
<small style="color: var(--pico-muted-color);">
|
||||
Variables are automatically monitored and recorded when PLC is connected and dataset
|
||||
is
|
||||
active
|
||||
</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button type="button" id="toggle-streaming-btn" class="outline"
|
||||
onclick="toggleRealTimeStreaming()">
|
||||
▶️ Start Live Display
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button type="button" id="toggle-streaming-btn" class="outline"
|
||||
onclick="toggleRealTimeStreaming()">
|
||||
▶️ Start Live Display
|
||||
</button>
|
||||
<div id="last-refresh-time"
|
||||
style="margin-top: 0.5rem; font-size: 0.9em; color: var(--pico-muted-color);">
|
||||
Live display shows cached values from automatic monitoring
|
||||
</div>
|
||||
</div>
|
||||
<div id="last-refresh-time"
|
||||
style="margin-top: 0.5rem; font-size: 0.9em; color: var(--pico-muted-color);">
|
||||
Live display shows cached values from automatic monitoring
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Variable Form -->
|
||||
<form id="variable-form" style="margin-bottom: 1.5rem;">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Variable Name:
|
||||
<input type="text" id="var-name" placeholder="temperature" required>
|
||||
</label>
|
||||
<label>
|
||||
Memory Area:
|
||||
<select id="var-area" required onchange="toggleFields()">
|
||||
<option value="db">DB (Data Block)</option>
|
||||
<option value="mw">MW (Memory Words)</option>
|
||||
<option value="pew">PEW (Process Input Words)</option>
|
||||
<option value="paw">PAW (Process Output Words)</option>
|
||||
<option value="e">E (Input Bits)</option>
|
||||
<option value="a">A (Output Bits)</option>
|
||||
<option value="mb">MB (Memory Bits)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="db-field">
|
||||
Data Block (DB):
|
||||
<input type="number" id="var-db" min="1" max="9999" value="1" required>
|
||||
</label>
|
||||
<label>
|
||||
Offset:
|
||||
<input type="number" id="var-offset" min="0" max="8192" value="0" required>
|
||||
</label>
|
||||
<label id="bit-field" style="display: none;">
|
||||
Bit Position:
|
||||
<select id="var-bit">
|
||||
<option value="0">0</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Data Type:
|
||||
<select id="var-type" required>
|
||||
<option value="real">REAL (Float 32-bit)</option>
|
||||
<option value="int">INT (16-bit Signed)</option>
|
||||
<option value="uint">UINT (16-bit Unsigned)</option>
|
||||
<option value="dint">DINT (32-bit Signed)</option>
|
||||
<option value="udint">UDINT (32-bit Unsigned)</option>
|
||||
<option value="word">WORD (16-bit)</option>
|
||||
<option value="byte">BYTE (8-bit)</option>
|
||||
<option value="sint">SINT (8-bit Signed)</option>
|
||||
<option value="usint">USINT (8-bit Unsigned)</option>
|
||||
<option value="bool">BOOL</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">➕ Add Variable</button>
|
||||
</form>
|
||||
|
||||
<!-- Variables Table -->
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h4 style="margin: 0;">📊 Variables in Dataset</h4>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div style="margin-left: 1rem;">
|
||||
<span id="last-refresh-time"
|
||||
style="color: var(--pico-muted-color); font-size: 0.9rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="variables-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Memory Area</th>
|
||||
<th>Offset</th>
|
||||
<th>Type</th>
|
||||
<th>Current Value</th>
|
||||
<th>Stream to PlotJuggler</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="variables-tbody">
|
||||
{% for name, var in variables.items() %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
{% if var.area == 'db' or var.get('db') %}
|
||||
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
|
||||
{% elif var.area == 'mw' or var.area == 'm' %}
|
||||
MW{{ var.offset }}
|
||||
{% elif var.area == 'pew' or var.area == 'pe' %}
|
||||
PEW{{ var.offset }}
|
||||
{% elif var.area == 'paw' or var.area == 'pa' %}
|
||||
PAW{{ var.offset }}
|
||||
{% elif var.area == 'e' %}
|
||||
E{{ var.offset }}.{{ var.bit }}
|
||||
{% elif var.area == 'a' %}
|
||||
A{{ var.offset }}.{{ var.bit }}
|
||||
{% elif var.area == 'mb' %}
|
||||
M{{ var.offset }}.{{ var.bit }}
|
||||
{% else %}
|
||||
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ var.offset }}</td>
|
||||
<td>{{ var.type.upper() }}</td>
|
||||
<td id="value-{{ name }}"
|
||||
style="font-family: monospace; font-weight: bold; color: var(--pico-muted-color);">
|
||||
--
|
||||
</td>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" id="stream-{{ name }}" role="switch"
|
||||
onchange="toggleStreaming('{{ name }}', this.checked)">
|
||||
Enable
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="outline" onclick="editVariable('{{ name }}')">✏️ Edit</button>
|
||||
<button class="secondary" onclick="removeVariable('{{ name }}')">🗑️ Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- No Dataset Selected Message -->
|
||||
<div id="no-dataset-message" style="text-align: center; padding: 2rem; color: var(--pico-muted-color);">
|
||||
<p>📊 Please select a dataset to manage its variables</p>
|
||||
<p>Or create a new dataset to get started</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- PlotJuggler Streaming Control -->
|
||||
<article>
|
||||
<header>📡 PlotJuggler UDP Streaming Control</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📡 UDP Streaming:</strong> Sends only variables marked for streaming to PlotJuggler via UDP
|
||||
</p>
|
||||
<p><strong>💾 Automatic Recording:</strong> When PLC is connected, all datasets with variables
|
||||
automatically record to CSV files</p>
|
||||
<p><strong>📁 File Organization:</strong> records/[dd-mm-yyyy]/[prefix]_[hour].csv (e.g., temp_14.csv,
|
||||
pressure_14.csv)</p>
|
||||
<p><strong>⏱️ Independent Operation:</strong> CSV recording works independently of UDP streaming -
|
||||
always active when PLC is connected</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="start-streaming-btn">📡 Start UDP Streaming</button>
|
||||
<button class="secondary" id="stop-streaming-btn">⏹️ Stop UDP Streaming</button>
|
||||
<button class="outline" onclick="location.reload()">🔄 Refresh Status</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- CSV Recording Configuration -->
|
||||
<article>
|
||||
<header>📁 CSV Recording Configuration</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📂 Directory Management:</strong> Configure where CSV files are saved and manage file
|
||||
rotation</p>
|
||||
<p><strong>🔄 Automatic Cleanup:</strong> Set limits by size, days, or hours to automatically delete old
|
||||
files</p>
|
||||
<p><strong>💿 Disk Space:</strong> Monitor available space and estimated recording time remaining</p>
|
||||
</div>
|
||||
|
||||
<!-- Current Configuration Display -->
|
||||
<div class="csv-config-display" id="csv-config-display">
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<strong>📁 Records Directory:</strong>
|
||||
<span id="csv-directory-path">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>🔄 Rotation Enabled:</strong>
|
||||
<span id="csv-rotation-enabled">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>📊 Max Size:</strong>
|
||||
<span id="csv-max-size">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>📅 Max Days:</strong>
|
||||
<span id="csv-max-days">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>⏰ Max Hours:</strong>
|
||||
<span id="csv-max-hours">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>🧹 Cleanup Interval:</strong>
|
||||
<span id="csv-cleanup-interval">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Information -->
|
||||
<div class="csv-directory-info" id="csv-directory-info" style="margin-top: 1rem;">
|
||||
<details>
|
||||
<summary><strong>📊 Directory Information</strong></summary>
|
||||
<div class="directory-stats" id="directory-stats">
|
||||
<p>Loading directory information...</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary><strong>⚙️ Modify Configuration</strong></summary>
|
||||
<form id="csv-config-form" style="margin-top: 1rem;">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="records-directory">📁 Records Directory:</label>
|
||||
<input type="text" id="records-directory" name="records_directory" placeholder="records"
|
||||
required>
|
||||
<small>Base directory where CSV files will be saved</small>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Add Variable Form -->
|
||||
<form id="variable-form" style="margin-bottom: 1.5rem;">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<input type="checkbox" id="rotation-enabled" name="rotation_enabled">
|
||||
🔄 Enable File Rotation
|
||||
Variable Name:
|
||||
<input type="text" id="var-name" placeholder="temperature" required>
|
||||
</label>
|
||||
<small>Automatically delete old files based on limits below</small>
|
||||
<label>
|
||||
Memory Area:
|
||||
<select id="var-area" required onchange="toggleFields()">
|
||||
<option value="db">DB (Data Block)</option>
|
||||
<option value="mw">MW (Memory Words)</option>
|
||||
<option value="pew">PEW (Process Input Words)</option>
|
||||
<option value="paw">PAW (Process Output Words)</option>
|
||||
<option value="e">E (Input Bits)</option>
|
||||
<option value="a">A (Output Bits)</option>
|
||||
<option value="mb">MB (Memory Bits)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="db-field">
|
||||
Data Block (DB):
|
||||
<input type="number" id="var-db" min="1" max="9999" value="1" required>
|
||||
</label>
|
||||
<label>
|
||||
Offset:
|
||||
<input type="number" id="var-offset" min="0" max="8192" value="0" required>
|
||||
</label>
|
||||
<label id="bit-field" style="display: none;">
|
||||
Bit Position:
|
||||
<select id="var-bit">
|
||||
<option value="0">0</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Data Type:
|
||||
<select id="var-type" required>
|
||||
<option value="real">REAL (Float 32-bit)</option>
|
||||
<option value="int">INT (16-bit Signed)</option>
|
||||
<option value="uint">UINT (16-bit Unsigned)</option>
|
||||
<option value="dint">DINT (32-bit Signed)</option>
|
||||
<option value="udint">UDINT (32-bit Unsigned)</option>
|
||||
<option value="word">WORD (16-bit)</option>
|
||||
<option value="byte">BYTE (8-bit)</option>
|
||||
<option value="sint">SINT (8-bit Signed)</option>
|
||||
<option value="usint">USINT (8-bit Unsigned)</option>
|
||||
<option value="bool">BOOL</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">➕ Add Variable</button>
|
||||
</form>
|
||||
|
||||
<!-- Variables Table -->
|
||||
<div
|
||||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h4 style="margin: 0;">📊 Variables in Dataset</h4>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div style="margin-left: 1rem;">
|
||||
<span id="last-refresh-time"
|
||||
style="color: var(--pico-muted-color); font-size: 0.9rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="max-size-mb">📊 Max Total Size (MB):</label>
|
||||
<input type="number" id="max-size-mb" name="max_size_mb" min="1" step="1"
|
||||
placeholder="1000">
|
||||
<small>Maximum total size of all CSV files in MB (leave empty for no limit)</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="max-days">📅 Max Days to Keep:</label>
|
||||
<input type="number" id="max-days" name="max_days" min="1" step="1" placeholder="30">
|
||||
<small>Delete files older than this many days (leave empty for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="max-hours">⏰ Max Hours to Keep:</label>
|
||||
<input type="number" id="max-hours" name="max_hours" min="1" step="1" placeholder="">
|
||||
<small>Delete files older than this many hours (overrides days setting)</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cleanup-interval">🧹 Cleanup Interval (hours):</label>
|
||||
<input type="number" id="cleanup-interval" name="cleanup_interval_hours" min="1" step="1"
|
||||
value="24" required>
|
||||
<small>How often to run automatic cleanup</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button type="submit">💾 Save Configuration</button>
|
||||
<button type="button" class="secondary" onclick="loadCsvConfig()">🔄 Reload</button>
|
||||
<button type="button" class="outline" onclick="triggerManualCleanup()">🧹 Manual
|
||||
Cleanup</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
|
||||
<!-- Application Events Log -->
|
||||
<article>
|
||||
<header>📋 Application Events Log</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📝 Event Tracking:</strong> Connection events, configuration changes, errors and system
|
||||
status</p>
|
||||
<p><strong>💾 Persistent Storage:</strong> Events are saved to disk and persist between application
|
||||
restarts</p>
|
||||
</div>
|
||||
|
||||
<div class="log-controls">
|
||||
<button class="outline" onclick="refreshEventLog()">🔄 Refresh Log</button>
|
||||
<button class="outline" onclick="clearLogView()">🧹 Clear View</button>
|
||||
<select id="log-limit" onchange="refreshEventLog()">
|
||||
<option value="25">Last 25 events</option>
|
||||
<option value="50" selected>Last 50 events</option>
|
||||
<option value="100">Last 100 events</option>
|
||||
<option value="200">Last 200 events</option>
|
||||
</select>
|
||||
<div class="log-stats" id="log-stats">
|
||||
Loading log statistics...
|
||||
<table class="variables-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Memory Area</th>
|
||||
<th>Offset</th>
|
||||
<th>Type</th>
|
||||
<th>Current Value</th>
|
||||
<th>Stream to PlotJuggler</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="variables-tbody">
|
||||
{% for name, var in variables.items() %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
{% if var.area == 'db' or var.get('db') %}
|
||||
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
|
||||
{% elif var.area == 'mw' or var.area == 'm' %}
|
||||
MW{{ var.offset }}
|
||||
{% elif var.area == 'pew' or var.area == 'pe' %}
|
||||
PEW{{ var.offset }}
|
||||
{% elif var.area == 'paw' or var.area == 'pa' %}
|
||||
PAW{{ var.offset }}
|
||||
{% elif var.area == 'e' %}
|
||||
E{{ var.offset }}.{{ var.bit }}
|
||||
{% elif var.area == 'a' %}
|
||||
A{{ var.offset }}.{{ var.bit }}
|
||||
{% elif var.area == 'mb' %}
|
||||
M{{ var.offset }}.{{ var.bit }}
|
||||
{% else %}
|
||||
DB{{ var.get('db', 'N/A') }}.{{ var.offset }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ var.offset }}</td>
|
||||
<td>{{ var.type.upper() }}</td>
|
||||
<td id="value-{{ name }}"
|
||||
style="font-family: monospace; font-weight: bold; color: var(--pico-muted-color);">
|
||||
--
|
||||
</td>
|
||||
<td>
|
||||
<label>
|
||||
<input type="checkbox" id="stream-{{ name }}" role="switch"
|
||||
onchange="toggleStreaming('{{ name }}', this.checked)">
|
||||
Enable
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button class="outline" onclick="editVariable('{{ name }}')">✏️ Edit</button>
|
||||
<button class="secondary" onclick="removeVariable('{{ name }}')">🗑️ Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" id="events-log">
|
||||
<div class="log-entry log-info">
|
||||
<div class="log-header">
|
||||
<span>📡 System</span>
|
||||
<span class="log-timestamp">Loading...</span>
|
||||
</div>
|
||||
<div class="log-message">Loading application events...</div>
|
||||
<!-- No Dataset Selected Message -->
|
||||
<div id="no-dataset-message" style="text-align: center; padding: 2rem; color: var(--pico-muted-color);">
|
||||
<p>📊 Please select a dataset to manage its variables</p>
|
||||
<p>Or create a new dataset to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
|
||||
<!-- PlotJuggler Streaming Control -->
|
||||
<article>
|
||||
<header>📡 PlotJuggler UDP Streaming Control</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📡 UDP Streaming:</strong> Sends only variables marked for streaming to PlotJuggler via
|
||||
UDP
|
||||
</p>
|
||||
<p><strong>💾 Automatic Recording:</strong> When PLC is connected, all datasets with variables
|
||||
automatically record to CSV files</p>
|
||||
<p><strong>📁 File Organization:</strong> records/[dd-mm-yyyy]/[prefix]_[hour].csv (e.g.,
|
||||
temp_14.csv,
|
||||
pressure_14.csv)</p>
|
||||
<p><strong>⏱️ Independent Operation:</strong> CSV recording works independently of UDP streaming -
|
||||
always active when PLC is connected</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="start-streaming-btn">📡 Start UDP Streaming</button>
|
||||
<button class="secondary" id="stop-streaming-btn">⏹️ Stop UDP Streaming</button>
|
||||
<button class="outline" onclick="location.reload()">🔄 Refresh Status</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- CSV Recording Configuration -->
|
||||
<article>
|
||||
<header>📁 CSV Recording Configuration</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📂 Directory Management:</strong> Configure where CSV files are saved and manage file
|
||||
rotation</p>
|
||||
<p><strong>🔄 Automatic Cleanup:</strong> Set limits by size, days, or hours to automatically delete
|
||||
old
|
||||
files</p>
|
||||
<p><strong>💿 Disk Space:</strong> Monitor available space and estimated recording time remaining
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Current Configuration Display -->
|
||||
<div class="csv-config-display" id="csv-config-display">
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<strong>📁 Records Directory:</strong>
|
||||
<span id="csv-directory-path">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>🔄 Rotation Enabled:</strong>
|
||||
<span id="csv-rotation-enabled">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>📊 Max Size:</strong>
|
||||
<span id="csv-max-size">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>📅 Max Days:</strong>
|
||||
<span id="csv-max-days">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>⏰ Max Hours:</strong>
|
||||
<span id="csv-max-hours">Loading...</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<strong>🧹 Cleanup Interval:</strong>
|
||||
<span id="csv-cleanup-interval">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Information -->
|
||||
<div class="csv-directory-info" id="csv-directory-info" style="margin-top: 1rem;">
|
||||
<details>
|
||||
<summary><strong>📊 Directory Information</strong></summary>
|
||||
<div class="directory-stats" id="directory-stats">
|
||||
<p>Loading directory information...</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Form -->
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary><strong>⚙️ Modify Configuration</strong></summary>
|
||||
<form id="csv-config-form" style="margin-top: 1rem;">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="records-directory">📁 Records Directory:</label>
|
||||
<input type="text" id="records-directory" name="records_directory" placeholder="records"
|
||||
required>
|
||||
<small>Base directory where CSV files will be saved</small>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" id="rotation-enabled" name="rotation_enabled">
|
||||
🔄 Enable File Rotation
|
||||
</label>
|
||||
<small>Automatically delete old files based on limits below</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="max-size-mb">📊 Max Total Size (MB):</label>
|
||||
<input type="number" id="max-size-mb" name="max_size_mb" min="1" step="1"
|
||||
placeholder="1000">
|
||||
<small>Maximum total size of all CSV files in MB (leave empty for no limit)</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="max-days">📅 Max Days to Keep:</label>
|
||||
<input type="number" id="max-days" name="max_days" min="1" step="1" placeholder="30">
|
||||
<small>Delete files older than this many days (leave empty for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="max-hours">⏰ Max Hours to Keep:</label>
|
||||
<input type="number" id="max-hours" name="max_hours" min="1" step="1" placeholder="">
|
||||
<small>Delete files older than this many hours (overrides days setting)</small>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cleanup-interval">🧹 Cleanup Interval (hours):</label>
|
||||
<input type="number" id="cleanup-interval" name="cleanup_interval_hours" min="1"
|
||||
step="1" value="24" required>
|
||||
<small>How often to run automatic cleanup</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button type="submit">💾 Save Configuration</button>
|
||||
<button type="button" class="secondary" onclick="loadCsvConfig()">🔄 Reload</button>
|
||||
<button type="button" class="outline" onclick="triggerManualCleanup()">🧹 Manual
|
||||
Cleanup</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
|
||||
<!-- Application Events Log -->
|
||||
<article>
|
||||
<header>📋 Application Events Log</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📝 Event Tracking:</strong> Connection events, configuration changes, errors and system
|
||||
status</p>
|
||||
<p><strong>💾 Persistent Storage:</strong> Events are saved to disk and persist between application
|
||||
restarts</p>
|
||||
</div>
|
||||
|
||||
<div class="log-controls">
|
||||
<button class="outline" onclick="refreshEventLog()">🔄 Refresh Log</button>
|
||||
<button class="outline" onclick="clearLogView()">🧹 Clear View</button>
|
||||
<select id="log-limit" onchange="refreshEventLog()">
|
||||
<option value="25">Last 25 events</option>
|
||||
<option value="50" selected>Last 50 events</option>
|
||||
<option value="100">Last 100 events</option>
|
||||
<option value="200">Last 200 events</option>
|
||||
</select>
|
||||
<div class="log-stats" id="log-stats">
|
||||
Loading log statistics...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" id="events-log">
|
||||
<div class="log-entry log-info">
|
||||
<div class="log-header">
|
||||
<span>📡 System</span>
|
||||
<span class="log-timestamp">Loading...</span>
|
||||
</div>
|
||||
<div class="log-message">Loading application events...</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<!-- Edit Variable Modal -->
|
||||
|
@ -555,6 +571,122 @@
|
|||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 📈 PLOTTING TAB -->
|
||||
<div class="tab-content" id="plotting-tab">
|
||||
<article>
|
||||
<header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>📈 Real-Time Plotting</span>
|
||||
<button type="button" id="new-plot-btn" class="outline">➕ New Plot</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="info-section">
|
||||
<p><strong>📈 Real-Time Plotting:</strong> Create interactive charts using cached data from active
|
||||
datasets</p>
|
||||
<p><strong>🎯 Trigger System:</strong> Use boolean variables to automatically restart traces when
|
||||
conditions are met</p>
|
||||
<p><strong>⚡ Performance:</strong> Uses recording cache - no additional PLC load</p>
|
||||
<p><strong>📊 Multiple Sessions:</strong> Create multiple independent plot sessions with different
|
||||
variables and settings</p>
|
||||
</div>
|
||||
|
||||
<!-- Plot Sessions Container -->
|
||||
<div id="plot-sessions-container">
|
||||
<div style="text-align: center; padding: 2rem; color: var(--pico-muted-color);">
|
||||
<p>📈 No plot sessions created yet</p>
|
||||
<p>Click "New Plot" to create your first real-time chart</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 📋 EVENTS TAB -->
|
||||
<div class="tab-content" id="events-tab">
|
||||
<article>
|
||||
<header>📋 Events & System Logs</header>
|
||||
<div class="info-section">
|
||||
<p><strong>📋 Event Logging:</strong> Monitor system events, errors, and operational status</p>
|
||||
<p><strong>🔍 Real-time Updates:</strong> Events are automatically updated as they occur</p>
|
||||
<p><strong>📊 Filtering:</strong> Filter events by type and time range</p>
|
||||
</div>
|
||||
|
||||
<div class="log-controls">
|
||||
<button id="refresh-events-btn">🔄 Refresh Events</button>
|
||||
<button id="clear-events-btn" class="secondary">🗑️ Clear Events</button>
|
||||
<div class="log-stats">
|
||||
<span id="events-count">Loading...</span> events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container" id="events-container">
|
||||
<p>Loading events...</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- New Plot Modal -->
|
||||
<div id="new-plot-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<h4>Create New Plot Session</h4>
|
||||
<form id="new-plot-form">
|
||||
<div class="form-group">
|
||||
<label for="plot-name">Plot Name:</label>
|
||||
<input type="text" id="plot-name" placeholder="Temperature Monitoring" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Variables to Plot:</label>
|
||||
<div id="plot-variables-selector">
|
||||
<p>Loading available variables...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="plot-time-window">Time Window (seconds):</label>
|
||||
<input type="number" id="plot-time-window" value="60" min="10" max="3600" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Y-Axis Range (optional):</label>
|
||||
<div class="range-inputs">
|
||||
<input type="number" id="plot-y-min" placeholder="Auto Min" step="any">
|
||||
<span>to</span>
|
||||
<input type="number" id="plot-y-max" placeholder="Auto Max" step="any">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="plot-trigger-enabled" onchange="toggleTriggerConfig()">
|
||||
Enable Trigger System
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="trigger-config" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="plot-trigger-variable">Trigger Variable:</label>
|
||||
<select id="plot-trigger-variable">
|
||||
<option value="">No trigger</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="plot-trigger-on-true" checked>
|
||||
Trigger on True (uncheck for False)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Create Plot</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeNewPlotModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Dataset Modal -->
|
||||
<div id="dataset-modal" class="modal" style="display: none;">
|
||||
|
@ -592,6 +724,10 @@
|
|||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js Libraries -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
|
||||
|
||||
<!-- JavaScript Modules -->
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
|
@ -602,6 +738,8 @@
|
|||
<script src="/static/js/streaming.js"></script>
|
||||
<script src="/static/js/csv.js"></script>
|
||||
<script src="/static/js/events.js"></script>
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/plotting.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
</body>
|
||||
|
||||
|
|
Loading…
Reference in New Issue