Version basica con Forms pero no dan una mejora a la aplicacion

This commit is contained in:
Miguel 2025-08-11 11:55:22 +02:00
parent 10df4e94bd
commit 5581e26d10
17 changed files with 558 additions and 53 deletions

View File

@ -30,6 +30,51 @@ The application is designed to be persistent across restarts, restoring the prev
### Latest Modifications (Current Session)
#### Config Editor UI Compact Layout and Schema Enhancements
User prompt summary: Mejorar la interfaz del editor JSONForm para que los campos no ocupen toda la pantalla; configurar límites; definir nombres y descripciones de campos.
Decisions:
- Aplicar layout compacto por CSS tanto para JSONForm como para el renderer fallback, en columnas auto-fit.
- Enriquecer los esquemas con `title`, `description`, límites (`minimum`, `maximum`, `minLength`, `maxLength`, `pattern`) y formatos donde aplique.
Implementation:
- styles.css: añadido grid en `#jsonform-form` y `.config-editor-form` + `.object-group` para 23 columnas según ancho, con gaps compactos.
- plc.schema.json: títulos y descripciones para `ip`, `rack`, `slot`; `format: ipv4` para `ip`; `maximum` y `description` para `sampling_interval`.
- datasets.schema.json: títulos/descr. para `name`, `prefix` (con `pattern` y límites de longitud), límites para `offset` (máx 8191) y `db` (máx 9999), títulos para `area/type/bit/streaming`, títulos para propiedades raíz y de dataset; `sampling_interval` con `maximum`.
- plots.schema.json: títulos y descripciones para `name`, `variables`, `time_window`, `y_min`, `y_max`, y campos de trigger.
Notes:
- JSON Schema estándar no define layout; el layout por columnas se resuelve en CSS/JSONForm form. El fallback ahora también se ve compacto sin depender de JSONForm.
#### Config Editor UI: JSONForm as primary renderer (simple forms from JSON Schema)
User prompt summary: Prefer a simpler form-based editor for JSON content (avoid exposing too many controls); evaluate alternatives to JSONEditor; prefer JSONForm (jQuery + Bootstrap-like forms) over tree/code editors.
Decisions:
- Keep backend `/api/config/*` unchanged; swap frontend renderer to JSONForm for a simpler UX.
- Load JSONForm via CDN with minimal deps (jQuery + Underscore). Avoid full Bootstrap migration; styling remains with Pico.css.
- Keep JSONEditor as fallback (tree/code) for advanced edits.
Implementation:
- templates/index.html: added CDN includes for jQuery, Underscore, and JSONForm; kept JSONEditor include for fallback.
- static/js/config_editor.js: prefer JSONForm when available, generating forms directly from JSON Schema; wired Save to submit JSONForm and PUT values; Import rebuilds form with imported JSON; Export uses last known values or JSONEditor when active.
Notes:
- JSONForm benefits: simple, guided forms; schema-driven validation; no React/bundler required.
- Licensing: MIT; OK for commercial/offline packaging.
- Handsontable not used due to licensing constraints; Tabulator remains an alternative for tabular arrays if needed.
#### Schema-based Config Editor & API
Decision: Añadir un editor dinámico basado en JSON Schema para gestionar `plc_config.json`, `plc_datasets.json` y `plot_sessions.json`, con importar/exportar.
Implementation:
- Backend: nueva clase `ConfigSchemaManager` (`core/schema_manager.py`), endpoints `/api/config/*` para listar esquemas, leer/escribir y exportar.
- Esquemas: `schemas/plc.schema.json`, `schemas/datasets.schema.json`, `schemas/plots.schema.json`.
- Frontend: pestaña “Config Editor” y script `static/js/config_editor.js` que genera formularios desde schema (objects, arrays, enums, booleans con labels) e importar/exportar.
- Dependencias: `jsonschema` (validación opcional en backend).
Notas UX: primera versión funcional. Siguiente paso acordado: evaluar librerías UI de JSON Schema para mejorar el diseño visual y la ergonomía del editor.
#### Real-Time Plotting System Implementation
**Decision**: Implementar un sistema completo de plotting en tiempo real con trigger de variables boolean y uso exclusivo del cache del recording.

View File

