Actualización de application_events.json para incluir múltiples entradas de eventos de inicio de aplicación, mejorando el registro de inicialización. Se ajustaron las fechas de última actualización y se incrementó el total de entradas. Se realizaron cambios en los esquemas de configuración para mejorar la claridad y se actualizaron las dependencias en package.json para incluir nuevas bibliotecas. Además, se migró la interfaz de usuario a Chakra UI, reemplazando componentes de Bootstrap y mejorando la experiencia del usuario en las páginas de configuración y eventos.

This commit is contained in:
Miguel 2025-08-12 15:08:37 +02:00
parent 1833fff18f
commit 09eccf5c0b
21 changed files with 727 additions and 1527 deletions

File diff suppressed because it is too large Load Diff

View File

@ -9259,8 +9259,92 @@
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T09:43:30.914932",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T09:44:48.333195",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T10:39:12.071678",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T11:47:27.789149",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T12:29:32.566924",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T14:30:28.890776",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T14:35:07.292689",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T14:40:08.698091",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T14:44:03.411647",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T14:50:27.446910",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T15:00:13.141898",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T15:06:11.269817",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
}
],
"last_updated": "2025-08-12T09:13:36.619106",
"total_entries": 862
"last_updated": "2025-08-12T15:06:11.269817",
"total_entries": 874
}

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Esquema para editar plc_datasets.json (múltiples datasets y variables)",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"type": "object",
"additionalProperties": false,
"properties": {
@ -15,14 +15,14 @@
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Nombre legible del dataset",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefijo para archivos CSV",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
@ -103,7 +103,7 @@
},
"streaming_variables": {
"type": "array",
"title": "Streaming Variables",
"title": "Streaming variables",
"items": {
"type": "string"
},
@ -114,8 +114,8 @@
"number",
"null"
],
"title": "Sampling Interval (s)",
"description": "Vacío para usar el intervalo global",
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},

View File

