From 593487e52fab435e88686377c59a507caba43400 Mon Sep 17 00:00:00 2001 From: Miguel Date: Mon, 11 Aug 2025 15:01:53 +0200 Subject: [PATCH] =?UTF-8?q?Actualizaci=C3=B3n=20del=20archivo=20.gitignore?= =?UTF-8?q?=20para=20ignorar=20archivos=20generados=20autom=C3=A1ticamente?= =?UTF-8?q?=20por=20React=20(Vite)=20en=20el=20frontend.=20Se=20a=C3=B1adi?= =?UTF-8?q?eron=20m=C3=BAltiples=20entradas=20en=20application=5Fevents.js?= =?UTF-8?q?on=20para=20registrar=20eventos=20de=20inicio=20de=20aplicaci?= =?UTF-8?q?=C3=B3n=20y=20errores=20de=20conexi=C3=B3n=20al=20PLC.=20Se=20r?= =?UTF-8?q?ealizaron=20cambios=20en=20main.py=20para=20ajustar=20las=20rut?= =?UTF-8?q?as=20y=20mejorar=20la=20gesti=C3=B3n=20de=20errores,=20adem?= =?UTF-8?q?=C3=A1s=20de=20eliminar=20la=20interfaz=20de=20usuario=20hereda?= =?UTF-8?q?da.=20Se=20actualizaron=20las=20dependencias=20en=20package.jso?= =?UTF-8?q?n=20y=20se=20implement=C3=B3=20un=20enrutador=20en=20App.jsx=20?= =?UTF-8?q?para=20la=20nueva=20SPA=20de=20React.=20Se=20modific=C3=B3=20in?= =?UTF-8?q?dex.html=20para=20reflejar=20la=20transici=C3=B3n=20a=20la=20nu?= =?UTF-8?q?eva=20interfaz.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .doc/MemoriaDeEvolucion.md | 95 ++++++ .gitignore | 12 + application_events.json | 51 +++- frontend/package.json | 9 +- frontend/src/App.jsx | 58 +++- frontend/src/components/PLCConfigModal.jsx | 85 ++++++ frontend/src/components/rjsf/widgets.jsx | 101 ++++++ frontend/src/main.jsx | 5 +- frontend/src/pages/Config.jsx | 167 ++++++++++ frontend/src/pages/Dashboard.jsx | 339 +++++++++++++++++++++ frontend/src/pages/Events.jsx | 72 +++++ frontend/src/pages/Plots.jsx | 12 + frontend/src/pages/Status.jsx | 69 +++++ frontend/src/services/api.js | 91 ++++++ main.py | 48 ++- static/icons/record.png | Bin 0 -> 15983 bytes templates/index.html | 3 +- 17 files changed, 1178 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/PLCConfigModal.jsx create mode 100644 frontend/src/components/rjsf/widgets.jsx create mode 100644 frontend/src/pages/Config.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Events.jsx create mode 100644 frontend/src/pages/Plots.jsx create mode 100644 frontend/src/pages/Status.jsx create mode 100644 frontend/src/services/api.js create mode 100644 static/icons/record.png diff --git a/.doc/MemoriaDeEvolucion.md b/.doc/MemoriaDeEvolucion.md index 110f688..1993869 100644 --- a/.doc/MemoriaDeEvolucion.md +++ b/.doc/MemoriaDeEvolucion.md @@ -30,6 +30,101 @@ The application is designed to be persistent across restarts, restoring the prev ### Latest Modifications (Current Session) +#### Unified React Dashboard (Status + Collapsible Config + Events Preview) +User prompt summary: "Hacer la página principal en React más intuitiva como el legacy: estado tipo `index.html`/`status.js`, agregar el log de eventos al final, y que Config sea un tab colapsable en la misma página." + +Decisions: +- Create a new React Dashboard as the main route with: legacy-like status controls, a collapsible Config section using RJSF with tabs for `plc`, `datasets`, `plots`, and a recent Events table at the bottom. +- Keep existing dedicated pages (`/status`, `/events`, `/config`, `/plots`) for deeper navigation. + +Implementation: +- `frontend/src/pages/Dashboard.jsx`: + - Status bar with PLC Connect/Disconnect and UDP Start/Stop mirrored from legacy behavior; live updates via SSE `/api/stream/status`. + - Collapsible Config editor using `@rjsf/core` + custom widgets, schema selector tabs, Import/Export, Save. + - Recent Events preview table (last 50) with quick Refresh and link to full Events page. +- `frontend/src/services/api.js`: added helpers `connectPlc`, `disconnectPlc`, `startUdpStreaming`, `stopUdpStreaming`. +- `frontend/src/App.jsx`: route `/` now renders the new Dashboard. + +Notes: +- Variables and comments kept in English as per project rules. No fallback code added. +- This makes the SPA landing page operationally useful similar to the legacy UI while maintaining modular pages. + +#### React SPA Migration and RJSF Implementation +User prompt summary: "Migrate the legacy `index.html` to a React SPA using Bootstrap 5. Use `react-jsonschema-form` (RJSF) to create forms from JSON schemas, especially for `plc_config.json` in a modal. Also, create a dedicated page for real-time plots and use a table editor for arrays." + +Decisions: +- We will replace the Jinja2-based frontend with a React Single-Page Application (SPA). +- We will use `react-bootstrap` for the UI components to align with Bootstrap 5. +- We will use `react-jsonschema-form` (`@rjsf/core`) to dynamically generate forms from our existing JSON schemas. +- Due to dependency conflicts with `@rjsf/bootstrap-4` and `@rjsf/react-bootstrap`, we will create custom form widgets using `react-bootstrap` components to ensure compatibility and full control over the UI. + +Implementation: +- `frontend/package.json`: Installed `@rjsf/core` and `@rjsf/validator-ajv8`. Removed attempts to install theme packages. +- `frontend/src/pages/Plots.jsx`: Created a placeholder page for the real-time plotting feature. +- `frontend/src/components/PLCConfigModal.jsx`: + - Refactored the modal to use `@rjsf/core` instead of a themed version. + - Implemented custom widgets (`TextWidget`, `UpDownWidget`) using `react-bootstrap` components (`BSForm.Control`). + - Created a `uiSchema` to map the custom widgets to the corresponding fields in the `plc_config.json` schema. +- This approach resolves the dependency issues and provides a flexible foundation for building the rest of the forms. + +Notes: +- The installation of RJSF theme packages proved to be problematic due to version incompatibilities with `react-bootstrap`. The custom widget approach is more robust. +- The next steps will be to create custom widgets for other form fields (like booleans and selects) and to implement the table editor for arrays. + +#### React SPA: rutas iniciales y consumo de APIs (Status, Events) +User prompt summary: "El servidor frontend con React en main.py parece funcionar; migrar resto de vistas incrementalmente (Status, Datasets/Variables, Plotting, Events, Config Editor)." + +Decisions: +- Mantener migración incremental creando SPA con router y servicios de API reutilizables. +- Priorizar páginas de bajo riesgo: `Status` y `Events` como primeras vistas. + +Implementation: +- `frontend/src/App.jsx`: añadió router con rutas `/`, `/status`, `/events`, barra de navegación. +- `frontend/src/pages/Status.jsx`: página que consume `/api/status` con botón de refresco. +- `frontend/src/pages/Events.jsx`: página que consume `/api/events?limit=100` con tabla responsive. +- `frontend/src/services/api.js`: cliente fetch básico (`getStatus`, `getEvents`, `getJson`, `postJson`, `putJson`). +- `frontend/src/main.jsx`: envoltura con `BrowserRouter`. +- `frontend/package.json`: dependencia `react-router-dom`. + +Notes: +- Próximos pasos: migrar `Datasets/Variables`, `Plotting` y `Config Editor` usando el mismo cliente API. + +#### RJSF + React-Bootstrap para Config Editor y modal PLC; página dedicada de Plots +User prompt summary: "Usar Bootstrap en legacy, migrar a React con RJSF (tema Bootstrap 5) para formularios basados en JSON Schema; arrays como tabla; página separada para plots; modal para `plc_config.json`." + +Decisions: +- Adoptar `react-jsonschema-form` con tema Bootstrap 5 para editar `plc`, `datasets`, `plots` desde schemas. +- Crear página `Config` y modal dedicado para PLC config con RJSF. +- Añadir página independiente `Plots` para migrar gráficos en tiempo real. + +Implementation: +- `frontend/package.json`: añadidas deps `@rjsf/core`, `@rjsf/bootstrap-5`, `@rjsf/validator-ajv8`, `react-bootstrap`. +- `frontend/src/services/api.js`: funciones `listSchemas`, `getSchema`, `readConfig`, `writeConfig`. +- `frontend/src/pages/Config.jsx`: selector de esquema, import/export JSON, form RJSF con guardado. +- `frontend/src/components/PLCConfigModal.jsx`: modal con RJSF para `plc`. +- `frontend/src/pages/Plots.jsx`: scaffolding de página de plots. +- `frontend/src/App.jsx`: rutas `/config` y `/plots`, botón para abrir modal de PLC. + +Notes: +- Próximo: editor tabular para arrays (variables de datasets) con tabla editable; luego migrar plotting realtime. + +#### React + Vite + Bootstrap Migration Kickoff +User prompt summary: "Pasar todo a Bootstrap y React con Vite; comenzar la refactorización del proyecto (main.py, index.html)". + +Decisions: +- Mantener backend Flask y APIs `/api/*` intactas. +- Añadir CORS para permitir desarrollo con Vite (`localhost:5173`). +- Servir build de React (Vite) en `/app` y conservar UI Jinja como `/legacy` para transición. + +Implementation: +- `main.py`: agregado Flask-Cors; nuevas rutas `/app` e `/app/assets/*` sirviendo `frontend/dist`; `/legacy` mantiene template actual; `/` ahora sirve la SPA React. +- `requirements.txt`: añadido `Flask-Cors`. +- `templates/index.html`: aviso con enlace a `/app` (nueva UI). +- `frontend/`: creado proyecto base Vite React con Bootstrap (package.json, vite.config.js con proxy a Flask, index.html, src/main.jsx, src/App.jsx). + +Notes: +- Migración incremental: iremos moviendo secciones (Status, Datasets/Variables, Plotting, Events, Config Editor) a React en etapas. + #### 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. diff --git a/.gitignore b/.gitignore index 644dba2..68350d1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,18 @@ __pycache__/ /.doc/realtime/ +# Ignorar archivos generados automáticamente por React (Vite) en frontend +/frontend/node_modules/ +/frontend/dist/ +/frontend/.vite/ +/frontend/.env.local +/frontend/.env.*.local +/frontend/.eslintcache +/frontend/yarn.lock +/frontend/package-lock.json +/frontend/.DS_Store + + # Distribution / packaging .Python build/ diff --git a/application_events.json b/application_events.json index a731a81..d5df825 100644 --- a/application_events.json +++ b/application_events.json @@ -9191,8 +9191,55 @@ "event_type": "application_started", "message": "Application initialization completed successfully", "details": {} + }, + { + "timestamp": "2025-08-11T12:09:11.404727", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-11T12:45:28.303517", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-11T13:12:55.081850", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-11T13:37:47.901634", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-11T13:52:48.189847", + "level": "info", + "event_type": "application_started", + "message": "Application initialization completed successfully", + "details": {} + }, + { + "timestamp": "2025-08-11T13:53:59.849319", + "level": "error", + "event_type": "plc_connection_failed", + "message": "Failed to connect to PLC 10.1.33.11", + "details": { + "ip": "10.1.33.11", + "rack": 0, + "slot": 2, + "error": "b' ISO : An error occurred during recv TCP : Connection timed out'" + } } ], - "last_updated": "2025-08-11T11:23:34.891879", - "total_entries": 853 + "last_updated": "2025-08-11T13:53:59.849319", + "total_entries": 859 } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 1632136..eae1515 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,13 +9,16 @@ "preview": "vite preview --host" }, "dependencies": { + "@rjsf/core": "^5.24.12", + "@rjsf/validator-ajv8": "^5.24.12", "bootstrap": "^5.3.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-bootstrap": "^2.10.4", + "react-dom": "^18.2.0", + "react-router-dom": "^6.26.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", "vite": "^5.4.0" } -} - +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b2e0ee9..e06e212 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,20 +1,26 @@ import React from 'react' +import { Routes, Route, Link } from 'react-router-dom' +import StatusPage from './pages/Status.jsx' +import EventsPage from './pages/Events.jsx' +import ConfigPage from './pages/Config.jsx' +import PlotsPage from './pages/Plots.jsx' +import PLCConfigModal from './components/PLCConfigModal.jsx' +import DashboardPage from './pages/Dashboard.jsx' -function App() { +function Home() { return (

