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 0000000..3f5ce65 Binary files /dev/null and b/static/icons/record.png differ diff --git a/templates/index.html b/templates/index.html index 17db47a..3a19bc5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,7 +26,8 @@
-

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.