Actualización del sistema de plotting en tiempo real con nuevas funcionalidades, incluyendo la creación y edición de sesiones de plot a través de un formulario colapsable. Se implementaron nuevos endpoints API para obtener y actualizar la configuración de las sesiones de plot. Además, se mejoró la interfaz de usuario con sub-tabs dinámicos para gestionar múltiples sesiones de plot y se realizaron ajustes en los estilos CSS para una mejor experiencia visual. Se actualizaron los archivos de configuración y estado del sistema para reflejar estos cambios.

This commit is contained in:
Miguel 2025-07-21 12:30:26 +02:00
parent a13baed5c6
commit 5e575fd112
30 changed files with 8693 additions and 268 deletions

View File

@ -3273,8 +3273,665 @@
"time_window": 10,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T09:39:40.657456",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T09:39:40.680497",
"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:39:40.684807",
"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:40:30.518857",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T09:40:30.542851",
"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:40:30.546860",
"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:41:09.159957",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'test' created",
"details": {
"session_id": "plot_0",
"variables": [
"CTS306_PV"
],
"time_window": 60,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T10:09:40.951446",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'test' created",
"details": {
"session_id": "plot_1",
"variables": [
"CTS306_PV"
],
"time_window": 10,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T10:10:03.044358",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T10:10:03.070132",
"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-21T10:10:03.074143",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T10:10:48.758561",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'test' created",
"details": {
"session_id": "plot_2",
"variables": [
"CTS306_PV"
],
"time_window": 10,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T10:28:02.672364",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T10:28:02.693756",
"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-21T10:28:02.697814",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T10:28:56.265087",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'temp' created",
"details": {
"session_id": "plot_3",
"variables": [
"CTS306_PV"
],
"time_window": 10,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T10:49:21.053339",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-07-21T10:49:21.057745",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-07-21T10:49:21.063745",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "dar"
}
},
{
"timestamp": "2025-07-21T10:49:21.068745",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-07-21T10:49:27.413472",
"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-21T10:49:27.420917",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T10:49:27.426918",
"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-21T10:51:12.198006",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T10:51:12.222774",
"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-21T10:51:12.227786",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T10:51:52.901833",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'Cond' created",
"details": {
"session_id": "plot_4",
"variables": [
"CTS306_PV"
],
"time_window": 60,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T11:22:36.755640",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T11:22:36.781108",
"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-21T11:22:36.785849",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T11:22:56.274910",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'test' removed",
"details": {
"session_id": "plot_0"
}
},
{
"timestamp": "2025-07-21T11:22:59.278159",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'test' removed",
"details": {
"session_id": "plot_1"
}
},
{
"timestamp": "2025-07-21T11:23:01.427750",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'test' removed",
"details": {
"session_id": "plot_2"
}
},
{
"timestamp": "2025-07-21T11:23:04.342691",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'temp' removed",
"details": {
"session_id": "plot_3"
}
},
{
"timestamp": "2025-07-21T11:23:07.835356",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'Cond' removed",
"details": {
"session_id": "plot_4"
}
},
{
"timestamp": "2025-07-21T11:23:28.573576",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'Cond' created",
"details": {
"session_id": "plot_5",
"variables": [
"CTS306_PV"
],
"time_window": 60,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T11:23:59.616795",
"level": "info",
"event_type": "plot_session_updated",
"message": "Plot session 'Cond' configuration updated",
"details": {
"session_id": "plot_5",
"new_config": {
"name": "Cond",
"variables": [
"UR29_Brix"
],
"time_window": 10,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true
}
}
},
{
"timestamp": "2025-07-21T11:24:11.314102",
"level": "info",
"event_type": "plot_session_removed",
"message": "Plot session 'Cond' removed",
"details": {
"session_id": "plot_5"
}
},
{
"timestamp": "2025-07-21T11:25:39.077731",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'Condix' created",
"details": {
"session_id": "plot_6",
"variables": [
"CTS306_PV",
"UR29_Brix"
],
"time_window": 60,
"trigger_variable": null
}
},
{
"timestamp": "2025-07-21T11:26:04.419881",
"level": "info",
"event_type": "plot_session_updated",
"message": "Plot session 'Condix' configuration updated",
"details": {
"session_id": "plot_6",
"new_config": {
"name": "Condix",
"variables": [
"UR62_Brix"
],
"time_window": 60,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true
}
}
},
{
"timestamp": "2025-07-21T11:40:49.279919",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T11:40:49.300672",
"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-21T11:40:49.306512",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T11:49:04.783801",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T11:49:04.807533",
"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-21T11:49:04.811438",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T12:09:29.076716",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T12:09:29.104501",
"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-21T12:09:29.112409",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T12:15:31.494921",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T12:15:31.516212",
"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-21T12:15:31.521785",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T12:15:48.946451",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T12:15:48.968995",
"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-21T12:15:48.977924",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T12:15:52.270831",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-07-21T12:15:52.278439",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-07-21T12:15:52.396232",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "dar"
}
},
{
"timestamp": "2025-07-21T12:15:52.402205",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-07-21T12:28:56.732253",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-21T12:29:22.706547",
"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-21T12:29:22.715569",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2
}
},
{
"timestamp": "2025-07-21T12:29:22.723751",
"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"
]
}
}
],
"last_updated": "2025-07-21T09:25:26.290146",
"total_entries": 309
"last_updated": "2025-07-21T12:29:22.723751",
"total_entries": 372
}

View File