- SIDEL + SIDEL PLC S7-31x Streamer & Logger (React)

-

Base de React + Bootstrap lista. Empezaremos a migrar vistas.

- Ir al modo legado +

React base ready. We will migrate views incrementally.

+ Go to legacy mode
- 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. + 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.
@@ -28,5 +34,45 @@ function App() { ) } +function NavBar() { + return ( + + ) +} + +function App() { + const [showPLCModal, setShowPLCModal] = React.useState(false) + return ( + <> + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + + setShowPLCModal(false)} /> + + ) +} + export default App diff --git a/frontend/src/components/PLCConfigModal.jsx b/frontend/src/components/PLCConfigModal.jsx new file mode 100644 index 0000000..5f159e5 --- /dev/null +++ b/frontend/src/components/PLCConfigModal.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import Form from '@rjsf/core'; +import validator from '@rjsf/validator-ajv8'; +import { getSchema, readConfig, writeConfig } from '../services/api.js'; +import { widgets } from './rjsf/widgets.jsx'; + +const uiSchema = { + 'ui:widget': 'TextWidget', + plc_config: { + rack: { 'ui:widget': 'UpDownWidget' }, + slot: { 'ui:widget': 'UpDownWidget' }, + }, + udp_config: { + port: { 'ui:widget': 'UpDownWidget' }, + }, + sampling_interval: { 'ui:widget': 'UpDownWidget' }, + csv_config: { + max_size_mb: { 'ui:widget': 'UpDownWidget' }, + max_days: { 'ui:widget': 'UpDownWidget' }, + max_hours: { 'ui:widget': 'UpDownWidget' }, + cleanup_interval_hours: { 'ui:widget': 'UpDownWidget' }, + }, +}; + +export default function PLCConfigModal({ show, onClose }) { + const [schema, setSchema] = useState(null); + const [formData, setFormData] = useState(null); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(''); + + useEffect(() => { + if (show) { + Promise.all([getSchema('plc'), readConfig('plc')]) + .then(([s, d]) => { + setSchema(s.schema); + setFormData(d.data); + }) + .catch(() => setMsg('Error loading PLC config')); + } + }, [show]); + + const handleSubmit = async ({ formData: newData }) => { + setSaving(true); + setMsg(''); + try { + await writeConfig('plc', newData); + setMsg('Saved successfully'); + onClose?.(); + } catch (e) { + setMsg(e.message || 'Error saving PLC config'); + } finally { + setSaving(false); + } + }; + + return ( + + + PLC Configuration + + + {msg &&
{msg}
} + {schema && ( +
setFormData(formData)} + widgets={widgets} + uiSchema={uiSchema} + > + +
+ )} +
+ + + +
+ ); +} + + diff --git a/frontend/src/components/rjsf/widgets.jsx b/frontend/src/components/rjsf/widgets.jsx new file mode 100644 index 0000000..85e7830 --- /dev/null +++ b/frontend/src/components/rjsf/widgets.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Form as BSForm } from 'react-bootstrap'; + +export const TextWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => ( + + {schema?.title && {schema.title}} + onChange(event.target.value)} + onBlur={() => onBlur && onBlur(id, value)} + onFocus={() => onFocus && onFocus(id, value)} + /> + +); + +export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => ( + + {schema?.title && {schema.title}} + onChange(event.target.value === '' ? undefined : Number(event.target.value))} + onBlur={() => onBlur && onBlur(id, value)} + onFocus={() => onFocus && onFocus(id, value)} + /> + +); + +export const TextareaWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => ( + + {schema?.title && {schema.title}} + onChange(event.target.value)} + onBlur={() => onBlur && onBlur(id, value)} + onFocus={() => onFocus && onFocus(id, value)} + /> + +); + +export const SelectWidget = ({ id, required, readonly, disabled, label, value, onChange, options, schema }) => { + const enumOptions = options?.enumOptions || []; + return ( + + {schema?.title && {schema.title}} + onChange(event.target.value)} + > + {enumOptions.map((opt) => ( + + ))} + + + ); +}; + +export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange }) => ( + + onChange(e.target.checked)} + /> + +); + +// Map keys must match RJSF default widget names to override them automatically by type +export const widgets = { + TextWidget, + UpDownWidget, + SelectWidget, + CheckboxWidget, + TextareaWidget, +}; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 4de5902..490eceb 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,10 +2,13 @@ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App.jsx' import 'bootstrap/dist/css/bootstrap.min.css' +import { BrowserRouter } from 'react-router-dom' createRoot(document.getElementById('root')).render( - + + + ) diff --git a/frontend/src/pages/Config.jsx b/frontend/src/pages/Config.jsx new file mode 100644 index 0000000..5f8838a --- /dev/null +++ b/frontend/src/pages/Config.jsx @@ -0,0 +1,167 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Container, Row, Col, Button, ButtonGroup, Dropdown, DropdownButton } from 'react-bootstrap' +import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import { listSchemas, getSchema, readConfig, writeConfig } from '../services/api.js' +import { widgets } from '../components/rjsf/widgets.jsx' + +function buildUiSchema(schema) { + if (!schema || typeof schema !== 'object') return undefined + + const mapForType = (s) => { + if (!s || typeof s !== 'object') return undefined + const type = s.type + // handle oneOf/anyOf by taking first option for ui mapping + const resolved = type || (Array.isArray(s.oneOf) && s.oneOf[0]?.type) || (Array.isArray(s.anyOf) && s.anyOf[0]?.type) + if (resolved === 'string') return { 'ui:widget': 'TextWidget' } + if (resolved === 'integer' || resolved === 'number') return { 'ui:widget': 'UpDownWidget' } + if (resolved === 'boolean') return { 'ui:widget': 'CheckboxWidget' } + if (resolved === 'object' && s.properties) { + const ui = {} + for (const [key, prop] of Object.entries(s.properties)) { + ui[key] = mapForType(prop) + } + return ui + } + if (resolved === 'array' && s.items) { + // Apply mapping to array items when simple types + const itemUi = mapForType(s.items) + return itemUi ? { items: itemUi } : undefined + } + return undefined + } + + return mapForType(schema) +} + +export default function ConfigPage() { + const [schemas, setSchemas] = useState({}) + const [currentId, setCurrentId] = useState('plc') + const [schema, setSchema] = useState(null) + const [uiSchema, setUiSchema] = useState(undefined) + const [formData, setFormData] = useState(null) + const [loading, setLoading] = useState(false) + const [message, setMessage] = useState('') + + const available = useMemo(() => { + const ids = [] + if (schemas?.plc) ids.push('plc') + if (schemas?.datasets) ids.push('datasets') + if (schemas?.plots) ids.push('plots') + return ids + }, [schemas]) + + const load = async (id) => { + setLoading(true) + setMessage('') + try { + const [schemaResp, dataResp] = await Promise.all([ + getSchema(id), + readConfig(id), + ]) + setSchema(schemaResp.schema) + setUiSchema(buildUiSchema(schemaResp.schema)) + setFormData(dataResp.data) + } catch (e) { + setMessage(e.message || 'Error loading schema/config') + } finally { + setLoading(false) + } + } + + useEffect(() => { + listSchemas().then(setSchemas).catch(() => { }) + }, []) + + useEffect(() => { + if (currentId) { + load(currentId) + } + }, [currentId]) + + const handleSave = async ({ formData: newData }) => { + setLoading(true) + setMessage('') + try { + await writeConfig(currentId, newData) + setFormData(newData) + setMessage('Saved successfully') + } catch (e) { + setMessage(e.message || 'Error saving configuration') + } finally { + setLoading(false) + } + } + + const handleFileImport = async (evt) => { + const file = evt.target.files?.[0] + if (!file) return + try { + const text = await file.text() + const json = JSON.parse(text) + setFormData(json) + setMessage(`Imported ${file.name}`) + } catch (e) { + setMessage('Invalid JSON file') + } + } + + const handleExport = () => { + const blob = new Blob([JSON.stringify(formData ?? {}, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${currentId}_config.json` + a.click() + URL.revokeObjectURL(url) + } + + return ( + + +

