From 10df4e94bd3886971fd652140d3f66886163612c Mon Sep 17 00:00:00 2001 From: Miguel Date: Sun, 10 Aug 2025 01:17:14 +0200 Subject: [PATCH] Base de el configurador json basado en schemas --- application_events.json | 45 +++- core/plc_data_streamer.py | 9 + core/schema_manager.py | 215 ++++++++++++++++ main.py | 89 +++++++ plc_datasets.json | 6 +- requirements.txt | 3 +- schemas/datasets.schema.json | 146 +++++++++++ schemas/plc.schema.json | 142 +++++++++++ schemas/plots.schema.json | 86 +++++++ static/js/config_editor.js | 465 +++++++++++++++++++++++++++++++++++ system_state.json | 6 +- templates/index.html | 31 +++ 12 files changed, 1234 insertions(+), 9 deletions(-) create mode 100644 core/schema_manager.py create mode 100644 schemas/datasets.schema.json create mode 100644 schemas/plc.schema.json create mode 100644 schemas/plots.schema.json create mode 100644 static/js/config_editor.js diff --git a/application_events.json b/application_events.json index 4dad8c6..47dc781 100644 --- a/application_events.json +++ b/application_events.json @@ -9019,8 +9019,49 @@ "trigger_on_true": true } } + }, + { + "timestamp": "2025-08-10T01:12:18.990709", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-10T01:12:19.105449", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: DAR", + "details": { + "dataset_id": "DAR", + "variables_count": 3, + "streaming_count": 2, + "prefix": "gateway_phoenix" + } + }, + { + "timestamp": "2025-08-10T01:12:19.127678", + "level": "info", + "event_type": "dataset_activated", + "message": "Dataset activated: Fast", + "details": { + "dataset_id": "Fast", + "variables_count": 2, + "streaming_count": 0, + "prefix": "fast" + } + }, + { + "timestamp": "2025-08-10T01:12:19.148987", + "level": "info", + "event_type": "csv_recording_started", + "message": "CSV recording started: 2 datasets activated", + "details": { + "activated_datasets": 2, + "total_datasets": 2 + } } ], - "last_updated": "2025-08-10T00:37:46.526185", - "total_entries": 834 + "last_updated": "2025-08-10T01:12:19.148987", + "total_entries": 838 } \ No newline at end of file diff --git a/core/plc_data_streamer.py b/core/plc_data_streamer.py index e7ef101..38deb99 100644 --- a/core/plc_data_streamer.py +++ b/core/plc_data_streamer.py @@ -12,6 +12,7 @@ try: from .streamer import DataStreamer from .event_logger import EventLogger from .instance_manager import InstanceManager + from .schema_manager import ConfigSchemaManager except ImportError: # Fallback to absolute imports (when run directly) from core.config_manager import ConfigManager @@ -19,6 +20,7 @@ except ImportError: from core.streamer import DataStreamer from core.event_logger import EventLogger from core.instance_manager import InstanceManager + from core.schema_manager import ConfigSchemaManager class PLCDataStreamer: @@ -50,6 +52,13 @@ class PLCDataStreamer: ) self.logger.info("DataStreamer initialized successfully") + # Initialize schema manager + self.logger.info("Initializing ConfigSchemaManager...") + self.schema_manager = ConfigSchemaManager( + self.config_manager, self.data_streamer.plot_manager, self.logger + ) + self.logger.info("ConfigSchemaManager initialized successfully") + self.logger.info("Initializing InstanceManager...") self.instance_manager = InstanceManager(self.logger) self.logger.info("InstanceManager initialized successfully") diff --git a/core/schema_manager.py b/core/schema_manager.py new file mode 100644 index 0000000..6806a34 --- /dev/null +++ b/core/schema_manager.py @@ -0,0 +1,215 @@ +import json +import os +from typing import Any, Dict, Optional + +try: + # Relative imports when packaged + from .config_manager import ConfigManager + from .plot_manager import PlotManager +except Exception: # pragma: no cover + # Absolute imports when run directly + from core.config_manager import ConfigManager + from core.plot_manager import PlotManager + + +def resource_path(relative_path: str) -> str: + """Obtener ruta absoluta para recursos (compatible con PyInstaller).""" + import sys + + try: + base_path = sys._MEIPASS # type: ignore[attr-defined] + except Exception: + base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + + +class ConfigSchemaManager: + """Gestor centralizado de esquemas JSON y lectura/escritura de configuraciones.""" + + def __init__( + self, + config_manager: ConfigManager, + plot_manager: PlotManager, + logger: Optional[Any] = None, + ) -> None: + self.config_manager = config_manager + self.plot_manager = plot_manager + self.logger = logger + + self.schemas_dir = resource_path("schemas") + self.schemas_index: Dict[str, Dict[str, Any]] = {} + + # Mapa de id -> ruta de archivo real de configuración + self.config_files: Dict[str, str] = { + "plc": resource_path("plc_config.json"), + "datasets": resource_path("plc_datasets.json"), + "plots": resource_path("plot_sessions.json"), + } + + self._load_all_schemas() + + def _load_all_schemas(self) -> None: + os.makedirs(self.schemas_dir, exist_ok=True) + # Cargar todos los *.schema.json + try: + for name in os.listdir(self.schemas_dir): + if name.endswith(".schema.json"): + schema_id = name.replace(".schema.json", "") + path = os.path.join(self.schemas_dir, name) + with open(path, "r", encoding="utf-8") as f: + self.schemas_index[schema_id] = json.load(f) + if self.logger: + self.logger.info( + f"Loaded {len(self.schemas_index)} schemas from {self.schemas_dir}" + ) + except Exception as e: + if self.logger: + self.logger.error(f"Error loading schemas: {e}") + + def list_schemas(self) -> Dict[str, Any]: + return { + "schemas": [ + { + "id": key, + "title": self.schemas_index[key].get("title", key), + "description": self.schemas_index[key].get("description"), + } + for key in sorted(self.schemas_index.keys()) + ] + } + + def get_schema(self, schema_id: str) -> Dict[str, Any]: + if schema_id not in self.schemas_index: + raise ValueError(f"Schema '{schema_id}' not found") + return self.schemas_index[schema_id] + + def read_config(self, config_id: str) -> Dict[str, Any]: + if config_id == "plc": + # Construir desde ConfigManager para asegurar consistencia + return { + "plc_config": self.config_manager.plc_config, + "udp_config": self.config_manager.udp_config, + "sampling_interval": self.config_manager.sampling_interval, + "csv_config": self.config_manager.csv_config, + } + elif config_id == "datasets": + return { + "datasets": self.config_manager.datasets, + "active_datasets": list(self.config_manager.active_datasets), + "current_dataset_id": self.config_manager.current_dataset_id, + "version": "1.0", + } + elif config_id == "plots": + # Leer del archivo para reflejar persistencia actual + path = self.config_files[config_id] + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + return {"plots": {}, "session_counter": 0, "version": "1.0"} + else: + raise ValueError(f"Unknown config id '{config_id}'") + + def write_config(self, config_id: str, data: Dict[str, Any]) -> Dict[str, Any]: + # Validación básica con jsonschema si está disponible + try: + import jsonschema # type: ignore + + schema = self.get_schema(config_id) + jsonschema.validate(instance=data, schema=schema) + except ImportError: + # Si jsonschema no está instalado, solo registramos una advertencia + if self.logger: + self.logger.warning( + "jsonschema not installed; skipping schema validation" + ) + except Exception as e: + raise ValueError(f"Schema validation failed: {e}") + + if config_id == "plc": + # Aplicar a ConfigManager y persistir + plc_cfg = data.get("plc_config", {}) + udp_cfg = data.get("udp_config", {}) + sampling = data.get( + "sampling_interval", self.config_manager.sampling_interval + ) + csv_cfg = data.get("csv_config", {}) + + # Actualizaciones atómicas + if plc_cfg: + self.config_manager.update_plc_config( + plc_cfg.get("ip", self.config_manager.plc_config.get("ip")), + int( + plc_cfg.get( + "rack", self.config_manager.plc_config.get("rack", 0) + ) + ), + int( + plc_cfg.get( + "slot", self.config_manager.plc_config.get("slot", 2) + ) + ), + ) + if udp_cfg: + self.config_manager.update_udp_config( + udp_cfg.get("host", self.config_manager.udp_config.get("host")), + int( + udp_cfg.get( + "port", self.config_manager.udp_config.get("port", 9870) + ) + ), + ) + if sampling is not None: + self.config_manager.update_sampling_interval(float(sampling)) + if csv_cfg: + self.config_manager.update_csv_config(**csv_cfg) + + # Guardar archivo completo para export + path = self.config_files[config_id] + with open(path, "w", encoding="utf-8") as f: + json.dump(self.read_config("plc"), f, indent=2) + return {"success": True} + + if config_id == "datasets": + # Reemplazar estructuras completas de datasets de manera controlada + datasets = data.get("datasets", {}) + active = set(data.get("active_datasets", [])) + current = data.get("current_dataset_id") + + # Sobrescribir en ConfigManager y persistir + self.config_manager.datasets = datasets + self.config_manager.active_datasets = set(active) + self.config_manager.current_dataset_id = ( + current if current in datasets else None + ) + self.config_manager.save_datasets() + + return {"success": True} + + if config_id == "plots": + # Guardar directamente y recargar gestor de plots + path = self.config_files[config_id] + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + # Recargar desde persistencia + try: + self.plot_manager.load_plots() + except Exception as e: + if self.logger: + self.logger.warning(f"Could not reload plot sessions: {e}") + return {"success": True} + + raise ValueError(f"Unknown config id '{config_id}'") + + def export_config(self, config_id: str) -> str: + path = self.config_files.get(config_id) + if not path: + raise ValueError(f"Unknown config id '{config_id}'") + # Asegurar que el archivo está sincronizado + if config_id == "plc": + with open(path, "w", encoding="utf-8") as f: + json.dump(self.read_config("plc"), f, indent=2) + elif config_id == "datasets": + self.config_manager.save_datasets() + elif config_id == "plots": + self.plot_manager.save_plots() + return path diff --git a/main.py b/main.py index 15ae296..39f0194 100644 --- a/main.py +++ b/main.py @@ -88,6 +88,95 @@ def index(): ) +# ============================== +# Config Schemas & Editor API +# ============================== + + +@app.route("/api/config/schemas", methods=["GET"]) +def list_config_schemas(): + """Listar esquemas disponibles (plc, datasets, plots).""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + info = streamer.schema_manager.list_schemas() + return jsonify({"success": True, **info}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/config/schema/", methods=["GET"]) +def get_config_schema(schema_id): + """Obtener un esquema específico en formato JSON Schema.""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + schema = streamer.schema_manager.get_schema(schema_id) + return jsonify({"success": True, "schema": schema}) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 404 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/config/", methods=["GET"]) +def read_config(config_id): + """Leer configuración actual (plc/datasets/plots).""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + data = streamer.schema_manager.read_config(config_id) + return jsonify({"success": True, "data": data}) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 404 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/config/", methods=["PUT"]) +def write_config(config_id): + """Sobrescribir configuración a partir del cuerpo JSON.""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + payload = request.get_json(force=True, silent=False) + result = streamer.schema_manager.write_config(config_id, payload) + return jsonify({"success": True, **result}) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route("/api/config//export", methods=["GET"]) +def export_config(config_id): + """Exportar configuración como descarga JSON.""" + error_response = check_streamer_initialized() + if error_response: + return error_response + + try: + data = streamer.schema_manager.read_config(config_id) + # Preparar respuesta con cabeceras de descarga + content = json.dumps(data, indent=2) + filename = f"{config_id}_export.json" + resp = Response(content, mimetype="application/json") + resp.headers["Content-Disposition"] = f"attachment; filename={filename}" + return resp + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 404 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route("/api/plc/config", methods=["POST"]) def update_plc_config(): """Update PLC configuration""" diff --git a/plc_datasets.json b/plc_datasets.json index fe4e0a9..ee3aeef 100644 --- a/plc_datasets.json +++ b/plc_datasets.json @@ -60,10 +60,10 @@ } }, "active_datasets": [ - "DAR", - "Fast" + "Fast", + "DAR" ], "current_dataset_id": "Fast", "version": "1.0", - "last_update": "2025-08-10T00:37:18.103618" + "last_update": "2025-08-10T01:12:19.126350" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cb6190f..226fa4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Flask==2.3.3 python-snap7==1.3 psutil==5.9.5 -flask-socketio==5.3.6 \ No newline at end of file +flask-socketio==5.3.6 +jsonschema==4.22.0 \ No newline at end of file diff --git a/schemas/datasets.schema.json b/schemas/datasets.schema.json new file mode 100644 index 0000000..20e7e4c --- /dev/null +++ b/schemas/datasets.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "datasets.schema.json", + "title": "Datasets Configuration", + "description": "Esquema para editar plc_datasets.json (múltiples datasets y variables)", + "type": "object", + "additionalProperties": false, + "properties": { + "datasets": { + "type": "object", + "title": "Datasets", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "area": { + "type": "string", + "enum": [ + "db", + "mw", + "m", + "pew", + "pe", + "paw", + "pa", + "e", + "a", + "mb" + ] + }, + "db": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "bit": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 7 + }, + "type": { + "type": "string", + "enum": [ + "real", + "int", + "bool", + "dint", + "word", + "byte", + "uint", + "udint", + "sint", + "usint" + ] + }, + "streaming": { + "type": "boolean", + "default": false + } + }, + "required": [ + "area", + "offset", + "type" + ] + } + }, + "streaming_variables": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "sampling_interval": { + "type": [ + "number", + "null" + ], + "minimum": 0.01 + }, + "enabled": { + "type": "boolean", + "default": false + }, + "created": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "prefix", + "variables", + "streaming_variables" + ] + } + }, + "active_datasets": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "current_dataset_id": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "last_update": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "datasets" + ] +} \ No newline at end of file diff --git a/schemas/plc.schema.json b/schemas/plc.schema.json new file mode 100644 index 0000000..89b8ee4 --- /dev/null +++ b/schemas/plc.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "plc.schema.json", + "title": "PLC & UDP Configuration", + "description": "Esquema para editar plc_config.json", + "type": "object", + "additionalProperties": false, + "properties": { + "plc_config": { + "type": "object", + "title": "PLC Configuration", + "additionalProperties": false, + "properties": { + "ip": { + "type": "string", + "title": "PLC IP", + "pattern": "^.+$" + }, + "rack": { + "type": "integer", + "minimum": 0, + "maximum": 7, + "default": 0 + }, + "slot": { + "type": "integer", + "minimum": 0, + "maximum": 31, + "default": 2 + } + }, + "required": [ + "ip", + "rack", + "slot" + ] + }, + "udp_config": { + "type": "object", + "title": "UDP Configuration", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "title": "UDP Host", + "pattern": "^.+$", + "default": "127.0.0.1" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "default": 9870 + } + }, + "required": [ + "host", + "port" + ] + }, + "sampling_interval": { + "type": "number", + "minimum": 0.01, + "title": "Sampling Interval (s)", + "default": 0.1 + }, + "csv_config": { + "type": "object", + "title": "CSV Recording", + "additionalProperties": false, + "properties": { + "records_directory": { + "type": "string", + "title": "Records Directory", + "default": "records" + }, + "rotation_enabled": { + "type": "boolean", + "title": "Rotation", + "default": true, + "x-ui": { + "toggleLabels": [ + "Activate", + "Deactivate" + ] + } + }, + "max_size_mb": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "title": "Max Size (MB)", + "default": 1000 + }, + "max_days": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "title": "Max Days", + "default": 30 + }, + "max_hours": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "title": "Max Hours", + "default": null + }, + "cleanup_interval_hours": { + "type": "integer", + "minimum": 1, + "title": "Cleanup Interval (h)", + "default": 24 + }, + "last_cleanup": { + "type": [ + "string", + "null" + ], + "title": "Last Cleanup" + } + }, + "required": [ + "records_directory", + "rotation_enabled", + "cleanup_interval_hours" + ] + } + }, + "required": [ + "plc_config", + "udp_config", + "sampling_interval", + "csv_config" + ] +} \ No newline at end of file diff --git a/schemas/plots.schema.json b/schemas/plots.schema.json new file mode 100644 index 0000000..b879fbf --- /dev/null +++ b/schemas/plots.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "plots.schema.json", + "title": "Plot Sessions", + "description": "Esquema para editar plot_sessions.json (sesiones de gráfica)", + "type": "object", + "additionalProperties": false, + "properties": { + "plots": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "time_window": { + "type": "integer", + "minimum": 5, + "maximum": 3600, + "default": 60 + }, + "y_min": { + "type": [ + "number", + "null" + ] + }, + "y_max": { + "type": [ + "number", + "null" + ] + }, + "trigger_variable": { + "type": [ + "string", + "null" + ] + }, + "trigger_enabled": { + "type": "boolean", + "default": false + }, + "trigger_on_true": { + "type": "boolean", + "default": true + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "name", + "variables", + "time_window" + ] + } + }, + "session_counter": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "last_saved": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string", + "default": "1.0" + } + }, + "required": [ + "plots" + ] +} \ No newline at end of file diff --git a/static/js/config_editor.js b/static/js/config_editor.js new file mode 100644 index 0000000..d9eeebb --- /dev/null +++ b/static/js/config_editor.js @@ -0,0 +1,465 @@ +/** + * 🧩 Dynamic JSON Config Editor + * Construye formularios en base a JSON Schema y llama a endpoints /api/config + */ + +(function () { + let schemasIndex = []; + let currentSchemaId = null; + let currentData = null; + + document.addEventListener('DOMContentLoaded', () => { + const tabBtn = document.querySelector('.tab-btn[data-tab="config-editor"]'); + if (!tabBtn) return; + + // Cargar esquemas cuando se entra al tab + tabBtn.addEventListener('click', ensureSchemasLoadedOnce); + + // Listeners de controles básicos + const saveBtn = document.getElementById('btn-save-config'); + if (saveBtn) saveBtn.addEventListener('click', onSave); + + const exportBtn = document.getElementById('btn-export-config'); + if (exportBtn) exportBtn.addEventListener('click', onExport); + + const importInput = document.getElementById('import-file'); + if (importInput) importInput.addEventListener('change', onImport); + }); + + async function ensureSchemasLoadedOnce() { + if (schemasIndex.length > 0) return; + try { + const res = await fetch('/api/config/schemas'); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to list schemas'); + schemasIndex = data.schemas || []; + populateSchemaSelector(); + } catch (e) { + showMessage(`Error loading schemas: ${e}`, 'error'); + } + } + + function populateSchemaSelector() { + const selector = document.getElementById('schema-selector'); + if (!selector) return; + selector.innerHTML = ''; + + for (const s of schemasIndex) { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = `${iconForSchema(s.id)} ${s.title || s.id}`; + selector.appendChild(opt); + } + + selector.addEventListener('change', () => loadConfigAndSchema(selector.value)); + if (schemasIndex.length > 0) { + selector.value = schemasIndex[0].id; + loadConfigAndSchema(selector.value); + } + } + + function iconForSchema(id) { + if (id === 'plc') return '⚙️'; + if (id === 'datasets') return '📊'; + if (id === 'plots') return '📈'; + return '🧩'; + } + + async function loadConfigAndSchema(schemaId) { + currentSchemaId = schemaId; + const container = document.getElementById('config-form-container'); + if (container) container.innerHTML = 'Loading...'; + + try { + const [schemaRes, dataRes] = await Promise.all([ + fetch(`/api/config/schema/${schemaId}`), + fetch(`/api/config/${schemaId}`) + ]); + + const schemaData = await schemaRes.json(); + const configData = await dataRes.json(); + + if (!schemaData.success) throw new Error(schemaData.error || 'Schema error'); + if (!configData.success) throw new Error(configData.error || 'Data error'); + + currentData = configData.data; + + renderForm(container, schemaData.schema, currentData); + } catch (e) { + if (container) container.innerHTML = ''; + showMessage(`Error loading editor: ${e}`, 'error'); + } + } + + // Renderizado muy simple basado en schema: soporta object, string, number, integer, boolean, array básica + function renderForm(container, schema, data) { + if (!container) return; + container.innerHTML = ''; + + const form = document.createElement('div'); + form.className = 'config-editor-form'; + + if (schema.type === 'object' && schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + const value = data ? data[key] : undefined; + const field = renderField(key, propSchema, value, [key]); + if (field) form.appendChild(field); + } + } else { + form.textContent = 'Unsupported schema root.'; + } + + container.appendChild(form); + } + + function renderField(label, propSchema, value, path) { + const wrapper = document.createElement('div'); + wrapper.className = 'form-group'; + + const title = document.createElement('label'); + title.textContent = propSchema.title || label; + wrapper.appendChild(title); + + const type = Array.isArray(propSchema.type) ? propSchema.type : [propSchema.type]; + + // Objetos + if (type.includes('object')) { + const inner = document.createElement('div'); + inner.className = 'object-group'; + + // Caso 1: propiedades conocidas + if (propSchema.properties) { + for (const [k, s] of Object.entries(propSchema.properties)) { + const v = value ? value[k] : undefined; + const child = renderField(k, s, v, path.concat(k)); + if (child) inner.appendChild(child); + } + wrapper.appendChild(inner); + return wrapper; + } + + // Caso 2: additionalProperties -> colección dinámica (key -> objeto) + if (propSchema.additionalProperties && typeof propSchema.additionalProperties === 'object') { + const entries = (value && typeof value === 'object') ? Object.entries(value) : []; + const list = document.createElement('div'); + list.className = 'dynamic-object-list'; + + function renderEntries() { + list.innerHTML = ''; + for (const [entryKey, entryVal] of entries) { + const row = document.createElement('div'); + row.className = 'dynamic-object-row'; + + const keyInput = document.createElement('input'); + keyInput.type = 'text'; + keyInput.value = entryKey; + keyInput.title = 'Key'; + + let currentKey = entryKey; + keyInput.addEventListener('change', () => { + const newKey = keyInput.value.trim(); + if (!newKey || newKey === currentKey) return; + // Renombrar clave conservando valor + const parentObj = getPathObject(path, true); + if (parentObj[newKey] !== undefined) { + showMessage('Key already exists', 'error'); + keyInput.value = currentKey; + return; + } + parentObj[newKey] = parentObj[currentKey]; + delete parentObj[currentKey]; + currentKey = newKey; + updateEntriesFromObject(parentObj); + setPathValue(path, parentObj); + renderEntries(); + }); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'secondary'; + delBtn.textContent = '🗑️'; + delBtn.addEventListener('click', () => { + const parentObj = getPathObject(path, true); + delete parentObj[currentKey]; + updateEntriesFromObject(parentObj); + setPathValue(path, parentObj); + renderEntries(); + }); + + const valueContainer = document.createElement('div'); + valueContainer.className = 'dynamic-object-value'; + const child = renderField(currentKey, propSchema.additionalProperties, entryVal, path.concat(currentKey)); + + row.appendChild(keyInput); + row.appendChild(delBtn); + if (child) valueContainer.appendChild(child); + row.appendChild(valueContainer); + list.appendChild(row); + } + } + + function updateEntriesFromObject(parentObj) { + const arr = Object.entries(parentObj); + entries.length = 0; + arr.forEach(e => entries.push(e)); + } + + renderEntries(); + + const addWrap = document.createElement('div'); + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'outline'; + addBtn.textContent = '➕ Add'; + addBtn.addEventListener('click', () => { + const key = prompt('Enter key name'); + if (!key) return; + const parentObj = getPathObject(path, true); + if (parentObj[key] !== undefined) { + showMessage('Key already exists', 'error'); + return; + } + parentObj[key] = defaultForSchema(propSchema.additionalProperties); + setPathValue(path, parentObj); + updateEntriesFromObject(parentObj); + renderEntries(); + }); + addWrap.appendChild(addBtn); + + inner.appendChild(list); + inner.appendChild(addWrap); + wrapper.appendChild(inner); + return wrapper; + } + + // Objeto sin propiedades definidas + const note = document.createElement('div'); + note.textContent = '(object)'; + wrapper.appendChild(note); + return wrapper; + } + + // Boolean con toggle opcional + if (type.includes('boolean')) { + const toggle = document.createElement('button'); + toggle.type = 'button'; + const labels = propSchema?.['x-ui']?.toggleLabels || ['On', 'Off']; + let current = !!value; + toggle.textContent = current ? labels[0] : labels[1]; + toggle.className = current ? 'outline' : 'secondary'; + toggle.addEventListener('click', () => { + current = !current; + setPathValue(path, current); + toggle.textContent = current ? labels[0] : labels[1]; + toggle.className = current ? 'outline' : 'secondary'; + }); + wrapper.appendChild(toggle); + return wrapper; + } + + // Enum (select) o string simple + if (propSchema.enum) { + const select = document.createElement('select'); + for (const opt of propSchema.enum) { + const o = document.createElement('option'); + o.value = opt; + o.textContent = String(opt).toUpperCase(); + if (value === opt) o.selected = true; + select.appendChild(o); + } + select.addEventListener('change', () => setPathValue(path, select.value)); + wrapper.appendChild(select); + return wrapper; + } + + if (type.includes('number') || type.includes('integer')) { + const input = document.createElement('input'); + input.type = 'number'; + if (typeof value === 'number') input.value = String(value); + if (typeof propSchema.minimum !== 'undefined') input.min = String(propSchema.minimum); + if (typeof propSchema.maximum !== 'undefined') input.max = String(propSchema.maximum); + if (propSchema.step) input.step = String(propSchema.step); + input.addEventListener('input', () => { + const v = input.value === '' ? null : (type.includes('integer') ? parseInt(input.value) : parseFloat(input.value)); + setPathValue(path, v); + }); + wrapper.appendChild(input); + return wrapper; + } + + if (type.includes('array')) { + const arrWrap = document.createElement('div'); + arrWrap.className = 'array-group'; + + const list = document.createElement('div'); + const arr = Array.isArray(value) ? value : []; + + function renderItems() { + list.innerHTML = ''; + arr.forEach((itemVal, idx) => { + const row = document.createElement('div'); + row.className = 'array-item-row'; + // Soporta items string simples por ahora + const itemInput = document.createElement('input'); + itemInput.type = 'text'; + itemInput.value = itemVal; + itemInput.addEventListener('input', () => { + arr[idx] = itemInput.value; + setPathValue(path, arr.slice()); + }); + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'secondary'; + delBtn.textContent = '🗑️'; + delBtn.addEventListener('click', () => { + arr.splice(idx, 1); + setPathValue(path, arr.slice()); + renderItems(); + }); + row.appendChild(itemInput); + row.appendChild(delBtn); + list.appendChild(row); + }); + } + + renderItems(); + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'outline'; + addBtn.textContent = '➕ Add'; + addBtn.addEventListener('click', () => { + arr.push(''); + setPathValue(path, arr.slice()); + renderItems(); + }); + + arrWrap.appendChild(list); + arrWrap.appendChild(addBtn); + wrapper.appendChild(arrWrap); + return wrapper; + } + + // Fallback: string + const input = document.createElement('input'); + input.type = 'text'; + input.value = value ?? ''; + input.placeholder = propSchema.placeholder || ''; + input.addEventListener('input', () => setPathValue(path, input.value)); + wrapper.appendChild(input); + return wrapper; + } + + function setPathValue(path, v) { + if (!currentData) currentData = {}; + let cursor = currentData; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {}; + cursor = cursor[key]; + } + cursor[path[path.length - 1]] = v; + } + + function getPathObject(path, createIfMissing = false) { + if (!currentData) currentData = {}; + let cursor = currentData; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + if (i === path.length - 1) { + if (typeof cursor[key] !== 'object' || cursor[key] === null) { + if (createIfMissing) cursor[key] = {}; + else return {}; + } + return cursor[key]; + } + if (!cursor[key] || typeof cursor[key] !== 'object') { + if (createIfMissing) cursor[key] = {}; + else return {}; + } + cursor = cursor[key]; + } + return {}; + } + + function defaultForSchema(schema) { + const t = Array.isArray(schema.type) ? schema.type[0] : schema.type; + if (t === 'object') { + const obj = {}; + if (schema.properties) { + for (const [k, s] of Object.entries(schema.properties)) { + if (typeof s.default !== 'undefined') obj[k] = s.default; + else obj[k] = defaultForSchema(s); + } + } + return obj; + } + if (t === 'array') return []; + if (t === 'boolean') return !!schema.default; + if (t === 'number' || t === 'integer') return typeof schema.default !== 'undefined' ? schema.default : 0; + if (t === 'string') return schema.default || ''; + return null; + } + + async function onSave() { + if (!currentSchemaId) return; + try { + const res = await fetch(`/api/config/${currentSchemaId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(currentData || {}) + }); + const result = await res.json(); + if (result.success) { + showMessage('Configuration saved successfully', 'success'); + } else { + showMessage(result.error || 'Failed to save configuration', 'error'); + } + } catch (e) { + showMessage(`Error saving configuration: ${e}`, 'error'); + } + } + + async function onExport() { + if (!currentSchemaId) return; + try { + const res = await fetch(`/api/config/${currentSchemaId}/export`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${currentSchemaId}_export.json`; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 0); + } catch (e) { + showMessage(`Error exporting configuration: ${e}`, 'error'); + } + } + + async function onImport(evt) { + const file = evt.target.files && evt.target.files[0]; + if (!file || !currentSchemaId) return; + try { + const text = await file.text(); + const json = JSON.parse(text); + currentData = json; + // Re-render con el nuevo JSON (no volvemos a pedir el schema) + const res = await fetch(`/api/config/schema/${currentSchemaId}`); + const schemaData = await res.json(); + if (!schemaData.success) throw new Error(schemaData.error || 'Schema error'); + renderForm(document.getElementById('config-form-container'), schemaData.schema, currentData); + showMessage('JSON imported (not saved yet)', 'info'); + } catch (e) { + showMessage(`Invalid JSON: ${e}`, 'error'); + } finally { + // Reset input para permitir re-importar el mismo archivo + evt.target.value = ''; + } + } +})(); + + diff --git a/system_state.json b/system_state.json index 5de8ec4..d2f9286 100644 --- a/system_state.json +++ b/system_state.json @@ -3,10 +3,10 @@ "should_connect": true, "should_stream": false, "active_datasets": [ - "DAR", - "Fast" + "Fast", + "DAR" ] }, "auto_recovery_enabled": true, - "last_update": "2025-08-10T00:37:18.130615" + "last_update": "2025-08-10T01:12:19.169231" } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 41caee0..c912118 100644 --- a/templates/index.html +++ b/templates/index.html @@ -127,6 +127,7 @@ @@ -467,6 +468,35 @@ + +
+
+
🧩 Dynamic JSON Config Editor
+
+

Esquemas: Selecciona un esquema y edita PLC, Datasets o Plot Sessions con + formularios dinámicos.

+

Import/Export: Puedes importar desde un archivo JSON o exportar el actual.

+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+
+
@@ -773,6 +803,7 @@ +