Refactorizado de index.html

This commit is contained in:
Miguel 2025-07-20 23:05:14 +02:00
parent 5ea9e51cd4
commit a37cb8be3b
19 changed files with 3056 additions and 2635 deletions

View File

@ -2,7 +2,7 @@
## PLC S7-315 Streamer & Logger
Functional Description of the Application
## Functional Description of the Application
This application is a web server (created with the Flask framework in Python) that acts as an intermediary to monitor and record data in CSV format from a PLC Siemens S7 with the SNAP7 library to be used on a low-resource PC connected to the PLC.
It must be as simple as possible to allow the pack using PyInstaller

View File

@ -2527,8 +2527,232 @@
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T19:55:09.970238",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-20T19:55:21.690709",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T19:55:32.161728",
"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-20T19:55:32.167727",
"level": "info",
"event_type": "streaming_started",
"message": "Multi-dataset streaming started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2,
"udp_host": "127.0.0.1",
"udp_port": 9870
}
},
{
"timestamp": "2025-07-20T22:58:33.195123",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-07-20T23:00:02.947627",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T23:00:05.459324",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T23:00:10.767162",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "dar",
"variables_count": 6,
"streaming_count": 4,
"prefix": "dar"
}
},
{
"timestamp": "2025-07-20T23:00:10.770176",
"level": "info",
"event_type": "streaming_started",
"message": "Multi-dataset streaming started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2,
"udp_host": "127.0.0.1",
"udp_port": 9870
}
},
{
"timestamp": "2025-07-20T23:00:21.646936",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "dar"
}
},
{
"timestamp": "2025-07-20T23:00:21.651937",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 1 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:00:21.655936",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 0 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:00:21.658937",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.249",
"details": {}
},
{
"timestamp": "2025-07-20T23:01:23.105142",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T23:01:33.860399",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 0 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:01:33.863399",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 0 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:01:33.866398",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.249",
"details": {}
},
{
"timestamp": "2025-07-20T23:02:06.003577",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T23:02:11.447466",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 0 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:02:11.452936",
"level": "info",
"event_type": "streaming_stopped",
"message": "Multi-dataset streaming stopped: 0 datasets deactivated",
"details": {}
},
{
"timestamp": "2025-07-20T23:02:11.456927",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.249",
"details": {}
},
{
"timestamp": "2025-07-20T23:03:30.463799",
"level": "info",
"event_type": "plc_connection",
"message": "Successfully connected to PLC 10.1.33.249",
"details": {
"ip": "10.1.33.249",
"rack": 0,
"slot": 2
}
},
{
"timestamp": "2025-07-20T23:03:40.709674",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "dar",
"variables_count": 6,
"streaming_count": 4,
"prefix": "dar"
}
},
{
"timestamp": "2025-07-20T23:03:40.714677",
"level": "info",
"event_type": "streaming_started",
"message": "Multi-dataset streaming started: 1 datasets activated",
"details": {
"activated_datasets": 1,
"total_datasets": 2,
"udp_host": "127.0.0.1",
"udp_port": 9870
}
}
],
"last_updated": "2025-07-20T10:58:20.099906",
"total_entries": 236
"last_updated": "2025-07-20T23:03:40.714677",
"total_entries": 260
}

View File