@ -9060,8 +9060,139 @@
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-10T01:23:18.751131",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-10T01:23:18.852237",
"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:23:18.873364",
"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:23:18.894713",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-10T01:45:12.463570",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-10T01:45:12.539769",
"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:45:12.552768",
"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:45:12.562768",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-11T00:48:08.669755",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T01:05:38.323746",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:04:13.582426",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:07:53.052382",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:12:42.001877",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:18:42.038963",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:23:34.891879",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
}
],
"last_updated": "2025-08-10T01:12:19.148987",
"total_entries": 838
"last_updated": "2025-08-11T11:23:34.891879",
"total_entries": 853
}

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PLC S7-31x Streamer & Logger - React</title>
<link rel="icon" href="/images/SIDEL.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "plc-streamer-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host"
},
"dependencies": {
"bootstrap": "^5.3.3",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

32
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,32 @@
import React from 'react'
function App() {
return (
<div className="container py-3">
<header className="mb-3">
<h1 className="h3 d-flex align-items-center gap-2">
<img src="/images/SIDEL.png" alt="SIDEL" style={{height: 28}} />
PLC S7-31x Streamer & Logger (React)
</h1>
<p className="text-muted mb-0">Base de React + Bootstrap lista. Empezaremos a migrar vistas.</p>
<a className="btn btn-sm btn-outline-secondary mt-2" href="/legacy">Ir al modo legado</a>
</header>
<div className="alert alert-info">
Esta es una vista inicial de React. Probaremos el consumo de APIs y luego migraremos módulos
(Status, Datasets/Variables, Plotting, Events, Config Editor) de forma incremental.
</div>
<section>
<h2 className="h5">Acciones rápidas</h2>
<div className="d-flex flex-wrap gap-2">
<a className="btn btn-primary" href="/app">Reload SPA</a>
<a className="btn btn-outline-primary" href="/api/status" target="_blank" rel="noreferrer">Ver /api/status</a>
</div>
</section>
</div>
)
}
export default App

11
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import 'bootstrap/dist/css/bootstrap.min.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

26
frontend/vite.config.js Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
host: true,
proxy: {
'/api': {
target: 'http://localhost:5050',
changeOrigin: true,
},
'/images': {
target: 'http://localhost:5050',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})

45
main.py
View File

@ -3,33 +3,28 @@ from flask import (
render_template,
request,
jsonify,
redirect,
url_for,
send_from_directory,
Response,
stream_template,
)
from flask_cors import CORS
import snap7
import snap7.util
import json
import socket
import time
import logging
import threading
from datetime import datetime
from typing import Dict, Any, Optional, List
import struct
import os
import csv
from pathlib import Path
import atexit
import psutil
import sys
from core import PLCDataStreamer
app = Flask(__name__)
CORS(
app,
resources={r"/api/*": {"origins": ["http://localhost:5173", "http://127.0.0.1:5173", "*"]}},
)
app.secret_key = "plc_streamer_secret_key"
# React build directory (for Vite production build)
REACT_DIST_DIR = os.path.join(os.path.abspath("."), "frontend", "dist")
def resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
@ -66,13 +61,31 @@ def serve_static(filename):
return send_from_directory("static", filename)
# ==============================
# Frontend (React SPA and Legacy)
# ==============================
@app.route("/app/assets/<path:filename>")
def serve_react_assets(filename):
"""Serve built React assets from Vite (production)."""
assets_dir = os.path.join(REACT_DIST_DIR, "assets")
return send_from_directory(assets_dir, filename)
@app.route("/")
def index():
"""Main page"""
@app.route("/app")
def serve_react_index():
"""Serve React SPA index (expects Vite build at frontend/dist)."""
index_path = os.path.join(REACT_DIST_DIR, "index.html")
return send_from_directory(REACT_DIST_DIR, "index.html")
@app.route("/legacy")
def legacy_index():
"""Legacy Jinja UI (kept for transition)."""
if streamer is None:
return "Application not initialized", 503
# Get variables for the current dataset or empty dict if no current dataset
current_variables = {}
if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets:
current_variables = streamer.datasets[streamer.current_dataset_id].get(

View File

@ -65,5 +65,5 @@
],
"current_dataset_id": "Fast",
"version": "1.0",
"last_update": "2025-08-10T01:12:19.126350"
"last_update": "2025-08-10T01:45:12.551768"
}

View File

@ -2,4 +2,5 @@ Flask==2.3.3
python-snap7==1.3
psutil==5.9.5
flask-socketio==5.3.6
jsonschema==4.22.0
jsonschema==4.22.0
Flask-Cors==4.0.0

View File