@ -1,12 +1,27 @@
import threading
import time
import json
import os
from collections import deque
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List, Set
import logging
def resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
import sys
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = sys._MEIPASS
except Exception:
# Not running in a bundle
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class PlotSession:
"""Representa una sesión de plotting individual con configuración específica"""
@ -184,6 +199,12 @@ class PlotManager:
self.event_logger = event_logger
self.logger = logger
# Persistent storage
self.plots_file = resource_path("plot_sessions.json")
# Load existing plots from disk
self.load_plots()
def create_session(self, config: Dict[str, Any]) -> str:
"""Crear una nueva sesión de plotting"""
with self.lock:
@ -191,11 +212,18 @@ class PlotManager:
self.session_counter += 1
session = PlotSession(session_id, config)
# Por defecto, crear sesiones en modo stopped
session.is_active = False
session.is_paused = False
self.sessions[session_id] = session
# Guardar automáticamente la configuración
self.save_plots()
if self.logger:
self.logger.info(
f"Created plot session '{session.name}' with {len(session.variables)} variables"
f"Created plot session '{session.name}' with {len(session.variables)} variables (stopped by default)"
)
if self.event_logger:
@ -232,6 +260,10 @@ class PlotManager:
)
del self.sessions[session_id]
# Guardar automáticamente después de eliminar
self.save_plots()
return True
return False
@ -311,6 +343,163 @@ class PlotManager:
with self.lock:
return [session.get_status() for session in self.sessions.values()]
def load_plots(self):
"""Cargar plots persistentes desde archivo"""
try:
if os.path.exists(self.plots_file):
with open(self.plots_file, "r", encoding="utf-8") as f:
data = json.load(f)
plots_data = data.get("plots", {})
for session_id, plot_config in plots_data.items():
# Crear sesión con configuración guardada
session = PlotSession(session_id, plot_config)
# IMPORTANTE: Iniciar todas las sesiones en modo stopped
session.is_active = False
session.is_paused = False
self.sessions[session_id] = session
# Actualizar contador para evitar IDs duplicados
try:
session_num = int(session_id.split("_")[1])
if session_num >= self.session_counter:
self.session_counter = session_num + 1
except (IndexError, ValueError):
pass
if self.logger and self.sessions:
self.logger.info(
f"Loaded {len(self.sessions)} persistent plot sessions (all stopped)"
)
else:
if self.logger:
self.logger.info(
"No persistent plots file found, starting with empty plots"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error loading persistent plots: {e}")
self.sessions = {}
def save_plots(self):
"""Guardar plots al archivo de persistencia"""
try:
plots_data = {}
for session_id, session in self.sessions.items():
# Guardar configuración de la sesión (sin datos temporales)
plots_data[session_id] = {
"name": session.name,
"variables": session.variables,
"time_window": session.time_window,
"y_min": session.y_min,
"y_max": session.y_max,
"trigger_variable": session.trigger_variable,
"trigger_enabled": session.trigger_enabled,
"trigger_on_true": session.trigger_on_true,
"session_id": session_id, # Para referencia
}
data = {
"plots": plots_data,
"session_counter": self.session_counter,
"last_saved": datetime.now().isoformat(),
"version": "1.0",
}
with open(self.plots_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
if self.logger:
self.logger.debug(f"Saved {len(plots_data)} plot sessions to disk")
except Exception as e:
if self.logger:
self.logger.error(f"Error saving persistent plots: {e}")
def update_session_config(self, session_id: str, config: Dict[str, Any]) -> bool:
"""Actualizar configuración de una sesión existente"""
with self.lock:
if session_id not in self.sessions:
return False
session = self.sessions[session_id]
# Actualizar configuración
session.name = config.get("name", session.name)
session.variables = config.get("variables", session.variables)
session.time_window = config.get("time_window", session.time_window)
session.y_min = config.get("y_min", session.y_min)
session.y_max = config.get("y_max", session.y_max)
session.trigger_variable = config.get(
"trigger_variable", session.trigger_variable
)
session.trigger_enabled = config.get(
"trigger_enabled", session.trigger_enabled
)
session.trigger_on_true = config.get(
"trigger_on_true", session.trigger_on_true
)
# Actualizar deques de datos si las variables cambiaron
new_variables = set(session.variables)
current_variables = set(session.data.keys())
# Agregar nuevas variables
max_points = int(session.time_window * 10)
for var in new_variables - current_variables:
session.data[var] = deque(maxlen=max_points)
# Remover variables que ya no están
for var in current_variables - new_variables:
del session.data[var]
# Actualizar tamaño de deques existentes si cambió time_window
for var in session.data:
if session.data[var].maxlen != max_points:
# Crear nuevo deque con el tamaño correcto preservando datos
old_data = list(session.data[var])
session.data[var] = deque(old_data, maxlen=max_points)
# Guardar cambios
self.save_plots()
if self.logger:
self.logger.info(f"Updated plot session '{session.name}' configuration")
if self.event_logger:
self.event_logger.log_event(
"info",
"plot_session_updated",
f"Plot session '{session.name}' configuration updated",
{"session_id": session_id, "new_config": config},
)
return True
def get_session_config(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Obtener configuración completa de una sesión"""
with self.lock:
if session_id not in self.sessions:
return None
session = self.sessions[session_id]
return {
"session_id": session_id,
"name": session.name,
"variables": session.variables,
"time_window": session.time_window,
"y_min": session.y_min,
"y_max": session.y_max,
"trigger_variable": session.trigger_variable,
"trigger_enabled": session.trigger_enabled,
"trigger_on_true": session.trigger_on_true,
"is_active": session.is_active,
"is_paused": session.is_paused,
}
def get_active_sessions_count(self) -> int:
"""Obtener número de sesiones activas"""
with self.lock:

107
main.py
View File

@ -1371,6 +1371,113 @@ def get_plot_data(session_id):
return jsonify({"error": str(e)}), 500
@app.route("/api/plots/<session_id>/config", methods=["GET"])
def get_plot_config(session_id):
"""Get plot configuration for a specific session"""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
config = streamer.data_streamer.plot_manager.get_session_config(session_id)
if config:
return jsonify({"success": True, "config": config})
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>/config", methods=["PUT"])
def update_plot_config(session_id):
"""Update plot configuration for a specific 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 actualizada
config = {
"name": data.get("name", f"Plot {session_id}"),
"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"])
# Actualizar configuración
success = streamer.data_streamer.plot_manager.update_session_config(
session_id, config
)
if success:
return jsonify(
{
"success": True,
"message": f"Plot session '{config['name']}' updated successfully",
}
)
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"""

View File

@ -70,5 +70,5 @@
],
"current_dataset_id": "dar",
"version": "1.0",
"last_update": "2025-07-21T09:21:45.845720"
"last_update": "2025-07-21T12:29:22.704454"
}

20
plot_sessions.json Normal file
View File

@ -0,0 +1,20 @@
{
"plots": {
"plot_6": {
"name": "Condix",
"variables": [
"UR62_Brix"
],
"time_window": 60,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true,
"session_id": "plot_6"
}
},
"session_counter": 7,
"last_saved": "2025-07-21T11:26:04.418899",
"version": "1.0"
}

View File

@ -703,27 +703,27 @@ textarea {
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;
@ -750,8 +750,10 @@ textarea {
border-bottom: 3px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
min-width: 120px;
flex: 1;
text-align: center;
min-width: 0;
max-width: none;
}
.tab-btn:hover {
@ -765,6 +767,104 @@ textarea {
background: var(--pico-card-background-color);
}
/* Plot tabs específicos */
.tab-btn.plot-tab {
position: relative;
padding-right: 2.5rem;
}
.tab-close {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--pico-muted-color);
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
padding: 0.25rem;
border-radius: 50%;
transition: all 0.2s ease;
}
.tab-close:hover {
background: var(--pico-muted-background-color);
color: var(--pico-color-red-500);
}
/* SUB-TABS STYLES */
.sub-tabs {
display: flex;
border-bottom: var(--pico-border-width) solid var(--pico-border-color);
margin-bottom: 1rem;
background: var(--pico-muted-background-color);
border-radius: var(--pico-border-radius);
overflow-x: auto;
}
.sub-tab-btn {
padding: 0.75rem 1rem;
border: none;
background: none;
color: var(--pico-muted-color);
cursor: pointer;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
flex: 1;
text-align: center;
min-width: 0;
font-size: 0.9rem;
}
.sub-tab-btn:hover {
color: var(--pico-primary);
background: var(--pico-card-background-color);
}
.sub-tab-btn.active {
color: var(--pico-primary);
border-bottom-color: var(--pico-primary);
background: var(--pico-card-background-color);
}
.sub-tab-btn.plot-sub-tab {
position: relative;
padding-right: 2rem;
}
.sub-tab-close {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--pico-muted-color);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0.2rem;
border-radius: 50%;
transition: all 0.2s ease;
}
.sub-tab-close:hover {
background: var(--pico-muted-background-color);
color: var(--pico-color-red-500);
}
.sub-tab-content {
display: none;
}
.sub-tab-content.active {
display: block;
}
.tab-content {
display: none;
}
@ -773,6 +873,391 @@ textarea {
display: block;
}
/* COLLAPSIBLE PLOT FORM STYLES */
.collapsible-section {
margin-bottom: 1.5rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
overflow: hidden;
transition: all 0.3s ease;
}
.plot-form-article {
margin: 0;
background: var(--pico-card-background-color);
}
.plot-form-article header {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
padding: 1rem 1.5rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.variables-selection {
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
background: var(--pico-muted-background-color);
}
.selected-variables {
margin-bottom: 1rem;
min-height: 2rem;
padding: 0.5rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color);
}
.selected-variables .no-variables {
color: var(--pico-muted-color);
font-style: italic;
margin: 0;
}
.variable-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
margin: 0.25rem;
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
font-weight: 500;
}
.variable-chip .color-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.variable-chip .remove-variable {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.1rem;
padding: 0;
margin-left: 0.25rem;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.variable-chip .remove-variable:hover {
opacity: 1;
}
/* VARIABLE SELECTION MODAL STYLES */
.variable-modal {
max-width: 900px;
width: 95%;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
margin: 0;
border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0;
}
.modal-header h3 {
margin: 0;
color: inherit;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.modal-footer {
padding: 1rem 1.5rem;
background: var(--pico-muted-background-color);
border-top: var(--pico-border-width) solid var(--pico-border-color);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.variable-selection-container {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.datasets-sidebar {
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
background: var(--pico-muted-background-color);
height: fit-content;
}
.datasets-sidebar h4 {
margin: 0 0 1rem 0;
color: var(--pico-h4-color);
font-size: 1rem;
}
.datasets-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dataset-item {
padding: 0.75rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.dataset-item:hover {
background: var(--pico-primary-hover);
color: var(--pico-primary-inverse);
}
.dataset-item.active {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.dataset-item .dataset-name {
font-weight: bold;
display: block;
}
.dataset-item .dataset-info {
font-size: 0.75rem;
opacity: 0.8;
margin-top: 0.25rem;
}
.variables-main {
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-card-background-color);
}
.variables-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--pico-muted-background-color);
border-bottom: var(--pico-border-width) solid var(--pico-border-color);
}
.variables-header h4 {
margin: 0;
color: var(--pico-h4-color);
font-size: 1rem;
}
.selection-controls {
display: flex;
gap: 0.5rem;
}
.selection-controls .btn {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
}
.variables-list {
padding: 1rem;
max-height: 300px;
overflow-y: auto;
}
.variable-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
margin-bottom: 0.5rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-muted-background-color);
transition: all 0.2s ease;
}
.variable-item:last-child {
margin-bottom: 0;
}
.variable-item.selected {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
border-color: var(--pico-primary);
}
.variable-info {
flex: 1;
}
.variable-name {
font-weight: bold;
margin-bottom: 0.25rem;
}
.variable-details {
font-size: 0.75rem;
opacity: 0.8;
}
.variable-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.variable-checkbox {
transform: scale(1.2);
}
.color-selector {
width: 40px;
height: 30px;
border: none;
border-radius: var(--pico-border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.color-selector:hover {
transform: scale(1.1);
}
.color-selector:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.selected-summary {
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
background: var(--pico-muted-background-color);
}
.selected-summary h4 {
margin: 0 0 1rem 0;
color: var(--pico-h4-color);
font-size: 1rem;
}
.selected-summary-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
}
.selected-summary-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
}
.selected-summary-item .color-indicator {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--pico-border-color);
}
.no-dataset-message {
text-align: center;
color: var(--pico-muted-color);
font-style: italic;
padding: 2rem;
margin: 0;
}
/* Responsive design para variable modal */
@media (max-width: 768px) {
.variable-modal {
width: 98%;
max-height: 90vh;
}
.variable-selection-container {
grid-template-columns: 1fr;
gap: 1rem;
}
.datasets-sidebar {
max-height: 150px;
overflow-y: auto;
}
.variables-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.selection-controls {
width: 100%;
justify-content: flex-end;
}
.variable-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.variable-controls {
width: 100%;
justify-content: flex-end;
}
.modal-footer {
flex-direction: column;
gap: 0.5rem;
}
}
/* Responsive tabs */
@media (max-width: 768px) {
.tabs {
@ -782,14 +1267,14 @@ textarea {
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);
}

View File

@ -7,7 +7,7 @@ let currentDatasets = {};
let currentDatasetId = null;
// Cargar todos los datasets desde API
function loadDatasets() {
window.loadDatasets = function () {
fetch('/api/datasets')
.then(response => response.json())
.then(data => {

View File

@ -89,10 +89,80 @@ function clearLogView() {
// Inicializar listeners para eventos
function initEventListeners() {
// Botones de control de log
document.querySelector('button[onclick="refreshEventLog()"]').addEventListener('click', refreshEventLog);
document.querySelector('button[onclick="clearLogView()"]').addEventListener('click', clearLogView);
// Botones de control de log para el tab de events
const refreshBtn = document.getElementById('refresh-events-btn');
const clearBtn = document.getElementById('clear-events-btn');
// Selector de límite de log
document.getElementById('log-limit').addEventListener('change', refreshEventLog);
if (refreshBtn) {
refreshBtn.addEventListener('click', loadEvents);
}
if (clearBtn) {
clearBtn.addEventListener('click', clearEventsView);
}
}
// Función para cargar eventos en el tab de events
window.loadEvents = function() {
fetch('/api/events?limit=50')
.then(response => response.json())
.then(data => {
if (data.success) {
const eventsContainer = document.getElementById('events-container');
const eventsCount = document.getElementById('events-count');
// Limpiar contenedor
eventsContainer.innerHTML = '';
// Actualizar contador
eventsCount.textContent = data.showing || 0;
// Añadir eventos (orden inverso para mostrar primero los más nuevos)
const events = data.events.reverse();
if (events.length === 0) {
eventsContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>📋 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">No events found</div>
</div>
`;
} else {
events.forEach(event => {
eventsContainer.appendChild(createLogEntry(event));
});
}
// Auto-scroll al inicio para mostrar eventos más nuevos
eventsContainer.scrollTop = 0;
} else {
console.error('Error loading events:', data.error);
showMessage('Error loading events log', 'error');
}
})
.catch(error => {
console.error('Error fetching events:', error);
showMessage('Error fetching events log', 'error');
});
}
// Función para limpiar vista de eventos
function clearEventsView() {
const eventsContainer = document.getElementById('events-container');
const eventsCount = document.getElementById('events-count');
eventsContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>🧹 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">Events view cleared. Click refresh to reload events.</div>
</div>
`;
eventsCount.textContent = '0';
}

File diff suppressed because it is too large Load Diff

View File

@ -6,17 +6,13 @@
class TabManager {
constructor() {
this.currentTab = 'datasets';
this.plotTabs = new Set(); // Track dynamic plot tabs
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);
});
});
// Event listeners para los botones de tab estáticos
this.bindStaticTabs();
// Inicializar con el tab activo por defecto
this.switchTab(this.currentTab);
@ -24,6 +20,16 @@ class TabManager {
console.log('📑 Tab Manager initialized');
}
bindStaticTabs() {
// Solo bindear tabs estáticos, los dinámicos se bindean al crearlos
document.querySelectorAll('.tab-btn:not([data-plot-id])').forEach(btn => {
btn.addEventListener('click', (e) => {
const tabName = e.target.dataset.tab;
this.switchTab(tabName);
});
});
}
switchTab(tabName) {
// Remover clase active de todos los tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
@ -50,6 +56,208 @@ class TabManager {
}
}
createPlotTab(sessionId, plotName) {
// Crear botón de sub-tab dinámico
const subTabBtn = document.createElement('button');
subTabBtn.className = 'sub-tab-btn plot-sub-tab';
subTabBtn.dataset.subTab = `plot-${sessionId}`;
subTabBtn.dataset.plotId = sessionId;
subTabBtn.innerHTML = `
📈 ${plotName}
<span class="sub-tab-close" data-session-id="${sessionId}">&times;</span>
`;
// Crear contenido del sub-tab
const subTabContent = document.createElement('div');
subTabContent.className = 'sub-tab-content plot-sub-tab-content';
subTabContent.id = `plot-${sessionId}-sub-tab`;
subTabContent.innerHTML = `
<article>
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>📈 ${plotName}</span>
<div>
<button type="button" class="outline" onclick="window.editPlotSession('${sessionId}')">
Edit Plot
</button>
<button type="button" class="secondary" onclick="window.removePlotSession('${sessionId}')">
🗑 Remove Plot
</button>
</div>
</div>
</header>
<div class="plot-session" id="plot-session-${sessionId}">
<div class="plot-header">
<h4>📈 ${plotName}</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>
</div>
</div>
<div class="plot-info">
<span class="plot-stats" id="plot-stats-${sessionId}">
Loading plot information...
</span>
</div>
<div class="plot-canvas">
<canvas id="chart-${sessionId}"></canvas>
</div>
</div>
</article>
`;
// Mostrar sub-tabs si no están visibles
const subTabs = document.getElementById('plot-sub-tabs');
const plotSessionsContainer = document.getElementById('plot-sessions-container');
const plotSubContent = document.getElementById('plot-sub-content');
if (subTabs.style.display === 'none') {
subTabs.style.display = 'flex';
plotSessionsContainer.style.display = 'none';
plotSubContent.style.display = 'block';
}
// Agregar sub-tab al contenedor de sub-tabs
subTabs.appendChild(subTabBtn);
// Agregar contenido del sub-tab
plotSubContent.appendChild(subTabContent);
// Bind events
subTabBtn.addEventListener('click', (e) => {
if (!e.target.classList.contains('sub-tab-close')) {
this.switchSubTab(`plot-${sessionId}`);
}
});
// Close button event
subTabBtn.querySelector('.sub-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
// Llamar a la función que elimina el plot del backend Y del frontend
if (typeof window.removePlotSession === 'function') {
window.removePlotSession(sessionId);
} else {
console.error('removePlotSession function not available');
// Fallback: solo remover del frontend
this.removePlotTab(sessionId);
}
});
this.plotTabs.add(sessionId);
console.log(`📑 Created plot sub-tab for session: ${sessionId}`);
return subTabBtn;
}
switchSubTab(subTabName) {
// Remover clase active de todos los sub-tabs
document.querySelectorAll('.sub-tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.sub-tab-content').forEach(content => {
content.classList.remove('active');
});
// Activar el sub-tab seleccionado
const activeBtn = document.querySelector(`[data-sub-tab="${subTabName}"]`);
const activeContent = document.getElementById(`${subTabName}-sub-tab`);
if (activeBtn && activeContent) {
activeBtn.classList.add('active');
activeContent.classList.add('active');
// Eventos específicos por sub-tab
this.handleSubTabSpecificEvents(subTabName);
console.log(`📑 Switched to sub-tab: ${subTabName}`);
}
}
handleSubTabSpecificEvents(subTabName) {
if (subTabName.startsWith('plot-')) {
// Sub-tab de plot individual - cargar datos específicos
const sessionId = subTabName.replace('plot-', '');
if (typeof plotManager !== 'undefined') {
plotManager.updateSessionData(sessionId);
}
}
}
removePlotTab(sessionId) {
// Remover sub-tab button
const subTabBtn = document.querySelector(`[data-plot-id="${sessionId}"]`);
if (subTabBtn) {
subTabBtn.remove();
}
// Remover sub-tab content
const subTabContent = document.getElementById(`plot-${sessionId}-sub-tab`);
if (subTabContent) {
subTabContent.remove();
}
this.plotTabs.delete(sessionId);
// Si no quedan sub-tabs, mostrar vista inicial
const subTabs = document.getElementById('plot-sub-tabs');
const plotSessionsContainer = document.getElementById('plot-sessions-container');
const plotSubContent = document.getElementById('plot-sub-content');
if (subTabs.children.length === 0) {
subTabs.style.display = 'none';
plotSessionsContainer.style.display = 'block';
plotSubContent.style.display = 'none';
}
console.log(`📑 Removed plot sub-tab for session: ${sessionId}`);
}
updatePlotTabName(sessionId, newName) {
const subTabBtn = document.querySelector(`[data-plot-id="${sessionId}"]`);
if (subTabBtn) {
subTabBtn.innerHTML = `
📈 ${newName}
<span class="sub-tab-close" data-session-id="${sessionId}">&times;</span>
`;
// Re-bind close event
subTabBtn.querySelector('.sub-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
// Llamar a la función que elimina el plot del backend Y del frontend
if (typeof window.removePlotSession === 'function') {
window.removePlotSession(sessionId);
} else {
console.error('removePlotSession function not available');
// Fallback: solo remover del frontend
this.removePlotTab(sessionId);
}
});
}
// Actualizar header del contenido
const header = document.querySelector(`#plot-${sessionId}-sub-tab h4`);
if (header) {
header.textContent = `📈 ${newName}`;
}
const articleHeader = document.querySelector(`#plot-${sessionId}-sub-tab header span`);
if (articleHeader) {
articleHeader.textContent = `📈 ${newName}`;
}
}
handleTabSpecificEvents(tabName) {
switch (tabName) {
case 'plotting':

View File

@ -7,5 +7,5 @@
]
},
"auto_recovery_enabled": true,
"last_update": "2025-07-21T09:21:45.854021"
"last_update": "2025-07-21T12:29:22.714509"
}

View File

@ -465,41 +465,137 @@
</form>
</details>
</article>
</div>
<!-- Application Events Log -->
<!-- 📈 PLOTTING TAB -->
<div class="tab-content" id="plotting-tab">
<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...
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>📈 Real-Time Plotting</span>
<button type="button" id="toggle-plot-form-btn" class="outline"> New Plot</button>
</div>
</header>
<!-- Plot Creation/Edit Form (Collapsible) -->
<div id="plot-form-container" class="collapsible-section" style="display: none;">
<article class="plot-form-article">
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span id="plot-form-title">🆕 Create New Plot</span>
<button type="button" id="close-plot-form-btn" class="close-btn">&times;</button>
</div>
</header>
<form id="plot-form">
<div class="form-group">
<label for="plot-form-name">Plot Name:</label>
<input type="text" id="plot-form-name" placeholder="Temperature Monitoring" required>
</div>
<div class="form-group">
<label>Variables to Plot:</label>
<div class="variables-selection">
<div id="selected-variables-display" class="selected-variables">
<p class="no-variables">No variables selected</p>
</div>
<button type="button" id="select-variables-btn" class="outline">
🎨 Select Variables & Colors
</button>
</div>
</div>
<div class="form-group">
<label for="plot-form-time-window">Time Window (seconds):</label>
<input type="number" id="plot-form-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-form-y-min" placeholder="Auto Min" step="any">
<span>to</span>
<input type="number" id="plot-form-y-max" placeholder="Auto Max" step="any">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="plot-form-trigger-enabled"
onchange="togglePlotFormTriggerConfig()">
Enable Trigger System
</label>
</div>
<div id="plot-form-trigger-config" style="display: none;">
<div class="form-group">
<label for="plot-form-trigger-variable">Trigger Variable:</label>
<select id="plot-form-trigger-variable">
<option value="">No trigger</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="plot-form-trigger-on-true" checked>
Trigger on True (uncheck for False)
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="plot-form-submit">Create Plot</button>
<button type="button" class="btn btn-secondary" id="cancel-plot-form">Cancel</button>
</div>
</form>
</article>
</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>
<!-- Sub-tabs para plots -->
<nav class="sub-tabs" id="plot-sub-tabs" style="display: none;">
<!-- Los sub-tabs se crearán dinámicamente aquí -->
</nav>
<!-- Contenido de sub-tabs -->
<div id="plot-sub-content">
<!-- Contenido de plots se mostrará aquí -->
</div>
<!-- Plot Sessions Container (vista inicial) -->
<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>
</main>
<!-- Edit Variable Modal -->
@ -579,21 +675,96 @@
<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>
<button type="button" id="toggle-plot-form-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>
<!-- Plot Creation/Edit Form (Collapsible) -->
<div id="plot-form-container" class="collapsible-section" style="display: none;">
<article class="plot-form-article">
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span id="plot-form-title">🆕 Create New Plot</span>
<button type="button" id="close-plot-form-btn" class="close-btn">&times;</button>
</div>
</header>
<form id="plot-form">
<div class="form-group">
<label for="plot-form-name">Plot Name:</label>
<input type="text" id="plot-form-name" placeholder="Temperature Monitoring" required>
</div>
<div class="form-group">
<label>Variables to Plot:</label>
<div class="variables-selection">
<div id="selected-variables-display" class="selected-variables">
<p class="no-variables">No variables selected</p>
</div>
<button type="button" id="select-variables-btn" class="outline">
🎨 Select Variables & Colors
</button>
</div>
</div>
<div class="form-group">
<label for="plot-form-time-window">Time Window (seconds):</label>
<input type="number" id="plot-form-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-form-y-min" placeholder="Auto Min" step="any">
<span>to</span>
<input type="number" id="plot-form-y-max" placeholder="Auto Max" step="any">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="plot-form-trigger-enabled"
onchange="togglePlotFormTriggerConfig()">
Enable Trigger System
</label>
</div>
<div id="plot-form-trigger-config" style="display: none;">
<div class="form-group">
<label for="plot-form-trigger-variable">Trigger Variable:</label>
<select id="plot-form-trigger-variable">
<option value="">No trigger</option>
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="plot-form-trigger-on-true" checked>
Trigger on True (uncheck for False)
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="plot-form-submit">Create Plot</button>
<button type="button" class="btn btn-secondary" id="cancel-plot-form">Cancel</button>
</div>
</form>
</article>
</div>
<!-- Plot Sessions Container -->
<!-- Sub-tabs para plots -->
<nav class="sub-tabs" id="plot-sub-tabs" style="display: none;">
<!-- Los sub-tabs se crearán dinámicamente aquí -->
</nav>
<!-- Contenido de sub-tabs -->
<div id="plot-sub-content">
<!-- Contenido de plots se mostrará aquí -->
</div>
<!-- Plot Sessions Container (vista inicial) -->
<div id="plot-sessions-container">
<div style="text-align: center; padding: 2rem; color: var(--pico-muted-color);">
<p>📈 No plot sessions created yet</p>
@ -627,64 +798,52 @@
</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>
<!-- Variable Selection Modal (NEW) -->
<div id="variable-selection-modal" class="modal" style="display: none;">
<div class="modal-content variable-modal">
<header class="modal-header">
<h3>🎨 Select Variables & Colors</h3>
<button type="button" class="close-btn" id="close-variable-modal">&times;</button>
</header>
<div class="form-group">
<label>Variables to Plot:</label>
<div id="plot-variables-selector">
<p>Loading available variables...</p>
<div class="modal-body">
<div class="variable-selection-container">
<div class="datasets-sidebar">
<h4>📊 Datasets</h4>
<div id="datasets-list" class="datasets-list">
<p>Loading datasets...</p>
</div>
</div>
<div class="variables-main">
<div class="variables-header">
<h4>🔧 Variables</h4>
<div class="selection-controls">
<button type="button" id="select-all-variables" class="btn btn-sm outline">Select
All</button>
<button type="button" id="deselect-all-variables" class="btn btn-sm outline">Deselect
All</button>
</div>
</div>
<div id="variables-list" class="variables-list">
<p class="no-dataset-message">Select a dataset to see its variables</p>
</div>
</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 class="selected-summary">
<h4>📝 Selected Variables Summary</h4>
<div id="selected-variables-summary" class="selected-summary-list">
<p>No variables selected</p>
</div>
</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 class="modal-footer">
<button type="button" class="btn btn-secondary" id="cancel-variable-selection">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-variable-selection">Confirm Selection</button>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,170 @@
/**
* Gestión de la configuración CSV y operaciones relacionadas
*/
// Cargar configuración CSV
function loadCsvConfig() {
fetch('/api/csv/config')
.then(response => response.json())
.then(data => {
if (data.success) {
const config = data.config;
// Actualizar elementos de visualización
document.getElementById('csv-directory-path').textContent = config.current_directory || 'N/A';
document.getElementById('csv-rotation-enabled').textContent = config.rotation_enabled ? '✅ Yes' : '❌ No';
document.getElementById('csv-max-size').textContent = config.max_size_mb ? `${config.max_size_mb} MB` : 'No limit';
document.getElementById('csv-max-days').textContent = config.max_days ? `${config.max_days} days` : 'No limit';
document.getElementById('csv-max-hours').textContent = config.max_hours ? `${config.max_hours} hours` : 'No limit';
document.getElementById('csv-cleanup-interval').textContent = `${config.cleanup_interval_hours} hours`;
// Actualizar campos del formulario
document.getElementById('records-directory').value = config.records_directory || '';
document.getElementById('rotation-enabled').checked = config.rotation_enabled || false;
document.getElementById('max-size-mb').value = config.max_size_mb || '';
document.getElementById('max-days').value = config.max_days || '';
document.getElementById('max-hours').value = config.max_hours || '';
document.getElementById('cleanup-interval').value = config.cleanup_interval_hours || 24;
// Cargar información del directorio
loadCsvDirectoryInfo();
} else {
showMessage('Error loading CSV configuration: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error loading CSV configuration', 'error');
});
}
// Cargar información del directorio CSV
function loadCsvDirectoryInfo() {
fetch('/api/csv/directory/info')
.then(response => response.json())
.then(data => {
if (data.success) {
const info = data.info;
const statsDiv = document.getElementById('directory-stats');
let html = `
<div class="stat-item">
<strong>📁 Directory:</strong>
<span>${info.base_directory}</span>
</div>
<div class="stat-item">
<strong>📊 Total Files:</strong>
<span>${info.total_files}</span>
</div>
<div class="stat-item">
<strong>💾 Total Size:</strong>
<span>${info.total_size_mb} MB</span>
</div>
`;
if (info.oldest_file) {
html += `
<div class="stat-item">
<strong>📅 Oldest File:</strong>
<span>${new Date(info.oldest_file).toLocaleString()}</span>
</div>
`;
}
if (info.newest_file) {
html += `
<div class="stat-item">
<strong>🆕 Newest File:</strong>
<span>${new Date(info.newest_file).toLocaleString()}</span>
</div>
`;
}
if (info.day_folders && info.day_folders.length > 0) {
html += '<h4>📂 Day Folders:</h4>';
info.day_folders.forEach(folder => {
html += `
<div class="day-folder-item">
<span><strong>${folder.name}</strong></span>
<span>${folder.files} files, ${folder.size_mb} MB</span>
</div>
`;
});
}
statsDiv.innerHTML = html;
}
})
.catch(error => {
document.getElementById('directory-stats').innerHTML = '<p>Error loading directory information</p>';
});
}
// Ejecutar limpieza manual
function triggerManualCleanup() {
if (!confirm('¿Estás seguro de que quieres ejecutar la limpieza manual? Esto eliminará archivos antiguos según la configuración actual.')) {
return;
}
fetch('/api/csv/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Limpieza ejecutada correctamente', 'success');
loadCsvDirectoryInfo(); // Recargar información del directorio
} else {
showMessage('Error en la limpieza: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error ejecutando la limpieza', 'error');
});
}
// Inicializar listeners para la configuración CSV
function initCsvListeners() {
// Manejar envío del formulario de configuración CSV
document.getElementById('csv-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const configData = {};
// Convertir datos del formulario a objeto, manejando valores vacíos
for (let [key, value] of formData.entries()) {
if (key === 'rotation_enabled') {
configData[key] = document.getElementById('rotation-enabled').checked;
} else if (value.trim() === '') {
configData[key] = null;
} else if (key.includes('max_') || key.includes('cleanup_interval')) {
configData[key] = parseFloat(value) || null;
} else {
configData[key] = value.trim();
}
}
fetch('/api/csv/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(configData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Configuración CSV actualizada correctamente', 'success');
loadCsvConfig(); // Recargar para mostrar valores actualizados
} else {
showMessage('Error actualizando configuración CSV: ' + data.message, 'error');
}
})
.catch(error => {
showMessage('Error actualizando configuración CSV', 'error');
});
});
}

View File

@ -0,0 +1,519 @@
/**
* Gestión de datasets y variables asociadas
*/
// Variables de gestión de datasets
let currentDatasets = {};
let currentDatasetId = null;
// Cargar todos los datasets desde API
window.loadDatasets = function () {
fetch('/api/datasets')
.then(response => response.json())
.then(data => {
if (data.success) {
currentDatasets = data.datasets;
currentDatasetId = data.current_dataset_id;
updateDatasetSelector();
updateDatasetInfo();
}
})
.catch(error => {
console.error('Error loading datasets:', error);
showMessage('Error loading datasets', 'error');
});
}
// Actualizar el selector de datasets
function updateDatasetSelector() {
const selector = document.getElementById('dataset-selector');
selector.innerHTML = '<option value="">Select a dataset...</option>';
Object.keys(currentDatasets).forEach(datasetId => {
const dataset = currentDatasets[datasetId];
const option = document.createElement('option');
option.value = datasetId;
option.textContent = `${dataset.name} (${dataset.prefix})`;
if (datasetId === currentDatasetId) {
option.selected = true;
}
selector.appendChild(option);
});
}
// Actualizar información del dataset
function updateDatasetInfo() {
const statusBar = document.getElementById('dataset-status-bar');
const variablesManagement = document.getElementById('variables-management');
const noDatasetMessage = document.getElementById('no-dataset-message');
if (currentDatasetId && currentDatasets[currentDatasetId]) {
const dataset = currentDatasets[currentDatasetId];
// Mostrar info del dataset en la barra de estado
document.getElementById('dataset-name').textContent = dataset.name;
document.getElementById('dataset-prefix').textContent = dataset.prefix;
document.getElementById('dataset-sampling').textContent =
dataset.sampling_interval ? `${dataset.sampling_interval}s` : 'Global interval';
document.getElementById('dataset-var-count').textContent = Object.keys(dataset.variables).length;
document.getElementById('dataset-stream-count').textContent = dataset.streaming_variables.length;
// Actualizar estado del dataset
const statusSpan = document.getElementById('dataset-status');
const isActive = dataset.enabled;
statusSpan.textContent = isActive ? '🟢 Active' : '⭕ Inactive';
statusSpan.className = `status-item ${isActive ? 'status-active' : 'status-inactive'}`;
// Actualizar botones de acción
document.getElementById('activate-dataset-btn').style.display = isActive ? 'none' : 'inline-block';
document.getElementById('deactivate-dataset-btn').style.display = isActive ? 'inline-block' : 'none';
// Mostrar secciones
statusBar.style.display = 'block';
variablesManagement.style.display = 'block';
noDatasetMessage.style.display = 'none';
// Cargar variables para este dataset
loadDatasetVariables(currentDatasetId);
} else {
statusBar.style.display = 'none';
variablesManagement.style.display = 'none';
noDatasetMessage.style.display = 'block';
}
}
// Cargar variables para un dataset específico
function loadDatasetVariables(datasetId) {
if (!datasetId || !currentDatasets[datasetId]) {
// Limpiar la tabla si no hay dataset válido
document.getElementById('variables-tbody').innerHTML = '';
return;
}
const dataset = currentDatasets[datasetId];
const variables = dataset.variables || {};
const streamingVars = dataset.streaming_variables || [];
const tbody = document.getElementById('variables-tbody');
// Limpiar filas existentes
tbody.innerHTML = '';
// Añadir una fila para cada variable
Object.keys(variables).forEach(varName => {
const variable = variables[varName];
const row = document.createElement('tr');
// Formatear visualización del área de memoria
let memoryAreaDisplay = '';
if (variable.area === 'db') {
memoryAreaDisplay = `DB${variable.db || 'N/A'}.${variable.offset}`;
} else if (variable.area === 'mw' || variable.area === 'm') {
memoryAreaDisplay = `MW${variable.offset}`;
} else if (variable.area === 'pew' || variable.area === 'pe') {
memoryAreaDisplay = `PEW${variable.offset}`;
} else if (variable.area === 'paw' || variable.area === 'pa') {
memoryAreaDisplay = `PAW${variable.offset}`;
} else if (variable.area === 'e') {
memoryAreaDisplay = `E${variable.offset}.${variable.bit}`;
} else if (variable.area === 'a') {
memoryAreaDisplay = `A${variable.offset}.${variable.bit}`;
} else if (variable.area === 'mb') {
memoryAreaDisplay = `M${variable.offset}.${variable.bit}`;
} else {
memoryAreaDisplay = `${variable.area.toUpperCase()}${variable.offset}`;
}
// Comprobar si la variable está en la lista de streaming
const isStreaming = streamingVars.includes(varName);
row.innerHTML = `
<td>${varName}</td>
<td>${memoryAreaDisplay}</td>
<td>${variable.offset}</td>
<td>${variable.type.toUpperCase()}</td>
<td id="value-${varName}" style="font-family: monospace; font-weight: bold; color: var(--pico-muted-color);">
--
</td>
<td>
<label>
<input type="checkbox" id="stream-${varName}" role="switch" ${isStreaming ? 'checked' : ''}
onchange="toggleStreaming('${varName}', this.checked)">
Enable
</label>
</td>
<td>
<button class="outline" onclick="editVariable('${varName}')">✏️ Edit</button>
<button class="secondary" onclick="removeVariable('${varName}')">🗑️ Remove</button>
</td>
`;
tbody.appendChild(row);
});
}
// Inicializar listeners de eventos para datasets
function initDatasetListeners() {
// Cambio de selector de dataset
document.getElementById('dataset-selector').addEventListener('change', function () {
const selectedDatasetId = this.value;
if (selectedDatasetId) {
// Detener streaming de variables actual si está activo
if (isStreamingVariables) {
stopVariableStreaming();
}
// Establecer como dataset actual
fetch('/api/datasets/current', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataset_id: selectedDatasetId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
currentDatasetId = selectedDatasetId;
// Recargar datasets para obtener datos frescos, luego actualizar info
loadDatasets();
// Actualizar texto del botón de streaming
const toggleBtn = document.getElementById('toggle-streaming-btn');
if (toggleBtn) {
toggleBtn.innerHTML = '▶️ Start Live Streaming';
}
// Auto-refrescar valores para el nuevo dataset
autoStartLiveDisplay();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error setting current dataset', 'error');
});
} else {
// Detener streaming de variables si está activo
if (isStreamingVariables) {
stopVariableStreaming();
}
currentDatasetId = null;
updateDatasetInfo();
// Limpiar valores cuando no hay dataset seleccionado
clearVariableValues();
}
});
// Botón de nuevo dataset
document.getElementById('new-dataset-btn').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'block';
});
// Cerrar modal de dataset
document.getElementById('close-dataset-modal').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'none';
});
document.getElementById('cancel-dataset-btn').addEventListener('click', function () {
document.getElementById('dataset-modal').style.display = 'none';
});
// Crear nuevo dataset
document.getElementById('dataset-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
dataset_id: document.getElementById('dataset-id').value.trim(),
name: document.getElementById('dataset-name-input').value.trim(),
prefix: document.getElementById('dataset-prefix-input').value.trim(),
sampling_interval: document.getElementById('dataset-sampling-input').value || null
};
if (data.sampling_interval) {
data.sampling_interval = parseFloat(data.sampling_interval);
}
fetch('/api/datasets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
document.getElementById('dataset-modal').style.display = 'none';
document.getElementById('dataset-form').reset();
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error creating dataset', 'error');
});
});
// Botón de eliminar dataset
document.getElementById('delete-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
const dataset = currentDatasets[currentDatasetId];
if (confirm(`Are you sure you want to delete dataset "${dataset.name}"?`)) {
fetch(`/api/datasets/${currentDatasetId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error deleting dataset', 'error');
});
}
});
// Botón de activar dataset
document.getElementById('activate-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) return;
fetch(`/api/datasets/${currentDatasetId}/activate`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error activating dataset', 'error');
});
});
// Botón de desactivar dataset
document.getElementById('deactivate-dataset-btn').addEventListener('click', function () {
if (!currentDatasetId) return;
fetch(`/api/datasets/${currentDatasetId}/deactivate`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(data.message, 'success');
loadDatasets();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage('Error deactivating dataset', 'error');
});
});
// Formulario de variables
document.getElementById('variable-form').addEventListener('submit', function (e) {
e.preventDefault();
if (!currentDatasetId) {
showMessage('No dataset selected. Please select a dataset first.', 'error');
return;
}
const area = document.getElementById('var-area').value;
const data = {
name: document.getElementById('var-name').value,
area: area,
db: area === 'db' ? parseInt(document.getElementById('var-db').value) : 1,
offset: parseInt(document.getElementById('var-offset').value),
type: document.getElementById('var-type').value,
streaming: false // Default to not streaming
};
// Añadir parámetro bit para áreas de bit
if (area === 'e' || area === 'a' || area === 'mb') {
data.bit = parseInt(document.getElementById('var-bit').value);
}
fetch(`/api/datasets/${currentDatasetId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
if (data.success) {
document.getElementById('variable-form').reset();
loadDatasets(); // Recargar para actualizar conteos
updateStatus();
}
});
});
}
// Eliminar variable del dataset actual
function removeVariable(name) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
if (confirm(`Are you sure you want to remove the variable "${name}" from this dataset?`)) {
fetch(`/api/datasets/${currentDatasetId}/variables/${name}`, { method: 'DELETE' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
if (data.success) {
loadDatasets(); // Recargar para actualizar conteos
updateStatus();
}
});
}
}
// Variables para edición de variables
let currentEditingVariable = null;
// Editar variable
function editVariable(name) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
currentEditingVariable = name;
// Obtener datos de la variable del dataset actual
const dataset = currentDatasets[currentDatasetId];
if (dataset && dataset.variables && dataset.variables[name]) {
const variable = dataset.variables[name];
const streamingVars = dataset.streaming_variables || [];
// Crear objeto de variable con la misma estructura que la API
const variableData = {
name: name,
area: variable.area,
db: variable.db,
offset: variable.offset,
type: variable.type,
bit: variable.bit,
streaming: streamingVars.includes(name)
};
populateEditForm(variableData);
document.getElementById('edit-modal').style.display = 'block';
} else {
showMessage('Variable not found in current dataset', 'error');
}
}
// Rellenar formulario de edición
function populateEditForm(variable) {
document.getElementById('edit-var-name').value = variable.name;
document.getElementById('edit-var-area').value = variable.area;
document.getElementById('edit-var-offset').value = variable.offset;
document.getElementById('edit-var-type').value = variable.type;
if (variable.db) {
document.getElementById('edit-var-db').value = variable.db;
}
if (variable.bit !== undefined) {
document.getElementById('edit-var-bit').value = variable.bit;
}
// Actualizar visibilidad de campos según el área
toggleEditFields();
}
// Cerrar modal de edición
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
currentEditingVariable = null;
}
// Inicializar listeners para edición de variables
function initVariableEditListeners() {
// Manejar envío del formulario de edición
document.getElementById('edit-variable-form').addEventListener('submit', function (e) {
e.preventDefault();
if (!currentEditingVariable || !currentDatasetId) {
showMessage('No variable or dataset selected for editing', 'error');
return;
}
const area = document.getElementById('edit-var-area').value;
const newName = document.getElementById('edit-var-name').value;
// Primero eliminar la variable antigua
fetch(`/api/datasets/${currentDatasetId}/variables/${currentEditingVariable}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(deleteResult => {
if (deleteResult.success) {
// Luego añadir la variable actualizada
const data = {
name: newName,
area: area,
db: area === 'db' ? parseInt(document.getElementById('edit-var-db').value) : 1,
offset: parseInt(document.getElementById('edit-var-offset').value),
type: document.getElementById('edit-var-type').value,
streaming: false // Se restaurará abajo si estaba habilitado
};
// Añadir parámetro bit para áreas de bit
if (area === 'e' || area === 'a' || area === 'mb') {
data.bit = parseInt(document.getElementById('edit-var-bit').value);
}
return fetch(`/api/datasets/${currentDatasetId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
throw new Error(deleteResult.message);
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Variable updated successfully', 'success');
closeEditModal();
loadDatasets();
updateStatus();
} else {
showMessage(data.message, 'error');
}
})
.catch(error => {
showMessage(`Error updating variable: ${error}`, 'error');
});
});
// Cerrar modal al hacer clic fuera de él
window.onclick = function (event) {
const editModal = document.getElementById('edit-modal');
const datasetModal = document.getElementById('dataset-modal');
if (event.target === editModal) {
closeEditModal();
}
if (event.target === datasetModal) {
datasetModal.style.display = 'none';
}
}
}

View File

@ -0,0 +1,168 @@
/**
* Gestión de eventos de la aplicación y log de eventos
*/
// Refrescar log de eventos
function refreshEventLog() {
const limit = document.getElementById('log-limit').value;
fetch(`/api/events?limit=${limit}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const logContainer = document.getElementById('events-log');
const logStats = document.getElementById('log-stats');
// Limpiar entradas existentes
logContainer.innerHTML = '';
// Actualizar estadísticas
logStats.textContent = `Showing ${data.showing} of ${data.total_events} events`;
// Añadir eventos (orden inverso para mostrar primero los más nuevos)
const events = data.events.reverse();
if (events.length === 0) {
logContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>📋 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">No events found</div>
</div>
`;
} else {
events.forEach(event => {
logContainer.appendChild(createLogEntry(event));
});
}
// Auto-scroll al inicio para mostrar eventos más nuevos
logContainer.scrollTop = 0;
} else {
console.error('Error loading events:', data.error);
showMessage('Error loading events log', 'error');
}
})
.catch(error => {
console.error('Error fetching events:', error);
showMessage('Error fetching events log', 'error');
});
}
// Crear entrada de log
function createLogEntry(event) {
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${event.level}`;
const hasDetails = event.details && Object.keys(event.details).length > 0;
logEntry.innerHTML = `
<div class="log-header">
<span>${getEventIcon(event.event_type)} ${event.event_type.replace(/_/g, ' ').toUpperCase()}</span>
<span class="log-timestamp">${formatTimestamp(event.timestamp)}</span>
</div>
<div class="log-message">${event.message}</div>
${hasDetails ? `<div class="log-details">${JSON.stringify(event.details, null, 2)}</div>` : ''}
`;
return logEntry;
}
// Limpiar vista de log
function clearLogView() {
const logContainer = document.getElementById('events-log');
logContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>🧹 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">Log view cleared. Click refresh to reload events.</div>
</div>
`;
const logStats = document.getElementById('log-stats');
logStats.textContent = 'Log view cleared';
}
// Inicializar listeners para eventos
function initEventListeners() {
// Botones de control de log para el tab de events
const refreshBtn = document.getElementById('refresh-events-btn');
const clearBtn = document.getElementById('clear-events-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', loadEvents);
}
if (clearBtn) {
clearBtn.addEventListener('click', clearEventsView);
}
}
// Función para cargar eventos en el tab de events
window.loadEvents = function() {
fetch('/api/events?limit=50')
.then(response => response.json())
.then(data => {
if (data.success) {
const eventsContainer = document.getElementById('events-container');
const eventsCount = document.getElementById('events-count');
// Limpiar contenedor
eventsContainer.innerHTML = '';
// Actualizar contador
eventsCount.textContent = data.showing || 0;
// Añadir eventos (orden inverso para mostrar primero los más nuevos)
const events = data.events.reverse();
if (events.length === 0) {
eventsContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>📋 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">No events found</div>
</div>
`;
} else {
events.forEach(event => {
eventsContainer.appendChild(createLogEntry(event));
});
}
// Auto-scroll al inicio para mostrar eventos más nuevos
eventsContainer.scrollTop = 0;
} else {
console.error('Error loading events:', data.error);
showMessage('Error loading events log', 'error');
}
})
.catch(error => {
console.error('Error fetching events:', error);
showMessage('Error fetching events log', 'error');
});
}
// Función para limpiar vista de eventos
function clearEventsView() {
const eventsContainer = document.getElementById('events-container');
const eventsCount = document.getElementById('events-count');
eventsContainer.innerHTML = `
<div class="log-entry log-info">
<div class="log-header">
<span>🧹 System</span>
<span class="log-timestamp">${new Date().toLocaleString('es-ES')}</span>
</div>
<div class="log-message">Events view cleared. Click refresh to reload events.</div>
</div>
`;
eventsCount.textContent = '0';
}

View File

@ -0,0 +1,43 @@
/**
* Archivo principal que inicializa todos los componentes
*/
// Inicializar la aplicación al cargar el documento
document.addEventListener('DOMContentLoaded', function () {
// Inicializar tema
loadTheme();
// Iniciar streaming de estado automáticamente
startStatusStreaming();
// Cargar datos iniciales
loadDatasets();
updateStatus();
loadCsvConfig();
refreshEventLog();
// Inicializar listeners de eventos
initPlcListeners();
initDatasetListeners();
initVariableEditListeners();
initStreamingListeners();
initCsvListeners();
initEventListeners();
// Configurar actualizaciones periódicas como respaldo
setInterval(updateStatus, 30000); // Cada 30 segundos como respaldo
setInterval(refreshEventLog, 10000); // Cada 10 segundos
// Inicializar visibilidad de campos en formularios
toggleFields();
});
// Limpiar conexiones SSE cuando se descarga la página
window.addEventListener('beforeunload', function () {
if (variableEventSource) {
variableEventSource.close();
}
if (statusEventSource) {
statusEventSource.close();
}
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,79 @@
/**
* Gestión de la conexión con el PLC y configuración relacionada
*/
// Inicializar listeners de eventos para PLC
function initPlcListeners() {
// Configuración del PLC
document.getElementById('plc-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
ip: document.getElementById('plc-ip').value,
rack: parseInt(document.getElementById('plc-rack').value),
slot: parseInt(document.getElementById('plc-slot').value)
};
fetch('/api/plc/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
// Configuración UDP
document.getElementById('udp-config-form').addEventListener('submit', function (e) {
e.preventDefault();
const data = {
host: document.getElementById('udp-host').value,
port: parseInt(document.getElementById('udp-port').value)
};
fetch('/api/udp/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
// Botón de conexión PLC
document.getElementById('connect-btn').addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Botón de desconexión PLC
document.getElementById('disconnect-btn').addEventListener('click', function () {
fetch('/api/plc/disconnect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Botón de actualización de intervalo
document.getElementById('update-sampling-btn').addEventListener('click', function () {
const interval = parseFloat(document.getElementById('sampling-interval').value);
fetch('/api/sampling', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval: interval })
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,256 @@
/**
* Gestión del estado del sistema y actualizaciones en tiempo real
*/
// Variables para el streaming de estado
let statusEventSource = null;
let isStreamingStatus = false;
// Actualizar el estado del sistema
function updateStatus() {
fetch('/api/status')
.then(response => response.json())
.then(data => {
const plcStatus = document.getElementById('plc-status');
const streamStatus = document.getElementById('stream-status');
const csvStatus = document.getElementById('csv-status');
const diskSpaceStatus = document.getElementById('disk-space');
// Actualizar estado de conexión PLC
if (data.plc_connected) {
plcStatus.innerHTML = '🔌 PLC: Connected <div style="margin-top: 8px;"><button type="button" id="status-disconnect-btn">❌ Disconnect</button></div>';
plcStatus.className = 'status-item status-connected';
// Añadir event listener al nuevo botón de desconexión
document.getElementById('status-disconnect-btn').addEventListener('click', function () {
fetch('/api/plc/disconnect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
} else {
plcStatus.innerHTML = '🔌 PLC: Disconnected <div style="margin-top: 8px;"><button type="button" id="status-connect-btn">🔗 Connect</button></div>';
plcStatus.className = 'status-item status-disconnected';
// Añadir event listener al botón de conexión
document.getElementById('status-connect-btn').addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
// Actualizar estado de streaming UDP
if (data.streaming) {
streamStatus.innerHTML = '📡 UDP Streaming: Active <div style="margin-top: 8px;"><button type="button" id="status-streaming-btn">⏹️ Stop</button></div>';
streamStatus.className = 'status-item status-streaming';
// Añadir event listener al botón de parar streaming UDP
document.getElementById('status-streaming-btn').addEventListener('click', function () {
fetch('/api/udp/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
} else {
streamStatus.innerHTML = '📡 UDP Streaming: Inactive <div style="margin-top: 8px;"><button type="button" id="status-start-btn">▶️ Start</button></div>';
streamStatus.className = 'status-item status-idle';
// Añadir event listener al botón de iniciar streaming UDP
document.getElementById('status-start-btn').addEventListener('click', function () {
fetch('/api/udp/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
// Actualizar estado de grabación CSV
if (data.csv_recording) {
csvStatus.textContent = `💾 CSV: Recording`;
csvStatus.className = 'status-item status-streaming';
} else {
csvStatus.textContent = `💾 CSV: Inactive`;
csvStatus.className = 'status-item status-idle';
}
// Actualizar estado de espacio en disco
if (data.disk_space_info) {
diskSpaceStatus.innerHTML = `💽 Disk: ${data.disk_space_info.free_space} free<br>
⏱️ ~${data.disk_space_info.recording_time_left}`;
diskSpaceStatus.className = 'status-item status-idle';
} else {
diskSpaceStatus.textContent = '💽 Disk Space: Calculating...';
diskSpaceStatus.className = 'status-item status-idle';
}
})
.catch(error => console.error('Error updating status:', error));
}
// Iniciar streaming de estado en tiempo real
function startStatusStreaming() {
if (isStreamingStatus) {
return;
}
// Cerrar conexión existente si hay alguna
if (statusEventSource) {
statusEventSource.close();
}
// Crear nueva conexión EventSource
statusEventSource = new EventSource('/api/stream/status?interval=2.0');
statusEventSource.onopen = function (event) {
console.log('Status streaming connected');
isStreamingStatus = true;
};
statusEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'connected':
console.log('Status stream connected:', data.message);
break;
case 'status':
// Actualizar estado en tiempo real
updateStatusFromStream(data.status);
break;
case 'error':
console.error('Status stream error:', data.message);
break;
}
} catch (error) {
console.error('Error parsing status SSE data:', error);
}
};
statusEventSource.onerror = function (event) {
console.error('Status stream error:', event);
isStreamingStatus = false;
// Intentar reconectar después de un retraso
setTimeout(() => {
startStatusStreaming();
}, 10000);
};
}
// Detener streaming de estado en tiempo real
function stopStatusStreaming() {
if (statusEventSource) {
statusEventSource.close();
statusEventSource = null;
}
isStreamingStatus = false;
}
// Actualizar estado desde datos de streaming
function updateStatusFromStream(status) {
const plcStatus = document.getElementById('plc-status');
const streamStatus = document.getElementById('stream-status');
const csvStatus = document.getElementById('csv-status');
const diskSpaceStatus = document.getElementById('disk-space');
// Actualizar estado de conexión PLC
if (status.plc_connected) {
plcStatus.innerHTML = '🔌 PLC: Connected <div style="margin-top: 8px;"><button type="button" id="status-disconnect-btn">❌ Disconnect</button></div>';
plcStatus.className = 'status-item status-connected';
// Añadir event listener al nuevo botón de desconexión
const disconnectBtn = document.getElementById('status-disconnect-btn');
if (disconnectBtn) {
disconnectBtn.addEventListener('click', function () {
fetch('/api/plc/disconnect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
} else {
plcStatus.innerHTML = '🔌 PLC: Disconnected <div style="margin-top: 8px;"><button type="button" id="status-connect-btn">🔗 Connect</button></div>';
plcStatus.className = 'status-item status-disconnected';
// Añadir event listener al botón de conexión
const connectBtn = document.getElementById('status-connect-btn');
if (connectBtn) {
connectBtn.addEventListener('click', function () {
fetch('/api/plc/connect', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
}
// Actualizar estado de streaming UDP
if (status.streaming) {
streamStatus.innerHTML = '📡 UDP Streaming: Active <div style="margin-top: 8px;"><button type="button" id="status-streaming-btn">⏹️ Stop</button></div>';
streamStatus.className = 'status-item status-streaming';
// Añadir event listener al botón de parar streaming UDP
const stopBtn = document.getElementById('status-streaming-btn');
if (stopBtn) {
stopBtn.addEventListener('click', function () {
fetch('/api/udp/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
} else {
streamStatus.innerHTML = '📡 UDP Streaming: Inactive <div style="margin-top: 8px;"><button type="button" id="status-start-btn">▶️ Start</button></div>';
streamStatus.className = 'status-item status-idle';
// Añadir event listener al botón de iniciar streaming UDP
const startBtn = document.getElementById('status-start-btn');
if (startBtn) {
startBtn.addEventListener('click', function () {
fetch('/api/udp/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
}
}
// Actualizar estado de grabación CSV
if (status.csv_recording) {
csvStatus.textContent = `💾 CSV: Recording`;
csvStatus.className = 'status-item status-streaming';
} else {
csvStatus.textContent = `💾 CSV: Inactive`;
csvStatus.className = 'status-item status-idle';
}
// Actualizar estado de espacio en disco
if (status.disk_space_info) {
diskSpaceStatus.innerHTML = `💽 Disk: ${status.disk_space_info.free_space} free<br>
⏱️ ~${status.disk_space_info.recording_time_left}`;
diskSpaceStatus.className = 'status-item status-idle';
} else {
diskSpaceStatus.textContent = '💽 Disk Space: Calculating...';
diskSpaceStatus.className = 'status-item status-idle';
}
}

View File

@ -0,0 +1,54 @@
/**
* Gestión del streaming UDP a PlotJuggler (independiente del recording CSV)
*/
// Inicializar listeners para el control de streaming UDP
function initStreamingListeners() {
// Iniciar streaming UDP
document.getElementById('start-streaming-btn').addEventListener('click', function () {
fetch('/api/udp/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
})
.catch(error => {
console.error('Error starting UDP streaming:', error);
showMessage('Error starting UDP streaming', 'error');
});
});
// Detener streaming UDP
document.getElementById('stop-streaming-btn').addEventListener('click', function () {
fetch('/api/udp/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
})
.catch(error => {
console.error('Error stopping UDP streaming:', error);
showMessage('Error stopping UDP streaming', 'error');
});
});
// Cargar estado de streaming de variables
loadStreamingStatus();
}
// Cargar estado de variables en streaming
function loadStreamingStatus() {
fetch('/api/variables/streaming')
.then(response => response.json())
.then(data => {
if (data.success) {
data.streaming_variables.forEach(varName => {
const checkbox = document.getElementById(`stream-${varName}`);
if (checkbox) {
checkbox.checked = true;
}
});
}
})
.catch(error => console.error('Error loading streaming status:', error));
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,296 @@
/**
* Tab System Management
* Maneja la navegación entre tabs en la aplicación
*/
class TabManager {
constructor() {
this.currentTab = 'datasets';
this.plotTabs = new Set(); // Track dynamic plot tabs
this.init();
}
init() {
// Event listeners para los botones de tab estáticos
this.bindStaticTabs();
// Inicializar con el tab activo por defecto
this.switchTab(this.currentTab);
console.log('📑 Tab Manager initialized');
}
bindStaticTabs() {
// Solo bindear tabs estáticos, los dinámicos se bindean al crearlos
document.querySelectorAll('.tab-btn:not([data-plot-id])').forEach(btn => {
btn.addEventListener('click', (e) => {
const tabName = e.target.dataset.tab;
this.switchTab(tabName);
});
});
}
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}`);
}
}
createPlotTab(sessionId, plotName) {
// Crear botón de sub-tab dinámico
const subTabBtn = document.createElement('button');
subTabBtn.className = 'sub-tab-btn plot-sub-tab';
subTabBtn.dataset.subTab = `plot-${sessionId}`;
subTabBtn.dataset.plotId = sessionId;
subTabBtn.innerHTML = `
📈 ${plotName}
<span class="sub-tab-close" data-session-id="${sessionId}">&times;</span>
`;
// Crear contenido del sub-tab
const subTabContent = document.createElement('div');
subTabContent.className = 'sub-tab-content plot-sub-tab-content';
subTabContent.id = `plot-${sessionId}-sub-tab`;
subTabContent.innerHTML = `
<article>
<header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>📈 ${plotName}</span>
<div>
<button type="button" class="outline" onclick="window.editPlotSession('${sessionId}')">
✏️ Edit Plot
</button>
<button type="button" class="secondary" onclick="window.removePlotSession('${sessionId}')">
🗑️ Remove Plot
</button>
</div>
</div>
</header>
<div class="plot-session" id="plot-session-${sessionId}">
<div class="plot-header">
<h4>📈 ${plotName}</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>
</div>
</div>
<div class="plot-info">
<span class="plot-stats" id="plot-stats-${sessionId}">
Loading plot information...
</span>
</div>
<div class="plot-canvas">
<canvas id="chart-${sessionId}"></canvas>
</div>
</div>
</article>
`;
// Mostrar sub-tabs si no están visibles
const subTabs = document.getElementById('plot-sub-tabs');
const plotSessionsContainer = document.getElementById('plot-sessions-container');
const plotSubContent = document.getElementById('plot-sub-content');
if (subTabs.style.display === 'none') {
subTabs.style.display = 'flex';
plotSessionsContainer.style.display = 'none';
plotSubContent.style.display = 'block';
}
// Agregar sub-tab al contenedor de sub-tabs
subTabs.appendChild(subTabBtn);
// Agregar contenido del sub-tab
plotSubContent.appendChild(subTabContent);
// Bind events
subTabBtn.addEventListener('click', (e) => {
if (!e.target.classList.contains('sub-tab-close')) {
this.switchSubTab(`plot-${sessionId}`);
}
});
// Close button event
subTabBtn.querySelector('.sub-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
// Llamar a la función que elimina el plot del backend Y del frontend
if (typeof window.removePlotSession === 'function') {
window.removePlotSession(sessionId);
} else {
console.error('removePlotSession function not available');
// Fallback: solo remover del frontend
this.removePlotTab(sessionId);
}
});
this.plotTabs.add(sessionId);
console.log(`📑 Created plot sub-tab for session: ${sessionId}`);
return subTabBtn;
}
switchSubTab(subTabName) {
// Remover clase active de todos los sub-tabs
document.querySelectorAll('.sub-tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.sub-tab-content').forEach(content => {
content.classList.remove('active');
});
// Activar el sub-tab seleccionado
const activeBtn = document.querySelector(`[data-sub-tab="${subTabName}"]`);
const activeContent = document.getElementById(`${subTabName}-sub-tab`);
if (activeBtn && activeContent) {
activeBtn.classList.add('active');
activeContent.classList.add('active');
// Eventos específicos por sub-tab
this.handleSubTabSpecificEvents(subTabName);
console.log(`📑 Switched to sub-tab: ${subTabName}`);
}
}
handleSubTabSpecificEvents(subTabName) {
if (subTabName.startsWith('plot-')) {
// Sub-tab de plot individual - cargar datos específicos
const sessionId = subTabName.replace('plot-', '');
if (typeof plotManager !== 'undefined') {
plotManager.updateSessionData(sessionId);
}
}
}
removePlotTab(sessionId) {
// Remover sub-tab button
const subTabBtn = document.querySelector(`[data-plot-id="${sessionId}"]`);
if (subTabBtn) {
subTabBtn.remove();
}
// Remover sub-tab content
const subTabContent = document.getElementById(`plot-${sessionId}-sub-tab`);
if (subTabContent) {
subTabContent.remove();
}
this.plotTabs.delete(sessionId);
// Si no quedan sub-tabs, mostrar vista inicial
const subTabs = document.getElementById('plot-sub-tabs');
const plotSessionsContainer = document.getElementById('plot-sessions-container');
const plotSubContent = document.getElementById('plot-sub-content');
if (subTabs.children.length === 0) {
subTabs.style.display = 'none';
plotSessionsContainer.style.display = 'block';
plotSubContent.style.display = 'none';
}
console.log(`📑 Removed plot sub-tab for session: ${sessionId}`);
}
updatePlotTabName(sessionId, newName) {
const subTabBtn = document.querySelector(`[data-plot-id="${sessionId}"]`);
if (subTabBtn) {
subTabBtn.innerHTML = `
📈 ${newName}
<span class="sub-tab-close" data-session-id="${sessionId}">&times;</span>
`;
// Re-bind close event
subTabBtn.querySelector('.sub-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
// Llamar a la función que elimina el plot del backend Y del frontend
if (typeof window.removePlotSession === 'function') {
window.removePlotSession(sessionId);
} else {
console.error('removePlotSession function not available');
// Fallback: solo remover del frontend
this.removePlotTab(sessionId);
}
});
}
// Actualizar header del contenido
const header = document.querySelector(`#plot-${sessionId}-sub-tab h4`);
if (header) {
header.textContent = `📈 ${newName}`;
}
const articleHeader = document.querySelector(`#plot-${sessionId}-sub-tab header span`);
if (articleHeader) {
articleHeader.textContent = `📈 ${newName}`;
}
}
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();
});

View File

@ -0,0 +1,32 @@
/**
* Gestión del tema de la aplicación (claro/oscuro/auto)
*/
// Establecer el tema
function setTheme(theme) {
const html = document.documentElement;
const buttons = document.querySelectorAll('.theme-selector button');
// Eliminar clase active de todos los botones
buttons.forEach(btn => btn.classList.remove('active'));
// Establecer tema
html.setAttribute('data-theme', theme);
// Añadir clase active al botón seleccionado
document.getElementById(`theme-${theme}`).classList.add('active');
// Guardar preferencia en localStorage
localStorage.setItem('theme', theme);
}
// Cargar tema guardado al cargar la página
function loadTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
setTheme(savedTheme);
}
// Inicializar tema al cargar la página
document.addEventListener('DOMContentLoaded', function () {
loadTheme();
});

View File

@ -0,0 +1,64 @@
/**
* Funciones de utilidad general para la aplicación
*/
// Función para mostrar mensajes en la interfaz
function showMessage(message, type = 'success') {
const messagesDiv = document.getElementById('messages');
let alertClass;
switch (type) {
case 'success':
alertClass = 'alert-success';
break;
case 'warning':
alertClass = 'alert-warning';
break;
case 'info':
alertClass = 'alert-info';
break;
case 'error':
default:
alertClass = 'alert-error';
break;
}
messagesDiv.innerHTML = `<div class="alert ${alertClass}">${message}</div>`;
setTimeout(() => {
messagesDiv.innerHTML = '';
}, 5000);
}
// Formatear timestamp para los logs
function formatTimestamp(isoString) {
const date = new Date(isoString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// Obtener icono para tipo de evento
function getEventIcon(eventType) {
const icons = {
'plc_connection': '🔗',
'plc_connection_failed': '❌',
'plc_disconnection': '🔌',
'plc_disconnection_error': '⚠️',
'streaming_started': '▶️',
'streaming_stopped': '⏹️',
'streaming_error': '❌',
'csv_started': '💾',
'csv_stopped': '📁',
'csv_error': '❌',
'config_change': '⚙️',
'variable_added': '',
'variable_removed': '',
'application_started': '🚀'
};
return icons[eventType] || '📋';
}

View File

@ -0,0 +1,322 @@
/**
* Gestión de variables y streaming de valores en tiempo real
*/
// Variables para el streaming de variables
let variableEventSource = null;
let isStreamingVariables = false;
// Toggle de campos de variables según el área de memoria
function toggleFields() {
const area = document.getElementById('var-area').value;
const dbField = document.getElementById('db-field');
const dbInput = document.getElementById('var-db');
const bitField = document.getElementById('bit-field');
const typeSelect = document.getElementById('var-type');
// Manejar campo DB
if (area === 'db') {
dbField.style.display = 'block';
dbInput.required = true;
} else {
dbField.style.display = 'none';
dbInput.required = false;
dbInput.value = 1; // Valor por defecto para áreas no DB
}
// Manejar campo Bit y restricciones de tipo de datos
if (area === 'e' || area === 'a' || area === 'mb') {
bitField.style.display = 'block';
// Para áreas de bit, forzar tipo de dato a bool
typeSelect.value = 'bool';
// Deshabilitar otros tipos de datos para áreas de bit
Array.from(typeSelect.options).forEach(option => {
option.disabled = (option.value !== 'bool');
});
} else {
bitField.style.display = 'none';
// Re-habilitar todos los tipos de datos para áreas no-bit
Array.from(typeSelect.options).forEach(option => {
option.disabled = false;
});
}
}
// Toggle de campos de edición de variables
function toggleEditFields() {
const area = document.getElementById('edit-var-area').value;
const dbField = document.getElementById('edit-db-field');
const dbInput = document.getElementById('edit-var-db');
const bitField = document.getElementById('edit-bit-field');
const typeSelect = document.getElementById('edit-var-type');
// Manejar campo DB
if (area === 'db') {
dbField.style.display = 'block';
dbInput.required = true;
} else {
dbField.style.display = 'none';
dbInput.required = false;
dbInput.value = 1; // Valor por defecto para áreas no DB
}
// Manejar campo Bit y restricciones de tipo de datos
if (area === 'e' || area === 'a' || area === 'mb') {
bitField.style.display = 'block';
// Para áreas de bit, forzar tipo de dato a bool
typeSelect.value = 'bool';
// Deshabilitar otros tipos de datos para áreas de bit
Array.from(typeSelect.options).forEach(option => {
option.disabled = (option.value !== 'bool');
});
} else {
bitField.style.display = 'none';
// Re-habilitar todos los tipos de datos para áreas no-bit
Array.from(typeSelect.options).forEach(option => {
option.disabled = false;
});
}
}
// Actualizar streaming para una variable
function toggleStreaming(varName, enabled) {
if (!currentDatasetId) {
showMessage('No dataset selected', 'error');
return;
}
fetch(`/api/datasets/${currentDatasetId}/variables/${varName}/streaming`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus(); // Actualizar contador de variables de streaming
})
.catch(error => {
console.error('Error toggling streaming:', error);
showMessage('Error updating streaming setting', 'error');
});
}
// Auto-start live display when dataset changes (if PLC is connected)
function autoStartLiveDisplay() {
if (currentDatasetId) {
// Check if PLC is connected by fetching status
fetch('/api/status')
.then(response => response.json())
.then(status => {
if (status.plc_connected && !isStreamingVariables) {
startVariableStreaming();
showMessage('Live display started automatically for active dataset', 'info');
}
})
.catch(error => {
console.error('Error checking PLC status:', error);
});
}
}
// Limpiar todos los valores de variables y establecer mensaje de estado
function clearVariableValues(statusMessage = '--') {
// Encontrar todas las celdas de valor y limpiarlas
const valueCells = document.querySelectorAll('[id^="value-"]');
valueCells.forEach(cell => {
cell.textContent = statusMessage;
cell.style.color = 'var(--pico-muted-color)';
});
}
// Iniciar streaming de variables en tiempo real
function startVariableStreaming() {
if (!currentDatasetId || isStreamingVariables) {
return;
}
// Cerrar conexión existente si hay alguna
if (variableEventSource) {
variableEventSource.close();
}
// Crear nueva conexión EventSource
variableEventSource = new EventSource(`/api/stream/variables?dataset_id=${currentDatasetId}&interval=1.0`);
variableEventSource.onopen = function (event) {
console.log('Variable streaming connected');
isStreamingVariables = true;
updateStreamingIndicator(true);
};
variableEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'connected':
console.log('Variable stream connected:', data.message);
break;
case 'values':
// Actualizar valores de variables en tiempo real desde caché
updateVariableValuesFromStream(data);
break;
case 'cache_error':
console.error('Cache error in variable stream:', data.message);
showMessage(`Cache error: ${data.message}`, 'error');
clearVariableValues('CACHE ERROR');
break;
case 'plc_disconnected':
clearVariableValues('PLC OFFLINE');
showMessage('PLC disconnected - cache not being populated', 'warning');
break;
case 'dataset_inactive':
clearVariableValues('DATASET INACTIVE');
showMessage('Dataset is not active - activate to populate cache', 'warning');
break;
case 'no_variables':
clearVariableValues('NO VARIABLES');
showMessage('No variables defined in this dataset', 'info');
break;
case 'no_cache':
clearVariableValues('READING...');
const samplingInfo = data.sampling_interval ? ` (every ${data.sampling_interval}s)` : '';
showMessage(`Waiting for cache to be populated${samplingInfo}`, 'info');
break;
case 'stream_error':
console.error('SSE stream error:', data.message);
showMessage(`Streaming error: ${data.message}`, 'error');
break;
default:
console.warn('Unknown SSE message type:', data.type);
break;
}
} catch (error) {
console.error('Error parsing SSE data:', error);
}
};
variableEventSource.onerror = function (event) {
console.error('Variable stream error:', event);
isStreamingVariables = false;
updateStreamingIndicator(false);
// Intentar reconectar después de un retraso
setTimeout(() => {
if (currentDatasetId) {
startVariableStreaming();
}
}, 5000);
};
}
// Detener streaming de variables en tiempo real
function stopVariableStreaming() {
if (variableEventSource) {
variableEventSource.close();
variableEventSource = null;
}
isStreamingVariables = false;
updateStreamingIndicator(false);
}
// Actualizar valores de variables desde datos de streaming
function updateVariableValuesFromStream(data) {
const values = data.values;
const timestamp = data.timestamp;
const source = data.source;
const stats = data.stats;
// Actualizar cada valor de variable
Object.keys(values).forEach(varName => {
const valueCell = document.getElementById(`value-${varName}`);
if (valueCell) {
const value = values[varName];
valueCell.textContent = value;
// Código de color basado en el estado del valor
if (value === 'ERROR' || value === 'FORMAT_ERROR') {
valueCell.style.color = 'var(--pico-color-red-500)';
valueCell.style.fontWeight = 'bold';
} else {
valueCell.style.color = 'var(--pico-color-green-600)';
valueCell.style.fontWeight = 'bold';
}
}
});
// Actualizar timestamp e información de origen
const lastRefreshTime = document.getElementById('last-refresh-time');
if (lastRefreshTime) {
const sourceIcon = source === 'cache' ? '📊' : '🔗';
const sourceText = source === 'cache' ? 'streaming cache' : 'direct PLC';
if (stats && stats.failed > 0) {
lastRefreshTime.innerHTML = `
<span style="color: var(--pico-color-green-600);">🔄 Live streaming</span><br/>
<small style="color: var(--pico-color-amber-600);">
⚠️ ${stats.success}/${stats.total} variables (${stats.failed} failed)
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText} • ${new Date(timestamp).toLocaleTimeString()}
</small>
`;
} else {
lastRefreshTime.innerHTML = `
<span style="color: var(--pico-color-green-600);">🔄 Live streaming</span><br/>
<small style="color: var(--pico-color-green-600);">
✅ All ${stats ? stats.success : 'N/A'} variables OK
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText} • ${new Date(timestamp).toLocaleTimeString()}
</small>
`;
}
}
}
// Actualizar indicador de streaming
function updateStreamingIndicator(isStreaming) {
const toggleBtn = document.getElementById('toggle-streaming-btn');
if (toggleBtn) {
if (isStreaming) {
toggleBtn.innerHTML = '⏹️ Stop Live Display';
toggleBtn.title = 'Stop live variable display';
} else {
toggleBtn.innerHTML = '▶️ Start Live Display';
toggleBtn.title = 'Start live variable display';
}
}
}
// Alternar streaming en tiempo real
function toggleRealTimeStreaming() {
if (isStreamingVariables) {
stopVariableStreaming();
showMessage('Real-time streaming stopped', 'info');
} else {
startVariableStreaming();
showMessage('Real-time streaming started', 'success');
}
// Actualizar texto del botón
const toggleBtn = document.getElementById('toggle-streaming-btn');
if (toggleBtn) {
if (isStreamingVariables) {
toggleBtn.innerHTML = '⏹️ Stop Live Streaming';
} else {
toggleBtn.innerHTML = '▶️ Start Live Streaming';
}
}
}