Config Editor

+ + + {available.map(id => ( + setCurrentId(id)}> + {id} + + ))} + + + + + + + + +
+ + {message && ( +
{message}
+ )} + + {schema && ( +
setFormData(formData)} + widgets={widgets} + uiSchema={uiSchema} + > +
+ + +
+
+ )} +
+ ) +} + + diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..10aa862 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,339 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import { widgets } from '../components/rjsf/widgets.jsx' +import { + getStatus, + getEvents, + listSchemas, + getSchema, + readConfig, + writeConfig, + connectPlc, + disconnectPlc, + startUdpStreaming, + stopUdpStreaming, +} from '../services/api.js' + +function StatusBar({ status }) { + const plcConnected = !!status?.plc_connected + const streaming = !!status?.streaming + const csvRecording = !!status?.csv_recording + return ( +
+
+
+
+
+
🔌 PLC: {plcConnected ? 'Connected' : 'Disconnected'}
+ {status?.plc_reconnection?.enabled && ( +
+ 🔄 Auto-reconnection: {status?.plc_reconnection?.active ? 'reconnecting…' : 'enabled'} +
+ )} +
+
+ {plcConnected ? ( + + ) : ( + + )} +
+
+
+
+
+
+
+
+
📡 UDP Streaming: {streaming ? 'Active' : 'Inactive'}
+
+
+ {streaming ? ( + + ) : ( + + )} +
+
+
+
+
+
+
+
💾 CSV: {csvRecording ? 'Recording' : 'Inactive'}
+ {status?.disk_space_info && ( +
+ 💽 {status.disk_space_info.free_space} free · ⏱️ ~{status.disk_space_info.recording_time_left} +
+ )} +
+
+
+
+ ) +} + +function buildUiSchema(schema) { + if (!schema || typeof schema !== 'object') return undefined + const mapForType = (s) => { + if (!s || typeof s !== 'object') return undefined + const type = s.type + const resolved = type || (Array.isArray(s.oneOf) && s.oneOf[0]?.type) || (Array.isArray(s.anyOf) && s.anyOf[0]?.type) + if (resolved === 'string') return { 'ui:widget': 'TextWidget' } + if (resolved === 'integer' || resolved === 'number') return { 'ui:widget': 'UpDownWidget' } + if (resolved === 'boolean') return { 'ui:widget': 'CheckboxWidget' } + if (resolved === 'object' && s.properties) { + const ui = {} + for (const [key, prop] of Object.entries(s.properties)) { + ui[key] = mapForType(prop) + } + return ui + } + if (resolved === 'array' && s.items) { + const itemUi = mapForType(s.items) + return itemUi ? { items: itemUi } : undefined + } + return undefined + } + return mapForType(schema) +} + +export default function DashboardPage() { + const [status, setStatus] = useState(null) + const [statusError, setStatusError] = useState('') + const sseRef = useRef(null) + + const [schemas, setSchemas] = useState({}) + const available = useMemo(() => { + if (!schemas) return [] + // Accept multiple shapes from API + if (Array.isArray(schemas.schemas)) return schemas.schemas + if (schemas.schemas && typeof schemas.schemas === 'object') return Object.keys(schemas.schemas) + const ids = [] + if (schemas?.plc) ids.push('plc') + if (schemas?.datasets) ids.push('datasets') + if (schemas?.plots) ids.push('plots') + return ids + }, [schemas]) + + const [currentSchemaId, setCurrentSchemaId] = useState('plc') + const [schema, setSchema] = useState(null) + const [uiSchema, setUiSchema] = useState(undefined) + const [formData, setFormData] = useState(null) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [accordionOpen, setAccordionOpen] = useState(true) + + const [events, setEvents] = useState([]) + const [eventsLoading, setEventsLoading] = useState(false) + + const loadStatusOnce = async () => { + try { + const data = await getStatus() + setStatus(data) + } catch (e) { + setStatusError(e.message || 'Error fetching status') + } + } + + const subscribeSSE = () => { + try { + const es = new EventSource('/api/stream/status?interval=2.0') + sseRef.current = es + es.onmessage = (evt) => { + try { + const payload = JSON.parse(evt.data) + if (payload?.type === 'status' && payload.status) { + setStatus(payload.status) + } + } catch { /* ignore */ } + } + es.onerror = () => { + es.close() + sseRef.current = null + } + } catch { /* ignore */ } + } + + const loadSchemas = async () => { + try { + const data = await listSchemas() + setSchemas(data) + } catch { /* ignore */ } + } + + const loadConfig = async (id) => { + try { + const [schemaResp, dataResp] = await Promise.all([ + getSchema(id), + readConfig(id), + ]) + setSchema(schemaResp.schema) + setUiSchema(buildUiSchema(schemaResp.schema)) + setFormData(dataResp.data) + } catch (e) { + setMessage(e.message || 'Error loading config') + } + } + + const saveConfig = async () => { + if (!currentSchemaId) return + setSaving(true) + setMessage('') + try { + await writeConfig(currentSchemaId, formData) + setMessage('Saved successfully') + } catch (e) { + setMessage(e.message || 'Error saving configuration') + } finally { + setSaving(false) + } + } + + const loadEvents = async () => { + setEventsLoading(true) + try { + const data = await getEvents(5) + if (data?.success) setEvents(data.events || []) + } finally { + setEventsLoading(false) + } + } + + useEffect(() => { + loadStatusOnce() + subscribeSSE() + loadSchemas() + loadEvents() + return () => { if (sseRef.current) sseRef.current.close() } + }, []) + + useEffect(() => { + if (currentSchemaId) loadConfig(currentSchemaId) + }, [currentSchemaId]) + + return ( +
+
+

PLC S7-31x Streamer & Logger

+
Unified dashboard: status, config and events
+
+ + {statusError &&
{statusError}
} + {status && } + +
+ +
+ + {accordionOpen && ( +
+
+
+
🧩 Schema:
+
+ {['plc', 'datasets', 'plots'].map(id => ( + + ))} +
+
+ + + +
+
+ + {message &&
{message}
} + + {schema && ( +
{ setFormData(e.formData); saveConfig() }} + onChange={({ formData }) => setFormData(formData)} + widgets={widgets} + uiSchema={uiSchema} + > +
+ + )} +
+
+ )} + +
+

📋 Recent Events

+
+ + Open Events +
+
+ +
+ + + + + + + + + + {events.map((ev, idx) => ( + + + + + + ))} + {events.length === 0 && ( + + )} + +
TimeLevelMessage
{ev.timestamp || '-'}{ev.level || ev.type || 'INFO'} +
{ev.message || ev.event || '-'}
+ {ev.details &&
{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}
} +
No events
+
+
+ ) +} + + diff --git a/frontend/src/pages/Events.jsx b/frontend/src/pages/Events.jsx new file mode 100644 index 0000000..1dec762 --- /dev/null +++ b/frontend/src/pages/Events.jsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react' +import { getEvents } from '../services/api.js' + +export default function EventsPage() { + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const load = async () => { + setLoading(true) + setError('') + try { + const data = await getEvents(100) + if (data && data.success) { + setEvents(data.events || []) + } else { + setError(data?.error || 'Unexpected response') + } + } catch (e) { + setError(e.message || 'Error fetching events') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + return ( +
+

