Base de el configurador json basado en schemas

This commit is contained in:
Miguel 2025-08-10 01:17:14 +02:00
parent 5e2149b9d4
commit 10df4e94bd
12 changed files with 1234 additions and 9 deletions

View File

@ -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
}

View File

@ -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")

215
core/schema_manager.py Normal file
View File

@ -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
View File

@ -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"""

View File

@ -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"
}

View File

@ -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

View File

@ -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"
]
}

142
schemas/plc.schema.json Normal file
View File

@ -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"
]
}

86
schemas/plots.schema.json Normal file
View File

@ -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"
]
}

465
static/js/config_editor.js Normal file
View File

@ -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 = '';
}
}
})();

View File

@ -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"
}

View File

@ -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>