@ -13,10 +13,19 @@
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"title": "Dataset Name",
"description": "Nombre legible del dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string"
"type": "string",
"title": "CSV Prefix",
"description": "Prefijo para archivos CSV",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"variables": {
"type": "object",
@ -25,6 +34,7 @@
"properties": {
"area": {
"type": "string",
"title": "Memory Area",
"enum": [
"db",
"mw",
@ -43,22 +53,28 @@
"integer",
"null"
],
"minimum": 1
"title": "DB Number",
"minimum": 1,
"maximum": 9999
},
"offset": {
"type": "integer",
"minimum": 0
"title": "Offset",
"minimum": 0,
"maximum": 8191
},
"bit": {
"type": [
"integer",
"null"
],
"title": "Bit Position",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"title": "Data Type",
"enum": [
"real",
"int",
@ -74,6 +90,7 @@
},
"streaming": {
"type": "boolean",
"title": "Stream to PlotJuggler",
"default": false
}
},
@ -86,6 +103,7 @@
},
"streaming_variables": {
"type": "array",
"title": "Streaming Variables",
"items": {
"type": "string"
},
@ -96,17 +114,32 @@
"number",
"null"
],
"minimum": 0.01
"title": "Sampling Interval (s)",
"description": "Vacío para usar el intervalo global",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"default": false
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
]
],
"title": "Created"
}
},
"required": [
@ -119,6 +152,7 @@
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
@ -128,16 +162,19 @@
"type": [
"string",
"null"
]
],
"title": "Current Dataset Id"
},
"version": {
"type": "string"
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
]
],
"title": "Last Update"
}
},
"required": [

View File

@ -14,16 +14,22 @@
"ip": {
"type": "string",
"title": "PLC IP",
"description": "Dirección IP del PLC (S7-31x)",
"format": "ipv4",
"pattern": "^.+$"
},
"rack": {
"type": "integer",
"title": "Rack",
"description": "Número de rack (0-7)",
"minimum": 0,
"maximum": 7,
"default": 0
},
"slot": {
"type": "integer",
"title": "Slot",
"description": "Número de slot (generalmente 2)",
"minimum": 0,
"maximum": 31,
"default": 2
@ -61,7 +67,9 @@
"sampling_interval": {
"type": "number",
"minimum": 0.01,
"maximum": 10,
"title": "Sampling Interval (s)",
"description": "Intervalo global de muestreo en segundos",
"default": 0.1
},
"csv_config": {
@ -78,8 +86,12 @@
"type": "boolean",
"title": "Rotation",
"default": true,
"x-ui": {
"toggleLabels": [
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]

View File

@ -12,10 +12,14 @@
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"title": "Plot Name",
"description": "Nombre de la sesión de gráfica"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables a graficar",
"items": {
"type": "string"
},
@ -23,6 +27,8 @@
},
"time_window": {
"type": "integer",
"title": "Time Window (s)",
"description": "Ventana temporal en segundos",
"minimum": 5,
"maximum": 3600,
"default": 60
@ -31,30 +37,38 @@
"type": [
"number",
"null"
]
],
"title": "Y Min",
"description": "Vacío para auto"
},
"y_max": {
"type": [
"number",
"null"
]
],
"title": "Y Max",
"description": "Vacío para auto"
},
"trigger_variable": {
"type": [
"string",
"null"
]
],
"title": "Trigger Variable"
},
"trigger_enabled": {
"type": "boolean",
"title": "Enable Trigger",
"default": false
},
"trigger_on_true": {
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"session_id": {
"type": "string"
"type": "string",
"title": "Session Id"
}
},
"required": [

View File

@ -1337,4 +1337,64 @@ textarea {
.tab-btn.active {
border-right-color: var(--pico-primary);
}
}
/* =============================
Config Editor (JSON Schema) UI compact layout
Works for both JSONForm (#jsonform-form) and fallback renderer (.config-editor-form)
============================= */
/* Grid layout for JSONForm-generated form */
#jsonform-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
#jsonform-form .control-group {
margin: 0;
}
#jsonform-form .form-actions {
grid-column: 1 / -1;
}
/* Grid layout for fallback renderer */
.config-editor-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.config-editor-form>.form-group {
margin: 0;
}
/* Group inner properties in a compact grid */
.config-editor-form .object-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-muted-background-color);
}
.config-editor-form .object-group>.form-group {
margin: 0;
}
/* Make number inputs a bit tighter to save space */
.config-editor-form input[type="number"] {
max-width: 100%;
}
/* Responsive tweak for very small screens */
@media (max-width: 480px) {
#jsonform-form,
.config-editor-form,
.config-editor-form .object-group {
grid-template-columns: 1fr;
}
}

View File