@ -2,10 +2,12 @@
"$id": "plc.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Esquema para editar plc_config.json",
"dependencies": {},
"description": "Schema to edit plc_config.json",
"properties": {
"csv_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"cleanup_interval_hours": {
"default": 24,
@ -15,80 +17,65 @@
},
"last_cleanup": {
"title": "Last Cleanup",
"type": [
"string",
"null"
]
"type": "string"
},
"max_days": {
"default": 30,
"minimum": 1,
"title": "Max Days",
"type": [
"integer",
"null"
]
"type": "integer"
},
"max_hours": {
"default": null,
"minimum": 1,
"title": "Max Hours",
"type": [
"integer",
"null"
]
"type": "integer"
},
"max_size_mb": {
"default": 1000,
"minimum": 1,
"title": "Max Size (MB)",
"type": [
"integer",
"null"
]
"type": "integer"
},
"records_directory": {
"default": "records",
"description": "Directory to save *.csv files",
"title": "Records Directory",
"type": "string"
},
"rotation_enabled": {
"default": true,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
},
"title": "Rotation",
"title": "Rotation Active",
"type": "boolean"
}
},
"required": [
"cleanup_interval_hours",
"records_directory",
"rotation_enabled",
"cleanup_interval_hours"
"rotation_enabled"
],
"title": "CSV Recording",
"type": "object"
},
"plc_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"ip": {
"description": "Dirección IP del PLC (S7-31x)",
"format": "ipv4",
"description": "IP of PLC (S7-31x)",
"pattern": "^.+$",
"title": "PLC IP",
"type": "string"
},
"rack": {
"default": 0,
"description": "Número de rack (0-7)",
"description": "Rack of PLC",
"maximum": 7,
"minimum": 0,
"title": "Rack",
@ -96,7 +83,7 @@
},
"slot": {
"default": 2,
"description": "Número de slot (generalmente 2)",
"description": "Normally 2",
"maximum": 31,
"minimum": 0,
"title": "Slot",
@ -113,7 +100,7 @@
},
"sampling_interval": {
"default": 0.1,
"description": "Intervalo global de muestreo en segundos",
"description": "interval sampling in seconds",
"maximum": 10,
"minimum": 0.01,
"title": "Sampling Interval (s)",
@ -121,11 +108,13 @@
},
"udp_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"host": {
"default": "127.0.0.1",
"description": "Normally this is 127.0.0.1",
"pattern": "^.+$",
"title": "UDP Host",
"title": "UDP Host IP",
"type": "string"
},
"port": {
@ -144,10 +133,10 @@
}
},
"required": [
"csv_config",
"plc_config",
"udp_config",
"sampling_interval",
"csv_config"
"udp_config"
],
"title": "PLC & UDP Configuration",
"type": "object"

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Esquema para editar plot_sessions.json (sesiones de gráfica)",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
@ -14,12 +14,12 @@
"name": {
"type": "string",
"title": "Plot Name",
"description": "Nombre de la sesión de gráfica"
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables a graficar",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
@ -27,8 +27,8 @@
},
"time_window": {
"type": "integer",
"title": "Time Window (s)",
"description": "Ventana temporal en segundos",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
@ -39,7 +39,7 @@
"null"
],
"title": "Y Min",
"description": "Vacío para auto"
"description": "Leave empty for auto"
},
"y_max": {
"type": [
@ -47,7 +47,7 @@
"null"
],
"title": "Y Max",
"description": "Vacío para auto"
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [

View File

@ -9,31 +9,31 @@
"ui:placeholder": "temp"
},
"sampling_interval": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"enabled": {
"ui:widget": "CheckboxWidget"
"ui:widget": "checkbox"
},
"variables": {
"ui:description": "Variables inside this dataset",
"items": {
"area": {
"ui:widget": "SelectWidget"
"ui:widget": "select"
},
"db": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"offset": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"bit": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"type": {
"ui:widget": "SelectWidget"
"ui:widget": "select"
},
"streaming": {
"ui:widget": "CheckboxWidget"
"ui:widget": "checkbox"
}
}
}

View File

@ -1,22 +1,23 @@
{
"csv_config": {
"cleanup_interval_hours": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"last_cleanup": {},
"max_days": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"max_hours": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"max_size_mb": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"records_directory": {
"ui:placeholder": "records"
},
"rotation_enabled": {
"ui:widget": "CheckboxWidget"
"ui:widget": "checkbox"
},
"ui:layout": [
[
@ -30,31 +31,126 @@
},
{
"name": "max_days",
"width": 3
"width": 2
},
{
"name": "max_hours",
"width": 2
},
{
"name": "max_size_mb",
"width": 2
}
],
[
{
"name": "records_directory",
"width": 10
},
{
"name": "rotation_enabled",
"width": 2
}
]
],
"ui:order": [
"cleanup_interval_hours",
"last_cleanup",
"max_days",
"max_hours",
"max_size_mb",
"records_directory",
"rotation_enabled"
]
},
"plc_config": {
"ip": {
"ui:placeholder": "192.168.1.100"
"ui:placeholder": "192.168.1.100",
"ui:column": 6
},
"rack": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown",
"ui:column": 3
},
"slot": {
"ui:widget": "UpDownWidget"
}
"ui:widget": "updown",
"ui:column": 3
},
"ui:layout": [
[
{
"name": "ip",
"width": 6
},
{
"name": "rack",
"width": 3
},
{
"name": "slot",
"width": 3
}
]
],
"ui:order": [
"ip",
"rack",
"slot"
]
},
"sampling_interval": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"udp_config": {
"host": {
"ui:placeholder": "127.0.0.1"
},
"port": {
"ui:widget": "UpDownWidget"
}
}
"ui:widget": "updown"
},
"ui:layout": [
[
{
"name": "host",
"width": 6
},
{
"name": "port",
"width": 6
}
]
],
"ui:order": [
"host",
"port"
]
},
"ui:layout": [
[
{
"name": "plc_config",
"width": 6
},
{
"name": "udp_config",
"width": 6
}
],
[
{
"name": "csv_config",
"width": 10
},
{
"name": "sampling_interval",
"width": 2
}
]
],
"ui:order": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
]
}

View File

@ -2,19 +2,19 @@
"plots": {
"items": {
"time_window": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"y_min": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"y_max": {
"ui:widget": "UpDownWidget"
"ui:widget": "updown"
},
"trigger_enabled": {
"ui:widget": "CheckboxWidget"
"ui:widget": "checkbox"
},
"trigger_on_true": {
"ui:widget": "CheckboxWidget"
"ui:widget": "checkbox"
}
}
}

View File

@ -10,12 +10,13 @@
},
"dependencies": {
"@rjsf/core": "^5.24.12",
"@rjsf/fluent-ui": "^5.24.12",
"@rjsf/chakra-ui": "^5.24.12",
"@rjsf/validator-ajv8": "^5.24.12",
"@fluentui/react": "^8.120.2",
"bootstrap": "^5.3.3",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"framer-motion": "^11.2.12",
"react": "^18.2.0",
"react-bootstrap": "^2.10.4",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.1"
},

View File

