Base de el configurador json basado en schemas
This commit is contained in:
parent
5e2149b9d4
commit
10df4e94bd
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
89
main.py
89
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/<schema_id>", 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/<config_id>", 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/<config_id>", 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/<config_id>/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"""
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
Flask==2.3.3
|
||||
python-snap7==1.3
|
||||
psutil==5.9.5
|
||||
flask-socketio==5.3.6
|
||||
flask-socketio==5.3.6
|
||||
jsonschema==4.22.0
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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 = '';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
|
@ -127,6 +127,7 @@
|
|||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="datasets">📊 Datasets & Variables</button>
|
||||
<button class="tab-btn" data-tab="plotting">📈 Real-Time Plotting</button>
|
||||
<button class="tab-btn" data-tab="config-editor">🧩 Config Editor</button>
|
||||
<button class="tab-btn" data-tab="events">📋 Events & Logs</button>
|
||||
</nav>
|
||||
|
||||
|
@ -467,6 +468,35 @@
|
|||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 🧩 CONFIG EDITOR TAB -->
|
||||
<div class="tab-content" id="config-editor-tab">
|
||||
<article>
|
||||
<header>🧩 Dynamic JSON Config Editor</header>
|
||||
<div class="info-section">
|
||||
<p><strong>Esquemas:</strong> Selecciona un esquema y edita PLC, Datasets o Plot Sessions con
|
||||
formularios dinámicos.</p>
|
||||
<p><strong>Import/Export:</strong> Puedes importar desde un archivo JSON o exportar el actual.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>Schema</label>
|
||||
<select id="schema-selector"></select>
|
||||
</div>
|
||||
<div class="controls" style="align-self: end; display: flex; gap: .5rem;">
|
||||
<button id="btn-export-config" class="outline">⬇️ Export</button>
|
||||
<label class="outline" style="margin: 0;">
|
||||
⬆️ Import <input type="file" id="import-file" accept="application/json"
|
||||
style="display:none;">
|
||||
</label>
|
||||
<button id="btn-save-config">💾 Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="config-form-container" style="margin-top: 1rem;"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- 📈 PLOTTING TAB -->
|
||||
<div class="tab-content" id="plotting-tab">
|
||||
<article>
|
||||
|
@ -773,6 +803,7 @@
|
|||
<script src="/static/js/events.js"></script>
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/plotting.js"></script>
|
||||
<script src="/static/js/config_editor.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue