diff --git a/.doc/MemoriaDeEvolucion.md b/.doc/MemoriaDeEvolucion.md index 8ebd47d..77773b2 100644 --- a/.doc/MemoriaDeEvolucion.md +++ b/.doc/MemoriaDeEvolucion.md @@ -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 diff --git a/application_events.json b/application_events.json index 8b29d2d..51b2b84 100644 --- a/application_events.json +++ b/application_events.json @@ -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 } \ No newline at end of file diff --git a/core/plc_client.py b/core/plc_client.py index b8b9233..33df4a4 100644 --- a/core/plc_client.py +++ b/core/plc_client.py @@ -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]: diff --git a/core/streamer.py b/core/streamer.py index 53e9f17..bc1bbf0 100644 --- a/core/streamer.py +++ b/core/streamer.py @@ -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, diff --git a/main.py b/main.py index adb7f73..6693033 100644 --- a/main.py +++ b/main.py @@ -552,7 +552,7 @@ def get_streaming_variables(): @app.route("/api/datasets//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) diff --git a/plc_datasets.json b/plc_datasets.json index 525c9d1..4867a4d 100644 --- a/plc_datasets.json +++ b/plc_datasets.json @@ -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" } \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..ae72f4a --- /dev/null +++ b/static/css/styles.css @@ -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); +} \ No newline at end of file diff --git a/static/js/csv.js b/static/js/csv.js new file mode 100644 index 0000000..50e1314 --- /dev/null +++ b/static/js/csv.js @@ -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 = ` +
+ 📁 Directory: + ${info.base_directory} +
+
+ 📊 Total Files: + ${info.total_files} +
+
+ 💾 Total Size: + ${info.total_size_mb} MB +
+ `; + + if (info.oldest_file) { + html += ` +
+ 📅 Oldest File: + ${new Date(info.oldest_file).toLocaleString()} +
+ `; + } + + if (info.newest_file) { + html += ` +
+ 🆕 Newest File: + ${new Date(info.newest_file).toLocaleString()} +
+ `; + } + + if (info.day_folders && info.day_folders.length > 0) { + html += '

📂 Day Folders:

'; + info.day_folders.forEach(folder => { + html += ` +
+ ${folder.name} + ${folder.files} files, ${folder.size_mb} MB +
+ `; + }); + } + + statsDiv.innerHTML = html; + } + }) + .catch(error => { + document.getElementById('directory-stats').innerHTML = '

Error loading directory information

'; + }); +} + +// 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'); + }); + }); +} \ No newline at end of file diff --git a/static/js/datasets.js b/static/js/datasets.js new file mode 100644 index 0000000..da52d44 --- /dev/null +++ b/static/js/datasets.js @@ -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 = ''; + + 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 = ` + ${varName} + ${memoryAreaDisplay} + ${variable.offset} + ${variable.type.toUpperCase()} + + -- + + + + + + + + + `; + + 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'; + } + } +} \ No newline at end of file diff --git a/static/js/events.js b/static/js/events.js new file mode 100644 index 0000000..2e1e765 --- /dev/null +++ b/static/js/events.js @@ -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 = ` +
+
+ 📋 System + ${new Date().toLocaleString('es-ES')} +
+
No events found
+
+ `; + } 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 = ` +
+ ${getEventIcon(event.event_type)} ${event.event_type.replace(/_/g, ' ').toUpperCase()} + ${formatTimestamp(event.timestamp)} +
+
${event.message}
+ ${hasDetails ? `
${JSON.stringify(event.details, null, 2)}
` : ''} + `; + + return logEntry; +} + +// Limpiar vista de log +function clearLogView() { + const logContainer = document.getElementById('events-log'); + logContainer.innerHTML = ` +
+
+ 🧹 System + ${new Date().toLocaleString('es-ES')} +
+
Log view cleared. Click refresh to reload events.
+
+ `; + + 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); +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..9c02dce --- /dev/null +++ b/static/js/main.js @@ -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(); + } +}); \ No newline at end of file diff --git a/static/js/plc.js b/static/js/plc.js new file mode 100644 index 0000000..9f3fd1d --- /dev/null +++ b/static/js/plc.js @@ -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'); + }); + }); +} \ No newline at end of file diff --git a/static/js/status.js b/static/js/status.js new file mode 100644 index 0000000..5857882 --- /dev/null +++ b/static/js/status.js @@ -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
'; + 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
'; + 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
'; + 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
'; + 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
+ ⏱️ ~${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
'; + plcStatus.className = 'status-item status-connected'; + } else { + plcStatus.innerHTML = '🔌 PLC: Disconnected
'; + plcStatus.className = 'status-item status-disconnected'; + } + + // Actualizar estado de streaming + if (status.streaming) { + streamStatus.innerHTML = '📡 Streaming: Active
'; + streamStatus.className = 'status-item status-streaming'; + } else { + streamStatus.innerHTML = '📡 Streaming: Inactive
'; + 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
+ ⏱️ ~${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'; + } +} \ No newline at end of file diff --git a/static/js/streaming.js b/static/js/streaming.js new file mode 100644 index 0000000..b9ee6bb --- /dev/null +++ b/static/js/streaming.js @@ -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)); +} \ No newline at end of file diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..3bb556c --- /dev/null +++ b/static/js/theme.js @@ -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(); +}); \ No newline at end of file diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000..8f9014a --- /dev/null +++ b/static/js/utils.js @@ -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 = `
${message}
`; + 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] || '📋'; +} \ No newline at end of file diff --git a/static/js/variables.js b/static/js/variables.js new file mode 100644 index 0000000..499535f --- /dev/null +++ b/static/js/variables.js @@ -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 = `
📈 ${stats.success}/${stats.total} variables`; + } + + lastRefreshTime.innerHTML = ` + Last refresh: ${data.timestamp}
+ + ✅ ${data.message} + ${statsText}
+ + ${sourceIcon} ${sourceText} + + ${data.cache_info ? `
${data.cache_info}` : ''} + `; + } + + // 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}
+ + ⚠️ Dataset not active - activate dataset to populate cache +
+ + 💡 Use "Activate" button in dataset controls above + + `; + } 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}
+ + 🔌 PLC not connected - cache cannot be populated +
+ + 💡 Connect to PLC first, then activate dataset + + `; + } 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}
+ + ⏳ Cache being populated by streaming process +
+ + ${data.note || 'Please wait for next reading cycle'} + + `; + } 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}
+ + ❌ ${data.message} +
+ + ${sourceIcon} ${sourceText} + + `; + + // 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()}
+ + 🌐 Network error communicating with server + + `; + }) + .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 = ` + 🔄 Live streaming
+ + ⚠️ ${stats.success}/${stats.total} variables (${stats.failed} failed) +
+ + ${sourceIcon} ${sourceText} • ${new Date(timestamp).toLocaleTimeString()} + + `; + } else { + lastRefreshTime.innerHTML = ` + 🔄 Live streaming
+ + ✅ All ${stats ? stats.success : 'N/A'} variables OK +
+ + ${sourceIcon} ${sourceText} • ${new Date(timestamp).toLocaleTimeString()} + + `; + } + } +} + +// 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); +} \ No newline at end of file diff --git a/system_state.json b/system_state.json index 86cc3f0..5b96373 100644 --- a/system_state.json +++ b/system_state.json @@ -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" } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 6c51c30..235b139 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,529 +10,8 @@ - - + + @@ -851,112 +330,6 @@ - - - - - -
🚀 Multi-Dataset Streaming Control
@@ -1122,1832 +495,123 @@
- + + + + + + + + + + + + + + + + + + + \ No newline at end of file