@ -1,6 +1,7 @@
import React from 'react'
import recLogo from './assets/logo/record.png'
import { Routes, Route, Link } from 'react-router-dom'
import { Box, Container, Flex, HStack, Select, Button, Heading, Text, useColorMode, useColorModeValue, Stack } from '@chakra-ui/react'
import StatusPage from './pages/Status.jsx'
import EventsPage from './pages/Events.jsx'
import ConfigPage from './pages/Config.jsx'
@ -10,59 +11,102 @@ import DashboardPage from './pages/Dashboard.jsx'
function Home() {
return (
<div className="container py-3">
<header className="mb-3">
<h1 className="h3 d-flex align-items-center gap-2">
<Container py={4}>
<Stack spacing={3}>
<Heading as="h1" size="md" display="flex" alignItems="center" gap={2}>
<img src={recLogo} alt="REC" style={{ height: 28 }} />
PLC S7-31x Streamer & Logger (React)
</h1>
<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>
</Heading>
<Text color={useColorModeValue('gray.600', 'gray.300')}>
React base ready. We will migrate views incrementally.
</Text>
<Button as="a" href="/legacy" size="sm" variant="outline" alignSelf="flex-start">Go to legacy mode</Button>
<Box borderWidth="1px" borderRadius="md" p={3} bg={useColorModeValue('blue.50', 'blue.900')}>
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.
</Box>
<Box>
<Heading as="h2" size="sm" mb={2}>Acciones rápidas</Heading>
<HStack spacing={2} flexWrap="wrap">
<Button as="a" href="/app" colorScheme="blue" size="sm">Reload SPA</Button>
<Button as="a" href="/api/status" target="_blank" rel="noreferrer" variant="outline" size="sm">Ver /api/status</Button>
</HStack>
</Box>
</Stack>
</Container>
)
}
<div className="alert alert-info">
Esta es una vista inicial de React. Probaremos el consumo de APIs y luego migraremos módulos (Status, Datasets/Variables, Plotting, Events, Config Editor) de forma incremental.
</div>
function ColorModeSelector() {
const { colorMode, setColorMode } = useColorMode()
const bg = useColorModeValue('gray.100', 'gray.700')
const [selection, setSelection] = React.useState(() => {
return localStorage.getItem('ui-color-mode-preference') || 'system'
})
<section>
<h2 className="h5">Acciones rápidas</h2>
<div className="d-flex flex-wrap gap-2">
<a className="btn btn-primary" href="/app">Reload SPA</a>
<a className="btn btn-outline-primary" href="/api/status" target="_blank" rel="noreferrer">Ver /api/status</a>
</div>
</section>
</div>
React.useEffect(() => {
if (selection === 'system') {
try { localStorage.removeItem('chakra-ui-color-mode') } catch { /* ignore */ }
const mq = window.matchMedia('(prefers-color-scheme: dark)')
setColorMode(mq.matches ? 'dark' : 'light')
const handler = (e) => setColorMode(e.matches ? 'dark' : 'light')
mq.addEventListener?.('change', handler)
return () => mq.removeEventListener?.('change', handler)
} else {
try { localStorage.setItem('chakra-ui-color-mode', selection) } catch { /* ignore */ }
setColorMode(selection)
}
}, [selection, setColorMode])
return (
<HStack spacing={2}>
<Select
size="sm"
value={selection}
onChange={(e) => { const val = e.target.value; setSelection(val); try { localStorage.setItem('ui-color-mode-preference', val) } catch { /* ignore */ } }}
bg={bg}
width="auto"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</Select>
</HStack>
)
}
function NavBar() {
const navBg = useColorModeValue('gray.100', 'gray.800')
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={recLogo} alt="REC" 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>
<Box borderBottomWidth="1px" bg={navBg}>
<Container>
<Flex h={12} align="center" justify="space-between" gap={3}>
<HStack spacing={2} as={Link} to="/" style={{ textDecoration: 'none' }}>
<img src={recLogo} alt="REC" style={{ height: 24 }} />
<Heading as="span" size="sm">PLC Streamer</Heading>
</HStack>
<HStack spacing={2}>
<Button as={Link} to="/status" size="sm" variant="outline">Status</Button>
<Button as={Link} to="/events" size="sm" variant="outline">Events</Button>
<Button as={Link} to="/config" size="sm" variant="outline">Config</Button>
<Button as={Link} to="/plots" size="sm" variant="outline">Plots</Button>
<ColorModeSelector />
</HStack>
</Flex>
</Container>
</Box>
)
}
function App() {
const [showPLCModal, setShowPLCModal] = React.useState(false)
return (
<>
<Box bg={useColorModeValue('gray.50', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} minH="100vh">
<NavBar />
<div className="container mt-2 mb-3">
<button className="btn btn-sm btn-secondary" onClick={() => setShowPLCModal(true)}> PLC Config</button>
</div>
<Container mt={3} mb={4}>
<Button size="sm" onClick={() => setShowPLCModal(true)} leftIcon={<span></span>}>
PLC Config
</Button>
</Container>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/status" element={<StatusPage />} />
@ -71,7 +115,7 @@ function App() {
<Route path="/plots" element={<PlotsPage />} />
</Routes>
<PLCConfigModal show={showPLCModal} onClose={() => setShowPLCModal(false)} />
</>
</Box>
)
}

View File

@ -1,25 +1,35 @@
import React, { useEffect, useState } from 'react';
import { Modal, Button } from 'react-bootstrap';
import Form from '@rjsf/core';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
Button,
Alert,
AlertIcon,
} from '@chakra-ui/react'
import Form from '@rjsf/chakra-ui';
import validator from '@rjsf/validator-ajv8';
import { getSchema, readConfig, writeConfig } from '../services/api.js';
import { widgets } from './rjsf/widgets.jsx';
// Chakra theme widgets are used by default
const uiSchema = {
'ui:widget': 'TextWidget',
plc_config: {
rack: { 'ui:widget': 'UpDownWidget' },
slot: { 'ui:widget': 'UpDownWidget' },
rack: { 'ui:widget': 'updown' },
slot: { 'ui:widget': 'updown' },
},
udp_config: {
port: { 'ui:widget': 'UpDownWidget' },
port: { 'ui:widget': 'updown' },
},
sampling_interval: { 'ui:widget': 'UpDownWidget' },
sampling_interval: { 'ui:widget': 'updown' },
csv_config: {
max_size_mb: { 'ui:widget': 'UpDownWidget' },
max_days: { 'ui:widget': 'UpDownWidget' },
max_hours: { 'ui:widget': 'UpDownWidget' },
cleanup_interval_hours: { 'ui:widget': 'UpDownWidget' },
max_size_mb: { 'ui:widget': 'updown' },
max_days: { 'ui:widget': 'updown' },
max_hours: { 'ui:widget': 'updown' },
cleanup_interval_hours: { 'ui:widget': 'updown' },
},
};
@ -55,29 +65,34 @@ export default function PLCConfigModal({ show, onClose }) {
};
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 isOpen={show} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>PLC Configuration</ModalHeader>
<ModalCloseButton />
<ModalBody>
{msg && (
<Alert status="info" mb={3} borderRadius="md">
<AlertIcon /> {msg}
</Alert>
)}
{schema && (
<Form
schema={schema}
formData={formData}
validator={validator}
onSubmit={handleSubmit}
onChange={({ formData }) => setFormData(formData)}
uiSchema={uiSchema}
>
<Button type="submit" isDisabled={saving} colorScheme="blue">💾 Save</Button>
</Form>
)}
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { SimpleGrid, Box, Heading, Text, Stack } from '@chakra-ui/react'
// Simple ObjectFieldTemplate supporting ui:layout
// uiSchema example:
// {
// "ui:layout": [
// [ { "name": "fieldA", "width": 6 }, { "name": "fieldB", "width": 6 } ],
// [ { "name": "fieldC", "width": 12 } ]
// ]
// }
export default function LayoutObjectFieldTemplate(props) {
const { TitleField, DescriptionField, title, description, properties = [], uiSchema } = props
const layout = uiSchema && uiSchema['ui:layout']
if (!layout) {
return (
<Stack spacing={3}>
{title && (
TitleField ? (
<TitleField id={`${props.idSchema.$id}__title`} title={title} />
) : (
<Heading as="h5" size="sm">{title}</Heading>
)
)}
{description && (
DescriptionField ? (
<DescriptionField id={`${props.idSchema.$id}__desc`} description={description} />
) : (
<Text color="gray.500">{description}</Text>
)
)}
<Stack spacing={2}>
{properties.map((prop) => (
<Box key={prop.name}>{prop.content}</Box>
))}
</Stack>
</Stack>
)
}
// Map property name to its renderer
const propMap = new Map(properties.map((p) => [p.name, p]))
return (
<Stack spacing={3}>
{title && (
TitleField ? (
<TitleField id={`${props.idSchema.$id}__title`} title={title} />
) : (
<Heading as="h5" size="sm">{title}</Heading>
)
)}
{description && (
DescriptionField ? (
<DescriptionField id={`${props.idSchema.$id}__desc`} description={description} />
) : (
<Text color="gray.500">{description}</Text>
)
)}
{layout.map((row, rowIdx) => (
<SimpleGrid key={rowIdx} columns={12} spacing={3}>
{row.map((cell, cellIdx) => {
const prop = propMap.get(cell.name)
if (!prop) return null
const col = Math.min(Math.max(cell.width || 12, 1), 12)
return (
<Box key={`${rowIdx}-${cellIdx}`} gridColumn={`span ${col}`}>{prop.content}</Box>
)
})}
</SimpleGrid>
))}
</Stack>
)
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Form as BSForm } from 'react-bootstrap';
// Deprecated: Bootstrap widgets were used before migrating to Chakra UI theme
// Legacy Bootstrap widgets no longer used after migrating to Chakra UI
export const TextWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">

View File

@ -1,14 +1,18 @@
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'
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'
import theme from './theme.js'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter basename="/app">
<App />
</BrowserRouter>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<ChakraProvider theme={theme}>
<BrowserRouter basename="/app">
<App />
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>
)

