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:
parent
1833fff18f
commit
09eccf5c0b
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
}
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": [
|
||||
|
|
Loading…
Reference in New Issue