@ -248,7 +248,15 @@ class PLCClient:
def read_multiple_variables(
self, variables: Dict[str, Dict[str, Any]]
) -> Dict[str, Any]:
"""Read multiple variables from the PLC"""
"""Read multiple variables from the PLC
INTERNAL USE ONLY - APIs should use cache instead
This method performs direct PLC reads and should only be called by:
- DataStreamer.read_dataset_variables() during streaming cycles
- Internal diagnostic operations
Public APIs should use cached values via DataStreamer.get_cached_dataset_values()
"""
if not self.is_connected():
return {}
@ -267,7 +275,17 @@ class PLCClient:
def read_multiple_variables_with_diagnostics(
self, variables: Dict[str, Dict[str, Any]]
) -> Dict[str, Any]:
"""Read multiple variables from the PLC with detailed error diagnostics"""
"""Read multiple variables from the PLC with detailed error diagnostics
INTERNAL USE ONLY - APIs should use cache instead
This method performs direct PLC reads and should only be called by:
- DataStreamer.read_dataset_variables() during streaming cycles
- Internal diagnostic operations
Public APIs should use cached values via DataStreamer.get_cached_dataset_values()
according to the application's single-read principle where variables are read
only once per dataset interval and cached for all other uses.
"""
if not self.is_connected():
return {
"success": False,
@ -314,33 +332,24 @@ class PLCClient:
except Exception as e:
data[var_name] = None
error_msg = f"Unexpected error: {type(e).__name__}: {str(e)}"
errors[var_name] = error_msg
errors[var_name] = f"Unexpected error: {str(e)}"
if self.logger:
self.logger.error(f"Unexpected error reading {var_name}: {e}")
# Determine overall success
if success_count == 0:
if total_count == 0:
return {
"success": True,
"message": "No variables to read",
"values": {},
"errors": {},
}
else:
return {
"success": False,
"error": "Failed to read any variables",
"error_type": "all_failed",
"values": data,
"errors": errors,
"stats": {
"success": 0,
"failed": total_count,
"total": total_count,
},
}
return {
"success": False,
"error": f"Failed to read any variables (0/{total_count})",
"error_type": "all_failed",
"values": data,
"errors": errors,
"stats": {
"success": success_count,
"failed": total_count - success_count,
"total": total_count,
},
}
elif success_count < total_count:
return {
"success": True,
@ -356,10 +365,13 @@ class PLCClient:
else:
return {
"success": True,
"message": f"Successfully read all {total_count} variables",
"values": data,
"errors": {},
"stats": {"success": success_count, "failed": 0, "total": total_count},
"stats": {
"success": success_count,
"failed": 0,
"total": total_count,
},
}
def get_connection_info(self) -> Dict[str, Any]:

View File

@ -22,7 +22,22 @@ def resource_path(relative_path):
class DataStreamer:
"""Handles data streaming, CSV recording, and dataset management"""
"""Handles data streaming, CSV recording, and dataset management
🔑 CORE PRINCIPLE: Single PLC Read per Dataset Interval
========================================================
This class implements the application's core principle of reading PLC variables
only once per dataset at their configured sampling intervals, then using cached
values for all other operations (CSV recording, UDP streaming, web interface).
Data Flow:
1. dataset_streaming_loop() reads ALL variables in a dataset at configured interval
2. read_dataset_variables() performs the actual PLC read and updates cache
3. All other functions (APIs, streaming, frontend) use get_cached_dataset_values()
4. NO direct PLC reads outside of the streaming loops
This protects the PLC from overload and ensures data consistency across all outputs.
"""
def __init__(self, config_manager, plc_client, event_logger, logger=None):
"""Initialize data streamer"""
@ -44,7 +59,9 @@ class DataStreamer:
self.dataset_csv_hours = {} # dataset_id -> current hour
self.dataset_using_modification_files = {} # dataset_id -> bool
# Cache for last read values (exactly what's being written to CSV)
# 📊 CACHE SYSTEM - Central data storage
# This is the ONLY source of data for all APIs and interfaces
# Updated ONLY by read_dataset_variables() during streaming cycles
self.last_read_values = {} # dataset_id -> {var_name: value}
self.last_read_timestamps = {} # dataset_id -> timestamp
self.last_read_errors = {} # dataset_id -> {var_name: error_message}
@ -281,7 +298,18 @@ class DataStreamer:
def read_dataset_variables(
self, dataset_id: str, variables: Dict[str, Any]
) -> Dict[str, Any]:
"""Read all variables for a specific dataset and update cache"""
"""Read all variables for a specific dataset and update cache
🔑 THIS IS THE ONLY FUNCTION THAT READS FROM PLC
================================================
This function implements the application's core principle:
- Variables are read from PLC ONLY here, at dataset intervals
- All other functions must use get_cached_dataset_values()
- This ensures single read per interval, protecting PLC from overload
Called by: dataset_streaming_loop() at configured intervals
Updates: self.last_read_values cache for use by all other functions
"""
data = {}
errors = {}
timestamp = datetime.now()
@ -323,7 +351,19 @@ class DataStreamer:
return data
def get_cached_dataset_values(self, dataset_id: str) -> Dict[str, Any]:
"""Get cached values for a dataset (values used for CSV generation)"""
"""Get cached values for a dataset (values used for CSV generation)
🎯 CACHE-ONLY DATA ACCESS - NO PLC READS
========================================
This function provides the ONLY way for APIs and interfaces to access
variable values according to the application's design principle.
Data Source: Cache populated by read_dataset_variables() only
Used by: Web APIs, frontend refresh, SSE streaming, monitoring
Never: Performs direct PLC reads (use read_dataset_variables for that)
Returns formatted response with success/error status and detailed diagnostics
"""
if dataset_id not in self.last_read_values:
return {
"success": False,

359
main.py
View File

@ -552,7 +552,7 @@ def get_streaming_variables():
@app.route("/api/datasets/<dataset_id>/variables/values", methods=["GET"])
def get_dataset_variable_values(dataset_id):
"""Get current values of all variables in a dataset"""
"""Get current values of all variables in a dataset - CACHE ONLY"""
error_response = check_streamer_initialized()
if error_response:
return error_response
@ -567,19 +567,6 @@ def get_dataset_variable_values(dataset_id):
404,
)
# Check if PLC is connected
if not streamer.plc_client.is_connected():
return (
jsonify(
{
"success": False,
"message": "PLC not connected. Please connect to PLC first.",
"values": {},
}
),
400,
)
# Get dataset variables
dataset_variables = streamer.get_dataset_variables(dataset_id)
@ -589,28 +576,83 @@ def get_dataset_variable_values(dataset_id):
"success": True,
"message": "No variables defined in this dataset",
"values": {},
"source": "no_variables",
}
)
# First, try to get cached values (values used for CSV generation)
if streamer.has_cached_values(dataset_id):
read_result = streamer.get_cached_dataset_values(dataset_id)
# Convert timestamp from ISO format to readable format for consistency
if read_result.get("timestamp"):
try:
cached_timestamp = datetime.fromisoformat(read_result["timestamp"])
read_result["timestamp"] = cached_timestamp.strftime(
"%Y-%m-%d %H:%M:%S"
)
except:
pass # Keep original timestamp if conversion fails
else:
# Fallback: Read directly from PLC if no cached values available
read_result = streamer.plc_client.read_multiple_variables_with_diagnostics(
dataset_variables
# Check if dataset is active (required for cache to be populated)
if dataset_id not in streamer.active_datasets:
return jsonify(
{
"success": False,
"message": f"Dataset '{dataset_id}' is not active. Activate the dataset to start reading variables and populate cache.",
"error_type": "dataset_inactive",
"values": {},
"detailed_errors": {},
"stats": {
"success": 0,
"failed": 0,
"total": len(dataset_variables),
},
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source": "no_cache_dataset_inactive",
"is_cached": False,
}
)
read_result["source"] = "plc_direct"
# Check if PLC is connected (required for streaming to populate cache)
if not streamer.plc_client.is_connected():
return jsonify(
{
"success": False,
"message": "PLC not connected. Connect to PLC and activate dataset to populate cache.",
"error_type": "plc_disconnected",
"values": {},
"detailed_errors": {},
"stats": {
"success": 0,
"failed": 0,
"total": len(dataset_variables),
},
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source": "no_cache_plc_disconnected",
"is_cached": False,
}
)
# Get cached values - this is the ONLY source of data according to application principles
if not streamer.has_cached_values(dataset_id):
return jsonify(
{
"success": False,
"message": f"No cached values available for dataset '{dataset_id}'. Cache is populated by the streaming process at the dataset's configured interval. Please wait for the next reading cycle.",
"error_type": "no_cache_available",
"values": {},
"detailed_errors": {},
"stats": {
"success": 0,
"failed": 0,
"total": len(dataset_variables),
},
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source": "no_cache_available",
"is_cached": False,
"note": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s",
}
)
# Get cached values (the ONLY valid source according to application design)
read_result = streamer.get_cached_dataset_values(dataset_id)
# Convert timestamp from ISO format to readable format for consistency
if read_result.get("timestamp"):
try:
cached_timestamp = datetime.fromisoformat(read_result["timestamp"])
read_result["timestamp"] = cached_timestamp.strftime(
"%Y-%m-%d %H:%M:%S"
)
except:
pass # Keep original timestamp if conversion fails
# Extract values and handle diagnostics
if not read_result.get("success", False):
@ -621,15 +663,12 @@ def get_dataset_variable_values(dataset_id):
# Log detailed error information
if streamer.logger:
streamer.logger.error(
f"Failed to read any variables from dataset '{dataset_id}': {error_msg}"
f"Cached values indicate failure for dataset '{dataset_id}': {error_msg}"
)
if read_result.get("errors"):
for var_name, var_error in read_result["errors"].items():
streamer.logger.error(f" Variable '{var_name}': {var_error}")
# Determine source for error case
error_source = read_result.get("source", "unknown")
return (
jsonify(
{
@ -642,8 +681,8 @@ def get_dataset_variable_values(dataset_id):
"timestamp": read_result.get(
"timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S")
),
"source": error_source,
"is_cached": error_source == "cache",
"source": "cache",
"is_cached": True,
}
),
500,
@ -704,25 +743,23 @@ def get_dataset_variable_values(dataset_id):
success_vars = stats.get("success", 0)
failed_vars = stats.get("failed", 0)
# Determine data source for message
data_source = read_result.get("source", "unknown")
source_text = ""
if data_source == "cache":
source_text = " (from last streaming cycle)"
elif data_source == "plc_direct":
source_text = " (direct PLC read)"
# Determine data source for message (always cache now)
data_source = "cache"
source_text = " (from streaming cache)"
if failed_vars == 0:
message = f"Successfully read all {success_vars} variables{source_text}"
message = (
f"Successfully displaying all {success_vars} variables{source_text}"
)
response_success = True
else:
message = f"Partial success: {success_vars}/{total_vars} variables read successfully, {failed_vars} failed{source_text}"
message = f"Partial data available: {success_vars}/{total_vars} variables have valid cached values, {failed_vars} failed{source_text}"
response_success = True # Still success if we got some values
# Log warnings for partial failures
if streamer.logger:
streamer.logger.warning(
f"Partial failure reading variables from dataset '{dataset_id}': {message}"
f"Partial failure in cached values for dataset '{dataset_id}': {message}"
)
for var_name, var_error in error_details.items():
if formatted_values.get(var_name) == "ERROR":
@ -740,7 +777,8 @@ def get_dataset_variable_values(dataset_id):
),
"warning": read_result.get("warning"),
"source": data_source,
"is_cached": data_source == "cache",
"is_cached": True,
"cache_info": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s",
}
)
@ -749,7 +787,7 @@ def get_dataset_variable_values(dataset_id):
jsonify(
{
"success": False,
"message": f"Error reading variable values: {str(e)}",
"message": f"Error retrieving cached variable values: {str(e)}",
"values": {},
}
),
@ -1146,119 +1184,150 @@ def stream_variables():
return jsonify({"error": f"Dataset {dataset_id} not found"}), 404
def generate():
"""Generate SSE data stream"""
"""Generate SSE data stream - CACHE ONLY"""
# Send initial connection message
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n"
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established - monitoring cache'})}\n\n"
last_values = {}
while True:
try:
# Get current variable values
if streamer.plc_client.is_connected():
dataset_variables = streamer.get_dataset_variables(dataset_id)
if dataset_variables:
# Try to get cached values first
if streamer.has_cached_values(dataset_id):
read_result = streamer.get_cached_dataset_values(dataset_id)
else:
# Fallback to direct PLC read
read_result = streamer.plc_client.read_multiple_variables_with_diagnostics(
dataset_variables
)
read_result["source"] = "plc_direct"
if read_result.get("success", False):
values = read_result.get("values", {})
timestamp = read_result.get(
"timestamp", datetime.now().isoformat()
)
# Format values for display
formatted_values = {}
for var_name, value in values.items():
if value is not None:
var_config = dataset_variables[var_name]
var_type = var_config.get("type", "unknown")
try:
if var_type == "real":
formatted_values[var_name] = (
f"{value:.3f}"
if isinstance(value, (int, float))
else str(value)
)
elif var_type == "bool":
formatted_values[var_name] = (
"TRUE" if value else "FALSE"
)
elif var_type in [
"int",
"uint",
"dint",
"udint",
"word",
"byte",
"sint",
"usint",
]:
formatted_values[var_name] = (
str(int(value))
if isinstance(value, (int, float))
else str(value)
)
else:
formatted_values[var_name] = str(value)
except:
formatted_values[var_name] = "FORMAT_ERROR"
else:
formatted_values[var_name] = "ERROR"
# Only send if values changed
if formatted_values != last_values:
data = {
"type": "values",
"values": formatted_values,
"timestamp": timestamp,
"source": read_result.get("source", "unknown"),
"stats": read_result.get("stats", {}),
}
yield f"data: {json.dumps(data)}\n\n"
last_values = formatted_values.copy()
else:
# Send error data
error_data = {
"type": "error",
"message": read_result.get("error", "Unknown error"),
"timestamp": datetime.now().isoformat(),
}
yield f"data: {json.dumps(error_data)}\n\n"
else:
# No variables in dataset
data = {
"type": "no_variables",
"message": "No variables defined in this dataset",
"timestamp": datetime.now().isoformat(),
}
yield f"data: {json.dumps(data)}\n\n"
else:
# PLC not connected
# Check basic preconditions for cache availability
if not streamer.plc_client.is_connected():
# PLC not connected - cache won't be populated
data = {
"type": "plc_disconnected",
"message": "PLC not connected",
"message": "PLC not connected - cache not being populated",
"timestamp": datetime.now().isoformat(),
}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(interval)
continue
# Check if dataset is active
if dataset_id not in streamer.active_datasets:
data = {
"type": "dataset_inactive",
"message": f"Dataset '{dataset_id}' is not active - activate to populate cache",
"timestamp": datetime.now().isoformat(),
}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(interval)
continue
# Get dataset variables
dataset_variables = streamer.get_dataset_variables(dataset_id)
if not dataset_variables:
# No variables in dataset
data = {
"type": "no_variables",
"message": "No variables defined in this dataset",
"timestamp": datetime.now().isoformat(),
}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(interval)
continue
# Get cached values - the ONLY source according to application principles
if not streamer.has_cached_values(dataset_id):
# No cache available yet - dataset might be starting up
sampling_interval = (
streamer.config_manager.get_dataset_sampling_interval(
dataset_id
)
)
data = {
"type": "no_cache",
"message": f"Waiting for cache to be populated (dataset reads every {sampling_interval}s)",
"timestamp": datetime.now().isoformat(),
"sampling_interval": sampling_interval,
}
yield f"data: {json.dumps(data)}\n\n"
time.sleep(interval)
continue
# Get cached values (the ONLY valid source)
read_result = streamer.get_cached_dataset_values(dataset_id)
if read_result.get("success", False):
values = read_result.get("values", {})
timestamp = read_result.get("timestamp", datetime.now().isoformat())
# Format values for display
formatted_values = {}
for var_name, value in values.items():
if value is not None:
var_config = dataset_variables[var_name]
var_type = var_config.get("type", "unknown")
try:
if var_type == "real":
formatted_values[var_name] = (
f"{value:.3f}"
if isinstance(value, (int, float))
else str(value)
)
elif var_type == "bool":
formatted_values[var_name] = (
"TRUE" if value else "FALSE"
)
elif var_type in [
"int",
"uint",
"dint",
"udint",
"word",
"byte",
"sint",
"usint",
]:
formatted_values[var_name] = (
str(int(value))
if isinstance(value, (int, float))
else str(value)
)
else:
formatted_values[var_name] = str(value)
except:
formatted_values[var_name] = "FORMAT_ERROR"
else:
formatted_values[var_name] = "ERROR"
# Only send if values changed
if formatted_values != last_values:
data = {
"type": "values",
"values": formatted_values,
"timestamp": timestamp,
"source": "cache",
"stats": read_result.get("stats", {}),
"cache_age_info": f"Dataset reads every {streamer.config_manager.get_dataset_sampling_interval(dataset_id)}s",
}
yield f"data: {json.dumps(data)}\n\n"
last_values = formatted_values.copy()
else:
# Send error data from cache
error_data = {
"type": "cache_error",
"message": read_result.get(
"error", "Cached data indicates error"
),
"timestamp": datetime.now().isoformat(),
"error_type": read_result.get("error_type", "unknown"),
"source": "cache",
}
yield f"data: {json.dumps(error_data)}\n\n"
time.sleep(interval)
except Exception as e:
error_data = {
"type": "error",
"message": f"Stream error: {str(e)}",
"type": "stream_error",
"message": f"SSE stream error: {str(e)}",
"timestamp": datetime.now().isoformat(),
"source": "cache_monitoring",
}
yield f"data: {json.dumps(error_data)}\n\n"
time.sleep(interval)