View File

@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Container, Row, Col, Button, ButtonGroup, Dropdown, DropdownButton } from 'react-bootstrap'
import Form from '@rjsf/fluent-ui'
import { Container, Heading, HStack, Button, Menu, MenuButton, MenuList, MenuItem, useColorModeValue, Alert, AlertIcon, Spacer } from '@chakra-ui/react'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import { listSchemas, getSchema, readConfig, writeConfig } from '../services/api.js'
import { widgets } from '../components/rjsf/widgets.jsx'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
function buildUiSchema(schema) {
if (!schema || typeof schema !== 'object') return undefined
@ -13,9 +13,9 @@ function buildUiSchema(schema) {
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 === 'string') return { 'ui:widget': 'text' }
if (resolved === 'integer' || resolved === 'number') return { 'ui:widget': 'updown' }
if (resolved === 'boolean') return { 'ui:widget': 'checkbox' }
if (resolved === 'object' && s.properties) {
const ui = {}
for (const [key, prop] of Object.entries(s.properties)) {
@ -120,31 +120,29 @@ export default function ConfigPage() {
}
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">
<Container py={3} maxW="container.lg">
<HStack mb={3} align="center">
<Heading as="h2" size="md">Config Editor</Heading>
<Menu>
<MenuButton as={Button} size="sm" variant="outline">Schema: {currentId}</MenuButton>
<MenuList>
{available.map(id => (
<Dropdown.Item key={id} active={id === currentId} onClick={() => setCurrentId(id)}>
{id}
</Dropdown.Item>
<MenuItem key={id} onClick={() => setCurrentId(id)}>{id}</MenuItem>
))}
</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>
</MenuList>
</Menu>
<Spacer />
<HStack>
<Button as="label" size="sm" variant="outline">
Import
<input type="file" accept="application/json" onChange={handleFileImport} hidden />
</Button>
<Button size="sm" variant="outline" onClick={handleExport}> Export</Button>
</HStack>
</HStack>
{message && (
<div className="alert alert-info py-2">{message}</div>
<Alert status="info" mb={3} borderRadius="md"><AlertIcon />{message}</Alert>
)}
{schema && (
@ -155,11 +153,12 @@ export default function ConfigPage() {
onSubmit={handleSave}
onChange={({ formData }) => setFormData(formData)}
uiSchema={uiSchema}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<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>
<HStack spacing={2}>
<Button type="submit" isDisabled={loading}>💾 Save</Button>
<Button variant="outline" type="button" onClick={() => load(currentId)} isDisabled={loading}>🔄 Reload</Button>
</HStack>
</Form>
)}
</Container>

View File

@ -1,6 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import Form from '@rjsf/fluent-ui'
import { Link } from 'react-router-dom'
import { Box, Container, Flex, Grid, GridItem, HStack, Heading, Text, Button, Badge, Table, Thead, Tbody, Tr, Th, Td, Alert, AlertIcon, Card, CardBody, useColorModeValue } from '@chakra-ui/react'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
import {
getStatus,
getEvents,
@ -18,58 +21,45 @@ function StatusBar({ status }) {
const plcConnected = !!status?.plc_connected
const streaming = !!status?.streaming
const csvRecording = !!status?.csv_recording
const muted = useColorModeValue('gray.600', 'gray.300')
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>
<Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={3} mb={3}>
<Card><CardBody display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Text fontWeight="semibold">🔌 PLC: {plcConnected ? 'Connected' : 'Disconnected'}</Text>
{status?.plc_reconnection?.enabled && (
<Text fontSize="sm" color={muted}>
🔄 Auto-reconnection: {status?.plc_reconnection?.active ? 'reconnecting…' : 'enabled'}
</Text>
)}
</Box>
{plcConnected ? (
<Button size="sm" variant="outline" colorScheme="red" onClick={disconnectPlc}> Disconnect</Button>
) : (
<Button size="sm" variant="outline" colorScheme="blue" onClick={connectPlc}>🔗 Connect</Button>
)}
</CardBody></Card>
<Card><CardBody display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Text fontWeight="semibold">📡 UDP Streaming: {streaming ? 'Active' : 'Inactive'}</Text>
</Box>
{streaming ? (
<Button size="sm" variant="outline" colorScheme="red" onClick={stopUdpStreaming}> Stop</Button>
) : (
<Button size="sm" variant="outline" colorScheme="blue" onClick={startUdpStreaming}> Start</Button>
)}
</CardBody></Card>
<Card><CardBody>
<Text fontWeight="semibold">💾 CSV: {csvRecording ? 'Recording' : 'Inactive'}</Text>
{status?.disk_space_info && (
<Text fontSize="sm" color={muted} mt={1}>
💽 {status.disk_space_info.free_space} free · ~{status.disk_space_info.recording_time_left}
</Text>
)}
</CardBody></Card>
</Grid>
)
}
@ -79,9 +69,9 @@ function buildUiSchema(schema) {
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 === 'string') return { 'ui:widget': 'text' }
if (resolved === 'integer' || resolved === 'number') return { 'ui:widget': 'updown' }
if (resolved === 'boolean') return { 'ui:widget': 'checkbox' }
if (resolved === 'object' && s.properties) {
const ui = {}
for (const [key, prop] of Object.entries(s.properties)) {
@ -102,6 +92,7 @@ export default function DashboardPage() {
const [status, setStatus] = useState(null)
const [statusError, setStatusError] = useState('')
const sseRef = useRef(null)
const muted = useColorModeValue('gray.600', 'gray.300')
const [schemas, setSchemas] = useState({})
const available = useMemo(() => {
@ -213,66 +204,70 @@ export default function DashboardPage() {
}, [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>
<Container py={3} maxW="container.xl">
<Box mb={3}>
<Heading as="h1" size="md">PLC S7-31x Streamer & Logger</Heading>
<Text fontSize="sm" color={muted}>Unified dashboard: status, config and events</Text>
</Box>
{statusError && <div className="alert alert-danger py-2">{statusError}</div>}
{statusError && <Alert status="error" mb={3}><AlertIcon />{statusError}</Alert>}
{status && <StatusBar status={status} />}
{['plc', 'datasets', 'plots'].map((sectionId) => (
<div className="card mb-4" key={sectionId}>
<div className="card-body">
<div className="d-flex flex-wrap gap-2 align-items-center mb-3">
<div className="fw-semibold text-uppercase">🧩 {sectionId}</div>
<div className="ms-auto d-flex gap-2">
<Card key={sectionId} mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">🧩 {sectionId}</Text>
<Box ml="auto">
<SectionControls sectionId={sectionId} />
</div>
</div>
</Box>
</Flex>
<SectionForm sectionId={sectionId} />
</div>
</div>
</CardBody>
</Card>
))}
<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}>
<Flex align="center" justify="space-between" mb={2}>
<Heading as="h2" size="sm">📋 Recent Events</Heading>
<HStack>
<Button size="sm" variant="outline" onClick={loadEvents} isDisabled={eventsLoading}>
{eventsLoading ? 'Loading…' : 'Refresh'}
</button>
<a className="btn btn-sm btn-outline-primary" href="/app/events">Open Events</a>
</div>
</section>
</Button>
<Button as={Link} to="/app/events" size="sm" variant="outline">Open Events</Button>
</HStack>
</Flex>
<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>
<Box maxH="240px" overflowY="auto">
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th width="10%">Time</Th>
<Th 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>
<Tr key={idx}>
<Td whiteSpace="nowrap">{ev.timestamp || '-'}</Td>
<Td><Badge>{ev.level || ev.type || 'INFO'}</Badge></Td>
<Td>
<Text fontWeight="semibold">{ev.message || ev.event || '-'}</Text>
{ev.details && (
<Text fontSize="sm" color={muted}>
{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}
</Text>
)}
</Td>
</Tr>
))}
{events.length === 0 && (
<tr><td colSpan={3} className="text-muted">No events</td></tr>
<Tr><Td colSpan={3}><Text color={muted}>No events</Text></Td></Tr>
)}
</tbody>
</table>
</div>
</div>
</Tbody>
</Table>
</Box>
</Container>
)
}
@ -288,8 +283,8 @@ function SectionControls({ sectionId }) {
})()
}, [sectionId])
return (
<div className="d-flex gap-2">
<label className="btn btn-sm btn-outline-secondary mb-0">
<HStack>
<Button as="label" size="sm" variant="outline">
Import
<input type="file" accept="application/json" hidden onChange={async (e) => {
const file = e.target.files?.[0]
@ -302,8 +297,8 @@ function SectionControls({ sectionId }) {
setLocalData(json)
} catch { /* ignore */ } finally { setBusy(false) }
}} />
</label>
<button className="btn btn-sm btn-outline-secondary" onClick={async () => {
</Button>
<Button size="sm" variant="outline" onClick={async () => {
const data = localData ?? (await readConfig(sectionId)).data
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@ -312,15 +307,15 @@ function SectionControls({ sectionId }) {
a.download = `${sectionId}_config.json`
a.click()
URL.revokeObjectURL(url)
}}> Export</button>
<button className="btn btn-sm btn-primary" disabled={busy} onClick={async () => {
}}> Export</Button>
<Button size="sm" colorScheme="blue" isDisabled={busy} onClick={async () => {
setBusy(true)
try {
const res = await readConfig(sectionId)
await writeConfig(sectionId, res.data)
} finally { setBusy(false) }
}}>💾 Save</button>
</div>
}}>💾 Save</Button>
</HStack>
)
}
@ -351,7 +346,7 @@ function SectionForm({ sectionId }) {
return () => { mounted = false }
}, [sectionId])
if (loading || !localSchema) return <div className="text-muted">Loading {sectionId}</div>
if (loading || !localSchema) return <Text color={useColorModeValue('gray.600', 'gray.300')}>Loading {sectionId}</Text>
return (
<Form
@ -364,6 +359,7 @@ function SectionForm({ sectionId }) {
try { await writeConfig(sectionId, formData) } finally { setSaving(false) }
}}
uiSchema={localUi}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<div />
</Form>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { getEvents } from '../services/api.js'
import { Container, Heading, HStack, Button, Alert, AlertIcon, Table, Thead, Tbody, Tr, Th, Td, Box, Text, useColorModeValue } from '@chakra-ui/react'
export default function EventsPage() {
const [events, setEvents] = useState([])
@ -28,44 +29,48 @@ export default function EventsPage() {
}, [])
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}>
<Container py={3} maxW="container.xl">
<Heading as="h2" size="md" mb={3}>Events</Heading>
<HStack mb={3}>
<Button size="sm" colorScheme="blue" onClick={load} isDisabled={loading}>
{loading ? 'Cargando...' : 'Refrescar'}
</button>
<a className="btn btn-outline-secondary btn-sm" href="/api/events" target="_blank" rel="noreferrer">/api/events</a>
</div>
</Button>
<Button as="a" href="/api/events" target="_blank" rel="noreferrer" size="sm" variant="outline">/api/events</Button>
</HStack>
{error && <div className="alert alert-danger">{error}</div>}
{loading && !error && <div className="alert alert-info">Cargando eventos...</div>}
{error && <Alert status="error" mb={3}><AlertIcon />{error}</Alert>}
{loading && !error && <Alert status="info" mb={3}><AlertIcon />Cargando eventos...</Alert>}
{!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>
<Box overflowX="auto">
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th width="10%">Time</Th>
<Th 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>
<Tr key={idx}>
<Td whiteSpace="nowrap">{ev.timestamp || '-'}</Td>
<Td>{ev.level || ev.type || 'INFO'}</Td>
<Td>
<Text fontWeight="semibold">{ev.message || ev.event || '-'}</Text>
{ev.details && (
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>
{typeof ev.details === 'object' ? JSON.stringify(ev.details) : String(ev.details)}
</Text>
)}
</Td>
</Tr>
))}
</tbody>
</table>
</div>
</Tbody>
</Table>
</Box>
)}
</div>
</Container>
)
}