@ -84,7 +84,41 @@
currentData = configData.data;
renderForm(container, schemaData.schema, currentData);
if (container) container.innerHTML = '';
// Prefer JSONForm if available for a simple form UI
if (window.$ && window._ && typeof $.fn.jsonForm === 'function') {
const formEl = document.createElement('form');
formEl.id = 'jsonform-form';
container.appendChild(formEl);
const formDef = ["*", { "type": "submit", "title": "Save" }];
$(formEl).jsonForm({
schema: schemaData.schema,
form: formDef,
value: currentData,
onSubmit: function (errors, values) {
if (errors) {
showMessage('Validation errors in form', 'error');
return false;
}
doSave(values);
return false;
}
});
const saveBtn = document.getElementById('btn-save-config');
if (saveBtn) {
saveBtn.onclick = () => {
const f = document.getElementById('jsonform-form');
if (f) f.requestSubmit ? f.requestSubmit() : f.dispatchEvent(new Event('submit', { cancelable: true }));
};
}
} else {
// Fallback: minimal manual renderer based on schema
renderForm(container, schemaData.schema, currentData);
}
} catch (e) {
if (container) container.innerHTML = '';
showMessage(`Error loading editor: ${e}`, 'error');
@ -120,6 +154,17 @@
title.textContent = propSchema.title || label;
wrapper.appendChild(title);
// Optional description/help text
if (propSchema.description) {
const help = document.createElement('small');
help.textContent = propSchema.description;
help.style.display = 'block';
help.style.color = 'var(--pico-muted-color)';
help.style.marginTop = '-0.25rem';
help.style.marginBottom = '0.25rem';
wrapper.appendChild(help);
}
const type = Array.isArray(propSchema.type) ? propSchema.type : [propSchema.type];
// Objetos
@ -401,20 +446,32 @@
return null;
}
async function onSave() {
async function doSave(payload) {
if (!currentSchemaId) return;
try {
const res = await fetch(`/api/config/${currentSchemaId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentData || {})
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.success) {
showMessage('Configuration saved successfully', 'success');
} else {
showMessage(result.error || 'Failed to save configuration', 'error');
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 onSave() {
if (!currentSchemaId) return;
try {
// If JSONForm exists, its Save is already wired; fall back to currentData
let payload = currentData || {};
if (window.__jsonEditorInstance && typeof window.__jsonEditorInstance.get === 'function') {
// Legacy safety; should not happen because we no longer render JSONEditor
payload = window.__jsonEditorInstance.get();
}
await doSave(payload);
} catch (e) {
showMessage(`Error saving configuration: ${e}`, 'error');
}
@ -423,8 +480,12 @@
async function onExport() {
if (!currentSchemaId) return;
try {
const res = await fetch(`/api/config/${currentSchemaId}/export`);
const blob = await res.blob();
let val = currentData || {};
// Prefer currentData; JSONForm updates are applied on submit
if (window.__jsonEditorInstance && typeof window.__jsonEditorInstance.get === 'function') {
val = window.__jsonEditorInstance.get();
}
const blob = new Blob([JSON.stringify(val, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -447,16 +508,31 @@
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);
const formEl = document.getElementById('jsonform-form');
if (formEl && window.$ && typeof $(formEl).jsonForm === 'function') {
const schemaRes = await fetch(`/api/config/schema/${currentSchemaId}`);
const schemaData = await schemaRes.json();
if (!schemaData.success) throw new Error(schemaData.error || 'Schema error');
$(formEl).jsonForm({
schema: schemaData.schema,
form: ["*", { "type": "submit", "title": "Save" }],
value: currentData,
onSubmit: function (errors, values) {
if (errors) return showMessage('Validation errors in form', 'error');
doSave(values);
return false;
}
});
} else {
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

@ -8,5 +8,5 @@
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-10T01:12:19.169231"
"last_update": "2025-08-10T01:45:12.574799"
}

View File

@ -12,6 +12,8 @@
<!-- Custom styles -->
<link rel="stylesheet" href="/static/css/styles.css">
<!-- JSONEditor (tree view) CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsoneditor@9.10.2/dist/jsoneditor.min.css">
</head>
<body>
@ -23,6 +25,9 @@
</div>
<main class="container">
<div class="info-section" style="margin: 1rem 0;">
<p><strong>New UI available:</strong> La nueva interfaz React está disponible en <a href="/app">/app</a>. Esta página permanecerá como modo legado temporalmente.</p>
</div>
<!-- Header -->
<header class="header">
<h1>
@ -790,6 +795,13 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
<script src="https://unpkg.com/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0"></script>
<!-- JSONEditor (tree view) -->
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.10.2/dist/jsoneditor.min.js"></script>
<!-- JSONForm deps (jQuery + Underscore) and JSONForm -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.13.6/underscore-min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jsonform/jsonform@2.5.1/lib/deps/opt/jsv.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jsonform/jsonform@2.5.1/lib/jsonform.js"></script>
<!-- JavaScript Modules -->
<script src="/static/js/utils.js"></script>