View File

@ -70,5 +70,5 @@
],
"current_dataset_id": "dar",
"version": "1.0",
"last_update": "2025-07-19T23:55:26.866407"
"last_update": "2025-07-20T23:03:40.707669"
}

520
static/css/styles.css Normal file
View File

@ -0,0 +1,520 @@
/* Header with logo */
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.header-logo {
height: 1.2em;
width: auto;
vertical-align: middle;
}
/* Status grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.status-item {
padding: 1rem;
border-radius: var(--pico-border-radius);
text-align: center;
font-weight: bold;
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
}
.status-connected {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.status-disconnected {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
.status-streaming {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.status-idle {
background: var(--pico-muted-background-color);
color: var(--pico-muted-color);
}
/* Configuration grid */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
/* Form styling */
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
/* Variables table */
.variables-table {
width: 100%;
margin-top: 1rem;
}
/* Variable value cells styling */
.variables-table td[id^="value-"] {
font-family: var(--pico-font-family-monospace);
font-weight: bold;
text-align: center;
min-width: 100px;
}
/* Refresh button styling */
#refresh-values-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Diagnose button styling */
#diagnose-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Last refresh time styling */
#last-refresh-time {
font-style: italic;
font-size: 0.85rem;
}
/* Error cell tooltips */
.variables-table td[id^="value-"]:hover {
position: relative;
}
/* Variable value status colors */
.value-success {
color: var(--pico-color-green-600) !important;
}
.value-error {
color: var(--pico-color-red-500) !important;
}
.value-warning {
color: var(--pico-color-amber-600) !important;
}
.value-offline {
color: var(--pico-muted-color) !important;
}
/* Alert styles */
.alert {
padding: 1rem;
border-radius: var(--pico-border-radius);
margin: 1rem 0;
font-weight: bold;
}
.alert-success {
background-color: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.alert-error {
background-color: var(--pico-color-red-400);
color: white;
}
.alert-warning {
background-color: var(--pico-color-amber-100);
color: var(--pico-color-amber-800);
border: 1px solid var(--pico-color-amber-300);
}
.alert-info {
background-color: var(--pico-color-blue-100);
color: var(--pico-color-blue-800);
border: 1px solid var(--pico-color-blue-300);
}
/* Dataset controls */
.dataset-controls {
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
padding: 1.5rem;
border-radius: var(--pico-border-radius);
margin-bottom: 1.5rem;
}
/* Info section */
.info-section {
background: var(--pico-muted-background-color);
border: var(--pico-border-width) solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-section p {
margin: 0.5rem 0;
}
/* Log container */
.log-container {
max-height: 400px;
overflow-y: auto;
background: var(--pico-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1rem;
font-family: var(--pico-font-family-monospace);
}
.log-entry {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
border-left: 3px solid transparent;
}
.log-entry.log-info {
background: var(--pico-card-background-color);
border-left-color: var(--pico-primary);
}
.log-entry.log-warning {
background: var(--pico-color-amber-50);
border-left-color: var(--pico-color-amber-500);
}
.log-entry.log-error {
background: var(--pico-color-red-50);
border-left-color: var(--pico-color-red-500);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
margin-bottom: 0.25rem;
}
.log-timestamp {
font-size: 0.75rem;
opacity: 0.7;
}
.log-message {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-details {
font-size: 0.75rem;
opacity: 0.8;
background: rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin-top: 0.25rem;
white-space: pre-wrap;
}
.log-controls {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: center;
}
.log-stats {
background: var(--pico-muted-background-color);
border: var(--pico-border-width) solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
/* Utility classes */
.status-active {
color: var(--pico-primary);
font-weight: bold;
}
.status-inactive {
color: var(--pico-muted-color);
}
.status-error {
color: var(--pico-color-red-500);
}
/* Modal improvements */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal article {
margin: 5% auto;
width: 90%;
max-width: 600px;
}
.modal header {
display: flex;
justify-content: space-between;
align-items: center;
}
.close {
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
.close:hover {
opacity: 0.7;
}
/* Mobile responsive */
@media (max-width: 768px) {
.header h1 {
flex-direction: column;
gap: 0.5rem;
}
.header-logo {
height: 1.2em;
}
.config-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.controls {
flex-direction: column;
}
.status-grid {
grid-template-columns: 1fr;
}
}
/* Theme selector */
.theme-selector {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
box-shadow: var(--pico-box-shadow);
}
.theme-selector button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: var(--pico-border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.theme-selector button.active {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.theme-selector button:not(.active) {
background: var(--pico-muted-background-color);
color: var(--pico-muted-color);
}
.theme-selector button:hover:not(.active) {
background: var(--pico-primary-hover);
color: var(--pico-primary-inverse);
}
/* Font size reduction - 15% smaller (more balanced) */
html {
font-size: 85%;
/* 15% reduction from 100% - more balanced */
}
/* Adjust specific elements that might need fine-tuning */
.header h1 {
font-size: 2.2rem;
/* Adjusted for smaller base font */
}
.header p {
font-size: 1.1rem;
}
.status-item {
font-size: 1rem;
}
.log-entry {
font-size: 0.9rem;
/* Adjusted for smaller base font */
}
.log-timestamp {
font-size: 0.8rem;
/* Adjusted for smaller base font */
}
.log-details {
font-size: 0.8rem;
/* Adjusted for smaller base font */
}
.log-stats {
font-size: 0.9rem;
/* Adjusted for smaller base font */
}
/* Ensure buttons and inputs remain readable */
button,
input,
select,
textarea {
font-size: 1rem;
}
/* Table adjustments */
.variables-table th,
.variables-table td {
font-size: 0.95rem;
}
/* Modal adjustments */
.modal article {
font-size: 1rem;
}
.modal h3 {
font-size: 1.4rem;
}
/* CSV Configuration styles */
.csv-config-display {
background: var(--pico-card-background-color);
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.config-item:last-child {
border-bottom: none;
}
.config-item span {
font-weight: normal;
color: var(--pico-primary);
}
.directory-stats {
padding: 1rem;
background: var(--pico-muted-background-color);
border-radius: var(--pico-border-radius);
margin-top: 0.5rem;
}
.directory-stats .stat-item {
display: flex;
justify-content: space-between;
margin: 0.5rem 0;
}
.day-folder-item {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
padding: 0.75rem;
margin: 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.cleanup-status {
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin: 0.5rem 0;
font-weight: bold;
}
.cleanup-success {
background-color: var(--pico-color-green-100);
color: var(--pico-color-green-800);
border: 1px solid var(--pico-color-green-300);
}
.cleanup-error {
background-color: var(--pico-color-red-100);
color: var(--pico-color-red-800);
border: 1px solid var(--pico-color-red-300);
}

170
static/js/csv.js Normal file
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');
});
});
}

519
static/js/datasets.js Normal file
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
function loadDatasets() {
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
autoRefreshOnDatasetChange();
} 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';
}
}
}

98
static/js/events.js Normal file
View File

@ -0,0 +1,98 @@
/**
* 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
document.querySelector('button[onclick="refreshEventLog()"]').addEventListener('click', refreshEventLog);
document.querySelector('button[onclick="clearLogView()"]').addEventListener('click', clearLogView);
// Selector de límite de log
document.getElementById('log-limit').addEventListener('change', refreshEventLog);
}

43
static/js/main.js Normal file
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();
}
});

79
static/js/plc.js Normal file
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');
});
});
}

204
static/js/status.js Normal file
View File

@ -0,0 +1,204 @@
/**
* 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
if (data.streaming) {
streamStatus.innerHTML = '📡 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
document.getElementById('status-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
} else {
streamStatus.innerHTML = '📡 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
document.getElementById('status-start-btn').addEventListener('click', function () {
fetch('/api/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';
} 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';
}
// Actualizar estado de streaming
if (status.streaming) {
streamStatus.innerHTML = '📡 Streaming: Active <div style="margin-top: 8px;"><button type="button" id="status-streaming-btn">⏹️ Stop</button></div>';
streamStatus.className = 'status-item status-streaming';
} else {
streamStatus.innerHTML = '📡 Streaming: Inactive <div style="margin-top: 8px;"><button type="button" id="status-start-btn">▶️ Start</button></div>';
streamStatus.className = 'status-item status-idle';
}
// 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';
}
}

46
static/js/streaming.js Normal file
View File

@ -0,0 +1,46 @@
/**
* Gestión del streaming de datos a PlotJuggler
*/
// Inicializar listeners para el control de streaming
function initStreamingListeners() {
// Iniciar streaming
document.getElementById('start-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/start', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// Detener streaming
document.getElementById('stop-streaming-btn').addEventListener('click', function () {
fetch('/api/streaming/stop', { method: 'POST' })
.then(response => response.json())
.then(data => {
showMessage(data.message, data.success ? 'success' : 'error');
updateStatus();
});
});
// 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));
}

32
static/js/theme.js Normal file
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();
});

64
static/js/utils.js Normal file
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] || '📋';
}