68
frontend/src/theme.js Normal file
View File

@ -0,0 +1,68 @@
import { extendTheme } from '@chakra-ui/react'
import { mode } from '@chakra-ui/theme-tools'
const config = {
initialColorMode: 'system',
useSystemColorMode: true,
}
const styles = {
global: (props) => ({
'html, body, #root': { height: '100%' },
body: {
backgroundColor: mode('gray.50', 'gray.700')(props),
color: mode('gray.800', 'gray.100')(props),
},
// Override common Bootstrap surfaces so they respect color mode
'.navbar': {
backgroundColor: mode('#f8f9fa', '#2D3748')(props),
borderColor: mode('#dee2e6', '#4A5568')(props),
color: 'inherit',
},
'.navbar *': { color: 'inherit' },
'.card': {
backgroundColor: mode('#ffffff', '#2D3748')(props),
borderColor: mode('#dee2e6', '#4A5568')(props),
color: 'inherit',
},
'.card *': { color: 'inherit' },
'.alert': {
backgroundColor: mode('#f8f9fa', '#2A4365')(props),
color: mode('#0c5460', '#bee3f8')(props),
borderColor: mode('#b8daff', '#2C5282')(props),
},
'.table': {
color: 'inherit',
},
'.table-striped tbody tr:nth-of-type(odd)': {
backgroundColor: mode('rgba(0,0,0,.05)', 'rgba(255,255,255,.06)')(props),
},
'.form-label': { color: 'inherit' },
'.btn.btn-outline-primary': {
borderColor: mode('#0d6efd', '#90cdf4')(props),
color: mode('#0d6efd', '#90cdf4')(props),
},
// In dark mode, align Bootstrap CSS variables to dark tokens
...(props.colorMode === 'dark'
? {
':root': {
'--bs-body-bg': '#2D3748',
'--bs-body-color': '#E2E8F0',
'--bs-border-color': '#4A5568',
'--bs-card-bg': '#2D3748',
'--bs-card-color': '#E2E8F0',
'--bs-heading-color': '#EDF2F7',
'--bs-link-color': '#90cdf4',
'--bs-table-bg': 'transparent',
'--bs-table-color': '#E2E8F0',
'--bs-table-striped-bg': 'rgba(255,255,255,0.06)',
'--bs-navbar-color': '#E2E8F0',
},
}
: {}),
}),
}
const theme = extendTheme({ config, styles })
export default theme

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Esquema para editar plc_datasets.json (múltiples datasets y variables)",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"type": "object",
"additionalProperties": false,
"properties": {
@ -15,14 +15,14 @@
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Nombre legible del dataset",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefijo para archivos CSV",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
@ -103,7 +103,7 @@
},
"streaming_variables": {
"type": "array",
"title": "Streaming Variables",
"title": "Streaming variables",
"items": {
"type": "string"
},
@ -114,8 +114,8 @@
"number",
"null"
],
"title": "Sampling Interval (s)",
"description": "Vacío para usar el intervalo global",
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plc.schema.json",
"title": "PLC & UDP Configuration",
"description": "Esquema para editar plc_config.json",
"description": "Schema to edit plc_config.json",
"type": "object",
"additionalProperties": false,
"properties": {
@ -14,14 +14,14 @@
"ip": {
"type": "string",
"title": "PLC IP",
"description": "Dirección IP del PLC (S7-31x)",
"description": "IP address of the PLC (S7-31x)",
"format": "ipv4",
"pattern": "^.+$"
},
"rack": {
"type": "integer",
"title": "Rack",
"description": "Número de rack (0-7)",
"description": "Rack number (0-7)",
"minimum": 0,
"maximum": 7,
"default": 0
@ -29,7 +29,7 @@
"slot": {
"type": "integer",
"title": "Slot",
"description": "Número de slot (generalmente 2)",
"description": "Slot number (usually 2)",
"minimum": 0,
"maximum": 31,
"default": 2
@ -69,7 +69,7 @@
"minimum": 0.01,
"maximum": 10,
"title": "Sampling Interval (s)",
"description": "Intervalo global de muestreo en segundos",
"description": "Global sampling interval in seconds",
"default": 0.1
},
"csv_config": {

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Esquema para editar plot_sessions.json (sesiones de gráfica)",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
@ -14,12 +14,12 @@
"name": {
"type": "string",
"title": "Plot Name",
"description": "Nombre de la sesión de gráfica"
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables a graficar",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
@ -27,8 +27,8 @@
},
"time_window": {
"type": "integer",
"title": "Time Window (s)",
"description": "Ventana temporal en segundos",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
@ -39,7 +39,7 @@
"null"
],
"title": "Y Min",
"description": "Vacío para auto"
"description": "Leave empty for auto"
},
"y_max": {
"type": [
@ -47,7 +47,7 @@
"null"
],
"title": "Y Max",
"description": "Vacío para auto"
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [