Actualización del archivo .gitignore para ignorar archivos generados automáticamente por React (Vite) en el frontend. Se añadieron múltiples entradas en application_events.json para registrar eventos de inicio de aplicación y errores de conexión al PLC. Se realizaron cambios en main.py para ajustar las rutas y mejorar la gestión de errores, además de eliminar la interfaz de usuario heredada. Se actualizaron las dependencias en package.json y se implementó un enrutador en App.jsx para la nueva SPA de React. Se modificó index.html para reflejar la transición a la nueva interfaz.
This commit is contained in:
parent
5581e26d10
commit
593487e52f
|
@ -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.
|
||||
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<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}} />
|
||||
<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>
|
||||
<p className="text-muted mb-0">React base ready. We will migrate views incrementally.</p>
|
||||
<a className="btn btn-sm btn-outline-secondary mt-2" href="/legacy">Go to legacy mode</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.
|
||||
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>
|
||||
|
@ -28,5 +34,45 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
return (
|
||||
<nav className="navbar navbar-expand-lg bg-light border-bottom">
|
||||
<div className="container">
|
||||
<Link to="/" className="navbar-brand d-flex align-items-center gap-2">
|
||||
<img src="/images/SIDEL.png" alt="SIDEL" style={{ height: 24 }} />
|
||||
<span className="fw-semibold">PLC Streamer</span>
|
||||
</Link>
|
||||
<div className="d-flex gap-2">
|
||||
<Link to="/status" className="btn btn-sm btn-outline-primary">Status</Link>
|
||||
<Link to="/events" className="btn btn-sm btn-outline-primary">Events</Link>
|
||||
<Link to="/config" className="btn btn-sm btn-outline-primary">Config</Link>
|
||||
<Link to="/plots" className="btn btn-sm btn-outline-primary">Plots</Link>
|
||||
{/* Future: Datasets, Plotting, Config */}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [showPLCModal, setShowPLCModal] = React.useState(false)
|
||||
return (
|
||||
<>
|
||||
<NavBar />
|
||||
<div className="container mt-2 mb-3">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setShowPLCModal(true)}>⚙️ PLC Config</button>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/status" element={<StatusPage />} />
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/plots" element={<PlotsPage />} />
|
||||
</Routes>
|
||||
<PLCConfigModal show={showPLCModal} onClose={() => setShowPLCModal(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<Modal show={show} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>PLC Configuration</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{msg && <div className="alert alert-info py-2">{msg}</div>}
|
||||
{schema && (
|
||||
<Form
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={({ formData }) => setFormData(formData)}
|
||||
widgets={widgets}
|
||||
uiSchema={uiSchema}
|
||||
>
|
||||
<Button type="submit" disabled={saving}>💾 Save</Button>
|
||||
</Form>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 }) => (
|
||||
<BSForm.Group className="mb-3">
|
||||
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
|
||||
<BSForm.Control
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autofocus}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={() => onBlur && onBlur(id, value)}
|
||||
onFocus={() => onFocus && onFocus(id, value)}
|
||||
/>
|
||||
</BSForm.Group>
|
||||
);
|
||||
|
||||
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
|
||||
<BSForm.Group className="mb-3">
|
||||
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
|
||||
<BSForm.Control
|
||||
type="number"
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autofocus}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
|
||||
onBlur={() => onBlur && onBlur(id, value)}
|
||||
onFocus={() => onFocus && onFocus(id, value)}
|
||||
/>
|
||||
</BSForm.Group>
|
||||
);
|
||||
|
||||
export const TextareaWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
|
||||
<BSForm.Group className="mb-3">
|
||||
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
|
||||
<BSForm.Control
|
||||
as="textarea"
|
||||
rows={options?.rows || 3}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autofocus}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={() => onBlur && onBlur(id, value)}
|
||||
onFocus={() => onFocus && onFocus(id, value)}
|
||||
/>
|
||||
</BSForm.Group>
|
||||
);
|
||||
|
||||
export const SelectWidget = ({ id, required, readonly, disabled, label, value, onChange, options, schema }) => {
|
||||
const enumOptions = options?.enumOptions || [];
|
||||
return (
|
||||
<BSForm.Group className="mb-3">
|
||||
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
|
||||
<BSForm.Select
|
||||
id={id}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{enumOptions.map((opt) => (
|
||||
<option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</BSForm.Select>
|
||||
</BSForm.Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange }) => (
|
||||
<BSForm.Group className="mb-3">
|
||||
<BSForm.Check
|
||||
type="checkbox"
|
||||
id={id}
|
||||
label={label}
|
||||
checked={!!value}
|
||||
required={required}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
</BSForm.Group>
|
||||
);
|
||||
|
||||
// Map keys must match RJSF default widget names to override them automatically by type
|
||||
export const widgets = {
|
||||
TextWidget,
|
||||
UpDownWidget,
|
||||
SelectWidget,
|
||||
CheckboxWidget,
|
||||
TextareaWidget,
|
||||
};
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<BrowserRouter basename="/app">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<Container className="py-3">
|
||||
<Row className="mb-3 align-items-center">
|
||||
<Col md="auto"><h2 className="h4 mb-0">Config Editor</h2></Col>
|
||||
<Col md="auto">
|
||||
<DropdownButton title={`Schema: ${currentId}`} size="sm" variant="outline-primary">
|
||||
{available.map(id => (
|
||||
<Dropdown.Item key={id} active={id === currentId} onClick={() => setCurrentId(id)}>
|
||||
{id}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
</Col>
|
||||
<Col className="d-flex justify-content-end">
|
||||
<ButtonGroup size="sm">
|
||||
<Button variant="outline-secondary" as="label">
|
||||
⬆️ Import
|
||||
<input type="file" accept="application/json" onChange={handleFileImport} hidden />
|
||||
</Button>
|
||||
<Button variant="outline-secondary" onClick={handleExport}>⬇️ Export</Button>
|
||||
</ButtonGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{message && (
|
||||
<div className="alert alert-info py-2">{message}</div>
|
||||
)}
|
||||
|
||||
{schema && (
|
||||
<Form
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onSubmit={handleSave}
|
||||
onChange={({ formData }) => setFormData(formData)}
|
||||
widgets={widgets}
|
||||
uiSchema={uiSchema}
|
||||
>
|
||||
<div className="d-flex gap-2">
|
||||
<Button type="submit" disabled={loading}>💾 Save</Button>
|
||||
<Button variant="outline-secondary" type="button" onClick={() => load(currentId)} disabled={loading}>🔄 Reload</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-md-4">
|
||||
<div className={`card h-100 ${plcConnected ? 'border-success' : 'border-danger'}`}>
|
||||
<div className="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div className="fw-semibold">🔌 PLC: {plcConnected ? 'Connected' : 'Disconnected'}</div>
|
||||
{status?.plc_reconnection?.enabled && (
|
||||
<div className="small text-muted">
|
||||
🔄 Auto-reconnection: {status?.plc_reconnection?.active ? 'reconnecting…' : 'enabled'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{plcConnected ? (
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={disconnectPlc}>❌ Disconnect</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-outline-primary" onClick={connectPlc}>🔗 Connect</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className={`card h-100 ${streaming ? 'border-success' : 'border-secondary'}`}>
|
||||
<div className="card-body d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div className="fw-semibold">📡 UDP Streaming: {streaming ? 'Active' : 'Inactive'}</div>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
{streaming ? (
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={stopUdpStreaming}>⏹️ Stop</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-outline-primary" onClick={startUdpStreaming}>▶️ Start</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className={`card h-100 ${csvRecording ? 'border-success' : 'border-secondary'}`}>
|
||||
<div className="card-body">
|
||||
<div className="fw-semibold">💾 CSV: {csvRecording ? 'Recording' : 'Inactive'}</div>
|
||||
{status?.disk_space_info && (
|
||||
<div className="small text-muted mt-1">
|
||||
💽 {status.disk_space_info.free_space} free · ⏱️ ~{status.disk_space_info.recording_time_left}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container py-3">
|
||||
<header className="mb-3">
|
||||
<h1 className="h4 mb-1">PLC S7-31x Streamer & Logger</h1>
|
||||
<div className="text-muted small">Unified dashboard: status, config and events</div>
|
||||
</header>
|
||||
|
||||
{statusError && <div className="alert alert-danger py-2">{statusError}</div>}
|
||||
{status && <StatusBar status={status} />}
|
||||
|
||||
<div className="mb-3">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setAccordionOpen(o => !o)}
|
||||
aria-expanded={accordionOpen}
|
||||
>
|
||||
{accordionOpen ? '▾' : '▸'} Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accordionOpen && (
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||
<div className="fw-semibold">🧩 Schema:</div>
|
||||
<div className="btn-group btn-group-sm" role="group">
|
||||
{['plc', 'datasets', 'plots'].map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`btn btn-outline-primary ${currentSchemaId === id ? 'active' : ''}`}
|
||||
onClick={() => setCurrentSchemaId(id)}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ms-auto d-flex gap-2">
|
||||
<label className="btn btn-sm btn-outline-secondary mb-0">
|
||||
⬆️ Import
|
||||
<input type="file" accept="application/json" hidden onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const text = await file.text()
|
||||
const json = JSON.parse(text)
|
||||
setFormData(json)
|
||||
setMessage(`Imported ${file.name}`)
|
||||
} catch { setMessage('Invalid JSON file') }
|
||||
}} />
|
||||
</label>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => {
|
||||
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 = `${currentSchemaId}_config.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}>⬇️ Export</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={saveConfig} disabled={saving}>💾 Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && <div className="alert alert-info py-2">{message}</div>}
|
||||
|
||||
{schema && (
|
||||
<Form
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
validator={validator}
|
||||
onSubmit={(e) => { setFormData(e.formData); saveConfig() }}
|
||||
onChange={({ formData }) => setFormData(formData)}
|
||||
widgets={widgets}
|
||||
uiSchema={uiSchema}
|
||||
>
|
||||
<div />
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mb-2 d-flex align-items-center justify-content-between">
|
||||
<h2 className="h6 mb-0">📋 Recent Events</h2>
|
||||
<div className="d-flex gap-2">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={loadEvents} disabled={eventsLoading}>
|
||||
{eventsLoading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
<a className="btn btn-sm btn-outline-primary" href="/app/events">Open Events</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="table-responsive" style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '10%' }}>Time</th>
|
||||
<th style={{ width: '10%' }}>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((ev, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="text-nowrap">{ev.timestamp || '-'}</td>
|
||||
<td><span className="badge text-bg-secondary">{ev.level || ev.type || 'INFO'}</span></td>
|
||||
<td>
|
||||
<div className="fw-semibold">{ev.message || ev.event || '-'}</div>
|
||||
{ev.details && <div className="small text-muted">{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<tr><td colSpan={3} className="text-muted">No events</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="container py-3">
|
||||
<h2 className="h4 mb-3">Events</h2>
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<button className="btn btn-primary btn-sm" onClick={load} disabled={loading}>
|
||||
{loading ? 'Cargando...' : 'Refrescar'}
|
||||
</button>
|
||||
<a className="btn btn-outline-secondary btn-sm" href="/api/events" target="_blank" rel="noreferrer">/api/events</a>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{loading && !error && <div className="alert alert-info">Cargando eventos...</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '10%' }}>Time</th>
|
||||
<th style={{ width: '10%' }}>Level</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((ev, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="text-nowrap">{ev.timestamp || '-'}</td>
|
||||
<td><span className="badge text-bg-secondary">{ev.level || ev.type || 'INFO'}</span></td>
|
||||
<td>
|
||||
<div className="fw-semibold">{ev.message || ev.event || '-'}</div>
|
||||
{ev.details && <div className="small text-muted">{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function PlotsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Real-Time Plotting</h1>
|
||||
<p>This page will contain the real-time plots.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { getStatus } from '../services/api.js'
|
||||
|
||||
function StatusItem({ label, value }) {
|
||||
return (
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="text-muted small">{label}</div>
|
||||
<div className="fw-semibold">{String(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container py-3">
|
||||
<h2 className="h4 mb-3">Status</h2>
|
||||
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<button className="btn btn-primary btn-sm" onClick={load} disabled={loading}>
|
||||
{loading ? 'Cargando...' : 'Refrescar'}
|
||||
</button>
|
||||
<a className="btn btn-outline-secondary btn-sm" href="/api/status" target="_blank" rel="noreferrer">/api/status</a>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && loading && (
|
||||
<div className="alert alert-info">Cargando estado...</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="row">
|
||||
{Object.entries(status).map(([k, v]) => (
|
||||
<StatusItem key={k} label={k} value={typeof v === 'object' ? JSON.stringify(v) : v} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
48
main.py
48
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/<path:filename>")
|
||||
|
||||
@app.route("/assets/<path:filename>")
|
||||
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/<path:path>")
|
||||
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
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -26,7 +26,8 @@
|
|||
|
||||
<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>
|
||||
<p><strong>Notice:</strong> Esta UI ha sido reemplazada por la SPA React. Accede a <a href="/app">/app</a>.
|
||||
Esta página no se usa en producción.</p>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
|
|
Loading…
Reference in New Issue