637
static/js/variables.js Normal file
View File

@ -0,0 +1,637 @@
/**
* 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');
});
}
// Refrescar valores de variables desde el PLC
function refreshVariableValues() {
if (!currentDatasetId) {
showMessage('Please select a dataset first', 'warning');
return;
}
const refreshBtn = document.getElementById('refresh-values-btn');
const lastRefreshTime = document.getElementById('last-refresh-time');
// Deshabilitar botón y mostrar estado de carga
refreshBtn.disabled = true;
refreshBtn.innerHTML = '⏳ Reading...';
fetch(`/api/datasets/${currentDatasetId}/variables/values`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Actualizar valores de variables en la tabla
Object.keys(data.values).forEach(varName => {
const valueCell = document.getElementById(`value-${varName}`);
if (valueCell) {
const value = data.values[varName];
valueCell.textContent = value;
// Código de color y tooltip basado en el estado del valor
if (value === 'ERROR' || value === 'FORMAT_ERROR') {
valueCell.style.color = 'var(--pico-color-red-500)';
// Añadir tooltip con error detallado si está disponible
const errorDetail = data.detailed_errors && data.detailed_errors[varName];
if (errorDetail) {
valueCell.title = `Error: ${errorDetail}`;
valueCell.style.cursor = 'help';
}
} else {
valueCell.style.color = 'var(--pico-color-green-600)';
valueCell.title = `Value: ${value}`;
valueCell.style.cursor = 'default';
}
}
});
// Actualizar timestamp, estadísticas e información de origen
if (data.timestamp) {
const stats = data.stats;
const source = data.source || 'cache';
const isCache = data.is_cached;
// Crear indicador de origen (siempre caché ahora)
const sourceIcon = '📊';
const sourceText = 'from streaming cache';
let statsText = '';
if (stats && stats.total > 0) {
statsText = `<br/><small style="color: var(--pico-muted-color);">📈 ${stats.success}/${stats.total} variables</small>`;
}
lastRefreshTime.innerHTML = `
Last refresh: ${data.timestamp}<br/>
<small style="color: var(--pico-color-green-600);">
${data.message}
</small>${statsText}<br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText}
</small>
${data.cache_info ? `<br/><small style="color: var(--pico-muted-color);">${data.cache_info}</small>` : ''}
`;
}
// Mostrar mensaje apropiado
if (data.warning) {
showMessage(data.warning, 'warning');
// Mostrar información detallada de error en consola para depuración
if (data.detailed_errors && Object.keys(data.detailed_errors).length > 0) {
console.warn('Variable read errors:', data.detailed_errors);
}
} else {
showMessage(data.message, 'success');
}
} else {
// Manejar diferentes tipos de casos de fallo
const errorType = data.error_type;
if (errorType === 'dataset_inactive') {
// Dataset no está activo - guiar al usuario para activarlo
showMessage(`⚠️ ${data.message}`, 'warning');
clearVariableValues('DATASET INACTIVE');
lastRefreshTime.innerHTML = `
Last refresh attempt: ${data.timestamp}<br/>
<small style="color: var(--pico-color-amber-500);">
Dataset not active - activate dataset to populate cache
</small><br/>
<small style="color: var(--pico-muted-color);">
💡 Use "Activate" button in dataset controls above
</small>
`;
} else if (errorType === 'plc_disconnected') {
// PLC no conectado - guiar al usuario para conectar
showMessage(`🔌 ${data.message}`, 'warning');
clearVariableValues('PLC OFFLINE');
lastRefreshTime.innerHTML = `
Last refresh attempt: ${data.timestamp}<br/>
<small style="color: var(--pico-color-red-500);">
🔌 PLC not connected - cache cannot be populated
</small><br/>
<small style="color: var(--pico-muted-color);">
💡 Connect to PLC first, then activate dataset
</small>
`;
} else if (errorType === 'no_cache_available') {
// No hay caché todavía - el streaming está iniciando
showMessage(`${data.message}`, 'info');
clearVariableValues('READING...');
lastRefreshTime.innerHTML = `
Last refresh attempt: ${data.timestamp}<br/>
<small style="color: var(--pico-color-blue-500);">
Cache being populated by streaming process
</small><br/>
<small style="color: var(--pico-muted-color);">
${data.note || 'Please wait for next reading cycle'}
</small>
`;
} else {
// Caso de fallo completo u otros errores
showMessage(`${data.message}`, 'error');
clearVariableValues('ERROR');
const source = data.source || 'cache';
const sourceIcon = '📊';
const sourceText = 'from streaming cache';
lastRefreshTime.innerHTML = `
Last refresh attempt: ${data.timestamp}<br/>
<small style="color: var(--pico-color-red-500);">
${data.message}
</small><br/>
<small style="color: var(--pico-muted-color);">
${sourceIcon} ${sourceText}
</small>
`;
// Mostrar información detallada de error si está disponible
if (data.detailed_errors && Object.keys(data.detailed_errors).length > 0) {
console.error('Detailed variable errors:', data.detailed_errors);
}
}
}
})
.catch(error => {
console.error('Error refreshing variable values:', error);
showMessage('Network error retrieving cached variable values', 'error');
clearVariableValues('COMM ERROR');
lastRefreshTime.innerHTML = `
Last refresh attempt: ${new Date().toLocaleString()}<br/>
<small style="color: var(--pico-color-red-500);">
🌐 Network error communicating with server
</small>
`;
})
.finally(() => {
// Re-habilitar botón
refreshBtn.disabled = false;
refreshBtn.innerHTML = '🔄 Refresh Values';
});
}
// 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)';
});
}
// Auto-refrescar valores cuando cambia el dataset (opcional)
function autoRefreshOnDatasetChange() {
if (currentDatasetId) {
// Pequeño retraso para asegurar que la tabla está cargada
setTimeout(() => {
refreshVariableValues();
}, 500);
}
}
// 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 refreshBtn = document.getElementById('refresh-values-btn');
if (refreshBtn) {
if (isStreaming) {
refreshBtn.innerHTML = '🔄 Live Streaming';
refreshBtn.disabled = true;
refreshBtn.title = 'Real-time streaming is active - values update automatically';
} else {
refreshBtn.innerHTML = '🔄 Refresh Values';
refreshBtn.disabled = false;
refreshBtn.title = 'Click to refresh variable values';
}
}
}
// 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';
}
}
}
// Función de diagnóstico para problemas de conexión y variables
function diagnoseConnection() {
if (!currentDatasetId) {
showMessage('No dataset selected for diagnosis', 'error');
return;
}
const diagnoseBtn = document.getElementById('diagnose-btn');
const originalText = diagnoseBtn.innerHTML;
// Deshabilitar botón y mostrar estado de diagnóstico
diagnoseBtn.disabled = true;
diagnoseBtn.innerHTML = '🔍 Diagnosing...';
// Crear informe de diagnóstico
let diagnosticReport = [];
diagnosticReport.push('=== PLC CONNECTION DIAGNOSTICS ===');
diagnosticReport.push(`Dataset: ${currentDatasetId}`);
diagnosticReport.push(`Timestamp: ${new Date().toLocaleString()}`);
diagnosticReport.push('');
// Paso 1: Verificar estado de conexión PLC
fetch('/api/status')
.then(response => response.json())
.then(statusData => {
diagnosticReport.push('1. PLC Connection Status:');
diagnosticReport.push(` Connected: ${statusData.plc_connected ? 'YES' : 'NO'}`);
diagnosticReport.push(` PLC IP: ${statusData.plc_config.ip}`);
diagnosticReport.push(` Rack: ${statusData.plc_config.rack}`);
diagnosticReport.push(` Slot: ${statusData.plc_config.slot}`);
diagnosticReport.push('');
if (!statusData.plc_connected) {
diagnosticReport.push(' ❌ PLC is not connected. Please check:');
diagnosticReport.push(' - Network connectivity to PLC');
diagnosticReport.push(' - PLC IP address, rack, and slot configuration');
diagnosticReport.push(' - PLC is powered on and operational');
diagnosticReport.push('');
showDiagnosticResults(diagnosticReport);
return;
}
// Paso 2: Obtener información del dataset
return fetch('/api/datasets')
.then(response => response.json())
.then(datasetData => {
const dataset = datasetData.datasets[currentDatasetId];
if (!dataset) {
diagnosticReport.push('2. Dataset Status:');
diagnosticReport.push(' ❌ Dataset not found');
showDiagnosticResults(diagnosticReport);
return;
}
diagnosticReport.push('2. Dataset Information:');
diagnosticReport.push(` Name: ${dataset.name}`);
diagnosticReport.push(` Variables: ${Object.keys(dataset.variables).length}`);
diagnosticReport.push(` Active: ${dataset.enabled ? 'YES' : 'NO'}`);
diagnosticReport.push('');
// Paso 3: Probar lectura de variables con diagnósticos
diagnosticReport.push('3. Variable Reading Test:');
return fetch(`/api/datasets/${currentDatasetId}/variables/values`)
.then(response => response.json())
.then(valueData => {
if (valueData.success) {
const stats = valueData.stats || {};
diagnosticReport.push(` ✅ Success: ${stats.success || 0}/${stats.total || 0} variables read`);
if (stats.failed > 0) {
diagnosticReport.push(` ⚠️ Failed: ${stats.failed} variables had errors`);
diagnosticReport.push('');
diagnosticReport.push('4. Variable-Specific Errors:');
if (valueData.detailed_errors) {
Object.keys(valueData.detailed_errors).forEach(varName => {
diagnosticReport.push(` ${varName}: ${valueData.detailed_errors[varName]}`);
});
}
} else {
diagnosticReport.push(' ✅ All variables read successfully');
}
} else {
diagnosticReport.push(` ❌ Complete failure: ${valueData.message}`);
diagnosticReport.push('');
diagnosticReport.push('4. Detailed Error Information:');
if (valueData.detailed_errors) {
Object.keys(valueData.detailed_errors).forEach(varName => {
diagnosticReport.push(` ${varName}: ${valueData.detailed_errors[varName]}`);
});
}
diagnosticReport.push('');
diagnosticReport.push('5. Troubleshooting Suggestions:');
if (valueData.error_type === 'connection_error') {
diagnosticReport.push(' - Check PLC network connection');
diagnosticReport.push(' - Verify PLC is responding to network requests');
diagnosticReport.push(' - Check firewall settings');
} else if (valueData.error_type === 'all_failed') {
diagnosticReport.push(' - Verify variable memory addresses are correct');
diagnosticReport.push(' - Check if data blocks exist in PLC program');
diagnosticReport.push(' - Ensure variable types match PLC configuration');
}
}
showDiagnosticResults(diagnosticReport);
});
});
})
.catch(error => {
diagnosticReport.push('❌ Diagnostic failed with network error:');
diagnosticReport.push(` ${error.message}`);
diagnosticReport.push('');
diagnosticReport.push('Troubleshooting:');
diagnosticReport.push(' - Check web server connection');
diagnosticReport.push(' - Refresh the page and try again');
showDiagnosticResults(diagnosticReport);
})
.finally(() => {
// Re-habilitar botón
diagnoseBtn.disabled = false;
diagnoseBtn.innerHTML = originalText;
});
}
// Mostrar resultados de diagnóstico en consola y como mensaje
function showDiagnosticResults(diagnosticReport) {
const reportText = diagnosticReport.join('\n');
// Log a consola para análisis detallado
console.log(reportText);
// Mostrar mensaje de resumen al usuario
const errorCount = reportText.match(/❌/g)?.length || 0;
const warningCount = reportText.match(/⚠️/g)?.length || 0;
const successCount = reportText.match(/✅/g)?.length || 0;
let summaryMessage = 'Diagnosis completed. ';
if (errorCount > 0) {
summaryMessage += `${errorCount} errors found. `;
}
if (warningCount > 0) {
summaryMessage += `${warningCount} warnings found. `;
}
if (successCount > 0) {
summaryMessage += `${successCount} checks passed. `;
}
summaryMessage += 'Check browser console (F12) for detailed report.';
const messageType = errorCount > 0 ? 'error' : (warningCount > 0 ? 'warning' : 'success');
showMessage(summaryMessage, messageType);
}

View File

@ -1,11 +1,11 @@
{
"last_state": {
"should_connect": true,
"should_stream": false,
"should_stream": true,
"active_datasets": [
"dar"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-07-20T10:58:20.099906"
"last_update": "2025-07-20T23:03:40.714677"
}

File diff suppressed because it is too large Load Diff