Events

+
+ + /api/events +
+ + {error &&
{error}
} + {loading && !error &&
Cargando eventos...
} + + {!loading && !error && ( +
+ + + + + + + + + + {events.map((ev, idx) => ( + + + + + + ))} + +
TimeLevelMessage
{ev.timestamp || '-'}{ev.level || ev.type || 'INFO'} +
{ev.message || ev.event || '-'}
+ {ev.details &&
{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}
} +
+
+ )} +
+ ) +} + + diff --git a/frontend/src/pages/Plots.jsx b/frontend/src/pages/Plots.jsx new file mode 100644 index 0000000..9179ad9 --- /dev/null +++ b/frontend/src/pages/Plots.jsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function PlotsPage() { + return ( +
+

Real-Time Plotting

+

This page will contain the real-time plots.

+
+ ); +} + + diff --git a/frontend/src/pages/Status.jsx b/frontend/src/pages/Status.jsx new file mode 100644 index 0000000..cf88355 --- /dev/null +++ b/frontend/src/pages/Status.jsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react' +import { getStatus } from '../services/api.js' + +function StatusItem({ label, value }) { + return ( +
+
+
+
{label}
+
{String(value)}
+
+
+
+ ) +} + +export default function StatusPage() { + const [status, setStatus] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + + const load = async () => { + setLoading(true) + setError('') + try { + const data = await getStatus() + setStatus(data) + } catch (e) { + setError(e.message || 'Error fetching status') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + return ( +
+

Status

+ +
+ + /api/status +
+ + {error && ( +
{error}
+ )} + + {!error && loading && ( +
Cargando estado...
+ )} + + {status && ( +
+ {Object.entries(status).map(([k, v]) => ( + + ))} +
+ )} +
+ ) +} + + diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..9a2fe70 --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,91 @@ +const BASE_URL = '' // same origin (Flask serves Vite build) + +function toJsonOrThrow(res) { + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + return res.json() +} + +export async function getStatus() { + const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function getEvents(limit = 50) { + const res = await fetch(`${BASE_URL}/api/events?limit=${encodeURIComponent(limit)}`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +// PLC control +export async function connectPlc() { + const res = await fetch(`${BASE_URL}/api/plc/connect`, { method: 'POST', headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function disconnectPlc() { + const res = await fetch(`${BASE_URL}/api/plc/disconnect`, { method: 'POST', headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +// UDP streaming control +export async function startUdpStreaming() { + const res = await fetch(`${BASE_URL}/api/udp/streaming/start`, { method: 'POST', headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function stopUdpStreaming() { + const res = await fetch(`${BASE_URL}/api/udp/streaming/stop`, { method: 'POST', headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +// Config schemas and data +export async function listSchemas() { + const res = await fetch(`${BASE_URL}/api/config/schemas`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function getSchema(schemaId) { + const res = await fetch(`${BASE_URL}/api/config/schema/${encodeURIComponent(schemaId)}`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function readConfig(configId) { + const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function writeConfig(configId, data) { + const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(data ?? {}), + }) + return toJsonOrThrow(res) +} + +// Helpers for future modules +export async function getJson(path) { + const res = await fetch(`${BASE_URL}${path}`, { headers: { 'Accept': 'application/json' } }) + return toJsonOrThrow(res) +} + +export async function postJson(path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(body ?? {}), + }) + return toJsonOrThrow(res) +} + +export async function putJson(path, body) { + const res = await fetch(`${BASE_URL}${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(body ?? {}), + }) + return toJsonOrThrow(res) +} + + diff --git a/main.py b/main.py index 0b03181..21aa73f 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,11 @@ from flask import ( Flask, - render_template, request, jsonify, send_from_directory, Response, ) from flask_cors import CORS -import snap7 import json import time from datetime import datetime @@ -18,7 +16,15 @@ from core import PLCDataStreamer app = Flask(__name__) CORS( app, - resources={r"/api/*": {"origins": ["http://localhost:5173", "http://127.0.0.1:5173", "*"]}}, + resources={ + r"/api/*": { + "origins": [ + "http://localhost:5173/app", + "http://127.0.0.1:5173/app", + "*", + ] + } + }, ) app.secret_key = "plc_streamer_secret_key" @@ -62,10 +68,11 @@ def serve_static(filename): # ============================== -# Frontend (React SPA and Legacy) +# Frontend (React SPA) # ============================== -@app.route("/app/assets/") + +@app.route("/assets/") def serve_react_assets(filename): """Serve built React assets from Vite (production).""" assets_dir = os.path.join(REACT_DIST_DIR, "assets") @@ -74,31 +81,20 @@ def serve_react_assets(filename): @app.route("/") @app.route("/app") -def serve_react_index(): +@app.route("/app/") +def serve_react_index(path: str = ""): """Serve React SPA index (expects Vite build at frontend/dist).""" index_path = os.path.join(REACT_DIST_DIR, "index.html") + if not os.path.exists(index_path): + return Response( + "React build not found. Run 'cd frontend && npm install && npm run build' first.", + status=500, + mimetype="text/plain", + ) 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 - - current_variables = {} - if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets: - current_variables = streamer.datasets[streamer.current_dataset_id].get( - "variables", {} - ) - - return render_template( - "index.html", - status=streamer.get_status(), - variables=current_variables, - datasets=streamer.datasets, - current_dataset_id=streamer.current_dataset_id, - ) +## Legacy UI removed after migration to React # ============================== @@ -623,7 +619,7 @@ def update_variable(name): current_dataset_id, new_name, area, db, offset, var_type, bit, was_streaming ) - return jsonify({"success": True, "message": f"Variable updated successfully"}) + return jsonify({"success": True, "message": "Variable updated successfully"}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 400 diff --git a/static/icons/record.png b/static/icons/record.png new file mode 100644 index 0000000000000000000000000000000000000000..3f5ce654de5fb8925ca85753e7d5170e22fd0089 GIT binary patch literal 15983 zcmeIZ_g7Qj6E1u}kS5>DQqEzXGUZokTfDq{@Dk8miLFv8sYN3Q6Na$U9 z4?UDX@*cnMFZW-#YuzkZfh1?2oxS(WJoC&m5xUx{l;lk0002;`t3A~R07CFjLV)xp z_;m7S>=Jw;@_wRjND4jzN$o#@|C7B^GxY`ls$c(p36ceZQ$Yf8gA?5 z0KnmJVVJv%x1H@P2VoB{r%d!?CIDau)SoIDzRB8~ho`ZOr|%ywmTZgW=_%%YiX$Zt zRb;p2ROGaa>kdWSB#9EMzF9STvxpL2WxDS_iSZBGJ7s#M_~yG+rmGhf_a|O2eMOlK zNeR}{?KuTAnkT5ilh?*R(6c~=;XKvB=i@R}Y3Rx(?d;=wWZ>lf-{b$<6?p&i7ptQ2 zgtgxqV8F}5-Y27|B#P1qT9v+z9<#65V}=I;fZB{}G)zK2Ghgcw=_6^>T!ouko$9>5 zi|X8WA8s+S?m~gem#0B^2A9DR6)sX#Y4PAs z#g^v_IPAqYpxJ=X_tgnsY9|(oUmP|nb>Xkq&3vuuz#-RCW2xT#H5Xrhp?>W=E&>2P zxxdY@F3dy<_X1avbgMm@52o5 z?A-wVXbMX8UR`%HO1R%@ThgTs6-u=buTRsMXWxnvoh1fxQ`@A3EEo1aGEcl8w|Pm{ z{ShgGPiw%y6I9-ha{~ac7>a)}ZJ6?l*vd%UOj%6w;KxW2T)KFAm73R+PoRbErt`RT zL0;jnLu@KLWs3{HmeK$LYsoUV7^7H@35b+q{*1l7jYd-k(4R(fAW^EYY;fKXD_ zMO4$i5N5l~VC{>pFJbj|K`OfD!;kODJ;VU;024tONsrH;3qxuE6IsER^jh*dJ_-Pk zFgR0H(Y0GUoaFto2?q+N6re3Kg}a~!(rJZ%sA5lsOAPZ4T~QYU-ZX!n@uI_PL& z^rPOvnu*qVpI;o{ve6XHn*?ljFXDWcz64Kgk%oF^<)nHak^n%&SRd2z=umKTACb*C zU(xdhrS9?&0N9?D*{Nz?9+bPbX(H;@=#?INIMPQ1el>a`yz|*Xe@GL!J@v2+a~Ga* zZhio`U8Bw{+;z3m7VNFV0ay+(fuHs1oe7yaHD}WRjC6A35o!i5^#6g|^R|S!vU&Me zcGuxoBKB&GOG7j`jj?wP7!hBbzbaw%;h+QwlMAwx?G`wVsD_Oy+)*ml9q*+|sLOjt zUck=3K2;PPhFNIK0)4e-y{iVe-nO`-^B4efxyw83u^~+AW`5fx%38MtQw{aj6f_8K z0J`*nc6)!{c3toFqDVsg`QmzCqygT)eq4KzwL8;5Zd8@>xC(P9 z$xt^eFCMNVcs*bAY$%~8p}{j8`rqsVcbje4I|t;QaudBKe9XtSR`Gy6t|Fhf=lEcK zS&l>v^`Hz*1>__3iGg)l&c#$OKhxpa!+-{6sR8$hC5d9~DO7RS;KxMmnX)*I3S)Zn z8}M)$pd(65oR+&kEBK@2>oni%%|Uw!jRO6e_l>F(1C`cQHouvOHB*R2ivo8o?a@Kj9_1eaH0C@BVF z)tE@V{81ffq#&fr3DNEeK*!HFi=kgKB`}IVMSl$+V1;O-JFx?|81=F2?-aiut&y(} z#-A;;k*^OW|6X%uQynDSVyN?hgD-<0-RhJmHzf0 zGB0Ww&My?f@OO&1Zy(4Pf>jsFs!lTW*zE=j9M?{^6eK2sJ2F110ZchE_(W7qUy6^b z95(Zzi8I{K=xTLh3ib!F8hl=4HjdHvO<|oqXN@3wdx!<)ig^v$M zk@D@8w4vEVk@RKf2g*lQeg$v~B5oFL?=AX)V9d-PvhGJcB=>Ze&~3|9dBV~^!v!9m zniRyyYB*MPEK~8^`Tp%0Kkj1q$U0cP(Ig6zrOW8zAtg|x6#%-EGT0}gT+j4~#Qb4P zR34d3bOPW?FKCwkL#U}g2Om85^{La8XnGaCA?bHYkcNjEU|(@0H5do3v8QT+2|BGt zBgZ#mj*PZ6r#V%(F;5wHJY04#l~=nn(Zi~3VkKHusAbLxTXZ(NPw-NAt9}rUv)Zc%GeSrloxuj8G-+AE%d$mCeIm;=1=Y?g6BPvd%F&Iw!Jvu|vN zr9ZNief=bM*xR5o%blBig4J#{ilt!euy5Z)VS#t>kVj25Fu!l3Kw^vZoBF+#R`DS0 zK*-*Bv$8r79L4$2@|3GU=dqr4*sasl-wZO?-Mo_swqyzWS6kk>!Kt(x`U%ZmcJTD5 zlaa*bmJh2EAj-Y3mBnlD z-SX>LE(g%|N+rf{yPsoi_>1KtU0+N(zxVkPUG|K*3c#8C+1dehWE~)cR&ySzZ{!a+ z+(^1u~PZ&0W(tO7HTVcI-+AZ(PC=>pDdiY%3}xG!VEh;f%wE#I@lGJi`qt z@6d@&j9 zdC$Wtll3c#B>7Hl-$``ggaRhFMNA|Lox~@;h=ZgPTqh&!m#K~UPWZ8GJ z2a7}f9iqf@E~4flXjfwL1E_xDpLvk3_(y zP?)nmrc?wWI=G;F)!?#Bb)RLrd@kf%p?22jp?xi5`u4#0Tng@Nqsyq(Xu>!E_|Nkr zrNR30a1ZOvCwBxbnc$@3JEN*-1zh4gH-|ujtt1Phu&2U7yPlv4^sx4Z|GPwk)z7}< z6Z!U+-y3v;Ic#tSsMs|Yd7*&M&((o(@bvklofr$maGX{DT#i{*Rs%vmIS5}GeoH0% z`WUxUrTK*(&Oj#tTGYkRBGOTV_Bu*zz!AJev*f)N6*mF!!140T`10y_h3dw5wb25t z&M($yYZ;kqSMv(IQ?Qf}{=@{&H)w0kAp%4xr#c}prs%(2<3-KD3TG&eI~x!69$gau8=vsT}AK?pCG~QbDku?;I0^5P7wFM&puaxV*XHjK;5GBWPi?w zlsCFZL*B81i^JsA7nR(G$9aao6~B|?XZ8W&9t(;3oK|C3Fe=|Udp5cyNgVRR{@8E& z$$hk_ugxTVZtq-)!J3E#1Ds(qYNO;JT%lO{c)FRR1avfmYa#dBkWJBIUIvKt&YVuBQscOekjll#F3t@B>*V>LI|A9-NY2d*Z;v~JUs&O! zY(Gc=UAw{-Q*=JZYRCDLxvz=|dz*3iOP3?u)kZ5@y4YwTl4>!CmLH8v{2p+c+^l?l zn+D+2ueOf>gO;Y-o*bkmL;?0o9`*M-%Bn(t6rDU26}6g#^JP)(6?@nv0iGdFve0~+ z3oqhGdc;Kak1vOT(=k-jr#p0lmi!@y5BNx(CqnFRQYOgkt+Y{IYwGhSmm=Fj+C1}i zOQA=`RS7c?d&rCf1#$X|tjC^bU z`M>GK;AdP0uGc3M_KhcXH6sT{RFNz`QM-RZnBo`}?-_+EdHoxmkadGvkdCMXvuM&s z4xg)B&WqLX)kzgR`6T9rTjrnE7Czc~edL6jwb1_F?sv2?q$djh@@@@je|+(I<{!9o zT2*VY>qE$FvbSK-0zK^~HE$M!YA{_qoPb)^)n3yFZ(CQF#GotB={~m9-q=)`V?o&Y z!8}#n#K5JIpMr>iq(;%GM!~lrm$X9e7LuqG1rPW9YtCc^c+BV6$24$;=oLG*MAq63 zPN`%Kz7krzn`(Hwv32~}4+`@2-xO|~z3y|oAuB2u9vxij;zB{m8Pa;_RfHF{Z1FlG z6hC1Od7(}NZr11DYxa|slwC{k#pG-`bH|z2u*;I{yDQaiq4%xh_ObE%@#q+~MHkvo z8+3cK@=KPW%l@oZY(m%GH*H6OrQs0kg_T%j$O`533$Y`d>#Px-lYH6opp#~Rch;+K z`aEAu5=#Spj~&-Mu5$XZB~Op682^))F3#Zw3>HyDWg`BS5w1l%ffDf9;aHtqcUfDrpHHM2EyLj*& zXLkh6?<&<7KTCg(U1t^#xl-mM+x({BQsKoq!bLE2@180kXRER&ptTtodyzkPjisP3 zeRKR9F{6t(HVnit(pkT;Dp`p!STCxEGr&4(5d?dKA^w&j?42b# z!d(a1f3;Hye^i^?aJ!Bwx=K_&Xhv9PvzPyV0|P8YRWFP01IQZ zb)cRS54NoD(ecA%{mYZl>~RZ$1{waYP2rbT=VYM>BLxNG z4js6+aU*?GX@Sj}#u^U00mAO_`o-euCrY^X7D^fY)VS08a&{jn6OTJWwoaBkp6_Y4 zq3{0u``!7nS1MWdVs*xP?2m+ld@b44LF(I#_09E?TqR02;k`EN)bRYdnT6Sl<3(e1 zAAaN&Jzm8aP8OOjs_+Yt^VFV7+;zfI`?% z=2=B{npDsf?Z)4*@sr7rpK%`WnW3aFk5+4`A}{|d#`iKGeR=Z$C9lhj z&;p|3FP5RT$zz8)m{aS7B>Cg#G9+v)a0Y|{QWc2d@V-oUV9n4j;_!R?pQ{O*f96Zw zaBbe5YF=7kcI4|78?H%M;K~*rp#==8>LjQ{e}+VDGUOE^g++GIb;czQgs)J2`TudW z$_;vEt6>P@@@@8J)MlQAF-lk0AW9#sQM&9`R*|<Wp&BRkyd=r{k5Ym7^yU{a6ah&R-1VskYsk_QFxb** zt$fm2<|oMT9x7>#?w&AC*^Xl2gD*e5sOq>2=RK5A89G=3NfYX(`I;5`A=$E72g6z5y?QwOqc zu|BKeSLa^{vF6-+V1>{CdSen*b`5+alvt?@9EPL~SgX1gT*BYOQ)G5r*Md)x+jwiq zhX@uKQ`vO}*nTAswk1YDYGuNmmTuTUG}UKp2&8;or0p_;&}+YkepT-J^%2rLHQ=o3 zB{wkRv(t?A(2dXl2BufPu{K1#A&O3p5YCuj$PMCg-7rH3tg@oer1QsO-KZ8F|f0~*5%bQb#b_1 zetcbBmi$E)>PG{pCqNusm33lHto-@Vx7iW(C(!k|<0M(HTCYhf4)gV)xQFAvH=+6E z$!Ax`ho`Y>TuwB9;}ey$NjHcQgX?X}+Okgy6Mm=`3pAP};b+P~Jf1C`r&4mkhSx(+ zZF94S4|&>$Q}JP6x%}ubpmrSAcGQ2b%gIe3nixac(9vDhy=WN+u;9RN@ zzSSg(sI-*Mf#5wCFU+h6T324Q8p;jDt=rlv5FRY}LB=p~f(SL>@>HPgfw>M*u&yIon){!Um{TPM0KFRO2S)_VnTXh2yEl3i1?T$|@yJZE>2= zdxG9uWFlYd{M%k!o~6=yPsDECVe{uj-`2ee|MA}5b>g9oqN^pz{;(4!Buw)up^b`4 z9hLM!SZg`i-xHf@>h18ZS6{)%9tF9T#OFQKQWM*o9(*0}IxsC(E{sm#i38iL6*yc~ zMn_PnlhdrM_~|ozfv?Te4qP`v5JHHjKMy205hWn?CQ`iElzRE3{e=$Ts$>0^j(?Tn z)PUZm=<>AK1DEMhdOkP`o)Zm54v9Cf0|A0j<$3 zSGOJ)Hyt35A_UpsBmA*BH*Q>Jbd1G%L+&cDe&S;S(bN;A(w}sOo9XAYp-h#vhmWg- zU2f#!z?Uzquk{-%gwVQwknd%vfoLHd5xeoMZQ+ZrY3DP*t&VXS7wGxC7MS%$L1^B0 zufLdU`ueU#bD}DksM}sVffm0;F!C8MWw8>O-C%TKAwPXgFvZhZNr!F^+Y4s?&;Sy; zy^|rt94TMt{xWbRItc6MMJf<{vNe_jk!_1b@iU1Z_Dx{2N@Q64YG00D{zwfx-<;n| zp#>XDo+X?Fq&xosWQsq*YfQR?Q{3HblK0it)f8`Uu#)f_wc~^7_S;0zqPM@XqvsE1 zW&;8>OdwxnZ4!HrYLy`ji z+QhE-HJ$ojXbu1ppT_ey6ToFtOt7q#UcfbR5qx<6yp!VYU@U|v={pFyu5@sTWFeHL z2SpIOswJC89+jJq6^d~md#3~TOJ@~pxgHSw!5%NiDC)yDx-Eo@x?}g_L)~UeZBDnR zE>-=mnill?&bo&6yKb&^T@=2X29tkf-ynz6^Pg!Xf1?;#vA+GEjE1+Lt2ivQ>lbE+ z(wqM!8Mu%sK=Bof!qS=gR=cDim^P}{LwCvf=T3S2bRK`-FHr(>;pDEv-sYdYrp6bX zTiw*Fh5U=K@%ocw(EYyaIgI)9Ko)c!jjr)2BB_{Z9<-Y8!RIf5!^ZsC`uy1mC3=V_ z_|r*e6t({ln53!AL!GWHKX9>%pCFF&>~EMlz+@s0 zdQyJ}qi;08R@n88_HR5=Yy4}4tIhw(KRkT=FUnbBi`A;V3doxl9)KkGG8OTEwQlCg zUFcNu6d{ndq%Jo#C?)>$Q|oQMKDbR4&UYkyHi_uif}59*<6R@vfn9+*`=P6+50E-$ zE^BmhVB!Eq10PGsW`rUDu>Sq01!$>cHWR;n{`T>rLA6)1>#Yy*)lRW2EWW-h`@GQS$1Mg{}{aTr-tMNne|{vOn(U)=^#UgD zT`-udc;MukP4`wa?nYidz$l#$(-h8 zFP`HxlXe|a5hwc~tw{MhCpBt2=k!PCnLBhN;KMeLw4=PI(F0@HS1-hsoBIssEXBuP z(GBFmer|KNoL?G@lx`2*Em{)|yIHce6T&rGq@#7Y6YVO-h;<)}=BEV|4$2nTxG^bh(#4@d8*QoZOp^%O)--t zFTNHB3U|%+K7Y%`n=@Qy5~lie=q(9jH8Sg>Sp&yCrVEKmkP`fL6x20K5m=*pYb~Y_0>;ARi?N~y`y`t&L3`t+ zv!p9>E81zfC-pFBQ`k9INTBz$_%!1r(!wu!)u^fF8BN;nOuDO$SX1rM4_*rSpL@PV z*(H5`lO}&Xl4w6!Rm3g3+~D6(?`jGSC_H~FvRD?Jm^;|A!Dmvf3ucYb27}n=AZ6Ac z@1+tI2XSpKx68*+m6#-T+1I@LK;ayel*Cr&x`U ztCffj&x#x{q^$o=?pUYS5Ac#Gl@mK&=R1#O^E?y@6Do5*65cQ=3sjZAi@V;^$$u8P9J78yiCym0X*vYkS^vs*ZsbH(Nw<{twjYgRgW|6(1dlR| zd+`J#p$Ghs~R*m)a!D`u{SUHun(b zGM()@y>vmP{6-}kBl7)&rL6nu?W5wEJ;h=`FNbiS5#0Z<0lK28Az@R&j+1YLdaB_l zEBPGbMm~X}s25UlOHoV(g&x5MILLnS!Jjbl^1`M183vw= zOg(vdMPy_A;a$BeY%5kiP^qcyDw?6b0nvFhfL_c|u)e(BFVK{?HsY$Zq&p&ZsGHW^ zclWi6s!g?c(B>0))wCE>nRC-dyrFBw+2m|dL@VpgK;F=vCM+|fOptDl3@js$1@_+u zacURa++Yw2_b~el%ymT2!Gmcsom?@=;=i|Az{5`~5EC^>&IUF!#QI0zPJS8g_NC zDBa(&EMgClUG2hUfKVk#A;4Gim`IGIdK;_?)UE{h zj&e^!%T}wbxEdOe>#|@~F5QjpmYXbBBBFmwK@rTk9seq(cbugl>>tER-O-CtNqI{I zoc5V*HJTT!ucIt=sNJgWP8ohLefMR`N=Wo95ih94-{YS3AIA%}n++H3SCdxBXRA42 zb?>%|Ak?%?1yc!e5N;N&=f3V<6chVXj8Wcj>;}>aZmhDrvH6XyOlL?O?&T$VzraIk zk3Ty@y&I6PzR{IL^fcP}EyAhRv>--aMQiawyX3h`^ki80cFTsnb-=?h?i&iaog9}( zGaU&VJ(r|W;3UgTlCJk|!A`e7YgfS<)5?~p3Y$FF(h3dOU59dv8TfzK^ zOawu+g)k3u9MOA(b*tnAP94x)oSCr?@pCJ&QYQXvC05Tm(wdO**fd>-Cl4pp8n40U zjo=!v2=v??ekp)j5DvJmQqD1PyixWqozii9*5z+6!1Dc;CTnNwTT9m&!^`fn1DOEO zTNsC_zucl#C_O1DQdyw>5igG;au6`_${!#WHh4dM)gZ4S1GcDDB%K1T=*wvZ=twuX)q$XcDp_tZyw0`)!wzV#!3 z@{TLMk(*#p1O7irxUap#EmLg|-!sMtUtnkKyi^SP1jJ@;{m4ubKKAXwA& z#s{IwmbA&|hK?VvdHWHQ&9CxowL5(&fzkbvyARIAZ(~otp_|;q1-i-mRDJ{@DJ9?? zEDfF2y-|{c+Q?#t-$|}9!mKN(9WTb_U+g8`a1o0wjGw~z(%p*)SrMfHBw4U?&$mWY z`60#yPFZ@?ZiU=BDIgk+$Gknr=hjO2pTa_qaX7I9Cxu$>8L9WsJbvkX*a^GS1KCe% zbd7-A0tey7{=I=3bP|BT%^SMa6b7U8w)5DcD}&x684BbvH6Zr3kQc$0?fAOByZfP= zI%%iB#ANAl*20f~EiSqoKUuUWXFQPmc3NTHbVjk5aJGzCh0o{Le`7a`IY35QeCvyc zYBobl)~-lZVv1lyIfYBb=MUe!SOw|lXhCL_E*Rg|{BsI4gJ^-JaYC)d;8RFTIXzea z86@zzWUy(&_)fco-0x5jLAf^$Q`#i_C23znCU>nfO*0v?3(=DSOy)HvX7`%G8dH&~ zrO+Qr*x948k06$qyKh*f8wVmj@aVAakS(4igENm4IhlFNesE)L0wfVY$ww2Xi`~DK z?6jy%BI2wz%Vm`0P4NAX5`GZZtV|ldp)Q4sJ$WPPL+HCdgk}G4lPmV-RkyKxsJ38X3Hy(I zVtSRH8FWZIor5>l649UCn0#FiFE$aJxIR(>;pq!l5=SaBnf$YmIX4!MllTvLElaCG zXv2IMQzKPpdOUW4phHRY@{MIQ|+DTL53IfHln; zQ-TBYF2<)CpKS9pl3po(2>g2TCa-$*ak#M~0(TYyeY|WUuoMN1jfmV@tI7O1uU)?z zJXsmtnMeess24E)h|+#GQ4v|SFS}M%;<~fd`e#8HvLaOrU$}=P&1F01zeB$La-Y{t z+E7r%I-@PTya5D7ALd9r{TH;=HE;JX_^HB9^;pN0)jpue$rM-o@IkP#&WHPtibrr$ z|3}=Ia~mhj%*eDawdi9jH-nB}Kuv4Do$OBftVDhtn(7&;_^LzXQs9)6oDDHvucO!G zQ0<{n`AY)freLzOXW^LiS(B}da)Z{a?myY$U=6Ss9-aq70 zXQa1b?lu+khdkA9U2W)yW`!|p3$sJnWq+x=QXlcSq+7%F6COj;NvE2*qwdR6&?l`E z)Yp%dBW-%fiIFMeDs9k%6X)xaDYSxNA zuj*jJ?w1w5Q}kblO@qnMkgU1ma(*+%jO|9gjZP!aT5sAs4WWzM)(>~hJU=L6!(qSK zGUL?CXYbcfI44wLw#;NzOfl~;Xu1ezF)AGS5Ur!nJ5ES(G0=E&V*RG(+wqfARAjAr zB1BV0tQ>k6!2`=1*Lt0<(%euKKaF8i*gbg-7D0MeShA(7&dD3(-|)k}uWDx5s{z~iB{Fe9-*L(3wQ+@-!c)Z@83 zi>kKw4JhIXqSisYq5}2l$oc`F6jz^6$Ojk*u~+4;UU7|l5XebgABsoUtU0H)Z$VxKOjL!Xvjrf>G)GSd_z*u8g zfv4`(*D;ha(_?A$r*Ab5k*|m6YeD%zPu_d=RTFF{_V*1cY0KWIs@4YfxX~(WQrr8U zphlKa!D@T5tNf?sq!5IF)N$Bo=_7MOU4s3N5p3iK>U|M;O5a7N$fS=$mYmKrVfj-h zy8V`-8Q!0h6WkSMToN$-Y4ba0ABl3$|Az6)>t*_1__)l=ZY7(FS9S95&^g?sj_?^* zGBs!& zgx?{&*N};FSUUyYcVfZCNGH=bJ=yX7$QLlDID-2)(y(iPd$kXw8B1?dCPLO@&rDlmvjxhDk zDC%^0C;9&r5npQ-UURNjm+WFb)Q|r+#+;MFLOIFUaI)FP2LLL99?vuqN zlwrf3NlaqjcJDtO8Z1Z!EpC2gcr?8pl|3herC2X}v*UI|KNu_i>!7)E5LBDW z`+7Ce|FUH4ntM3FM`1Ef!A~&FE07=sDGS2eME|6t|C3bT(c#7W& z>6Xua>h0jSXQ;OQfk$3`8^R1*oOS;+`0K|PKHHwx`EuSQlnJ9h zsWry#_r=R>^UCn(B)Yj>|7pit{;Ln)2M@7f;5v-hdwooPLR9jhdSa29>ODRQFT+&z zO1?YxDMHYL1La!pjS10y(vuyLjVy0_s_a~w)ci_48UR$~T$$Dx243AC#jolHj^>+2m+oD~_5 z_9)fEKcHOs3r-dS!clj&pL*A2am2()_?OpD0wo<WOQL`>IGvC*gg;tP|_>wA09(REaAkT#yF6ezv|+Vnk`|TAz`k6vG!vp z+p?aLaPIQ2UCNK&b(nE3M4cDocS`wbLa6K#hGx+IZjpFtv4x;MFN#(VSbfi~y?G z!nkC@D7~;yt&ITedGaql{m3leS;V&ch_E`_Y0#Lf;o02;&Wq0Whb|g<1i>$pKY?99 zV@nLjYeT_PFhbGg5mkEgt;M{Et)K5=sQd(sQ$F8}Ck4Bj9&%jIT1hw#?(c47gQEX> z_rKlvY7}xQyn#e1*9Li)&bXX+oRash3rYy=v+PSV4RD*k%oC>Gs*Hy6?LtpQ+b%!tuq8bTRt6`CJcn*TTSWs-qBEV|`kRx`gHiz;_! zG72WMRiC7-St(!_Wj}D~1a1wp1msHVh=nf1}Dzt&X?)wb{Rj|E}``CVmh0h?=C>+5u3vC_+Vt!3>&*Z!|}+U>W; z)}L!4l@qj!Ii zLfkd=7NW;`dx^B$S5rsEL3buCfQ!`taRD~+_;Y73u;l9}eq8h(es?d&99C4OBU@6X zRp6fZ+N{70073_((+6pq3f8J8%su7}M=p|mADWhOM2*Trgo0PZYTC0WkH81)3p1AIuE6XrTN{X(9E};N&lmz=lP=2CrTlu}k1AUS*KT21#*IJe zwy}@BiWcV{TcH#*2UJJEUMNarT6@$*>W_&EtcvGh>H{W%sp$a&9l36z?@e5m+i_a?V6cF=BK6@F0< z!xlzeL{Yvoa2Tvf$XJd7dkQ}_FuSnT_0H|w{2tWMI=h%?0@k^Nq_^@5xhJ!?nY?S0 z3)N(GQNb_XgU7$iNYj5@O$3>ieo
-

New UI available: La nueva interfaz React está disponible en /app. Esta página permanecerá como modo legado temporalmente.

+

Notice: Esta UI ha sido reemplazada por la SPA React. Accede a /app. + Esta página no se usa en producción.