Compare commits

..

6 Commits

Author SHA1 Message Date
Miguel 09eccf5c0b 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. 2025-08-12 15:08:37 +02:00
Miguel 1833fff18f Eliminación de archivos obsoletos como plc_config.json, plc_datasets.json y plot_sessions.json. Se actualizó application_events.json para incluir un nuevo evento de inicio de aplicación y se ajustaron las fechas de última actualización. Se realizaron cambios en los esquemas de configuración para mejorar la gestión de datos y se actualizaron las dependencias en package.json para incluir nuevas bibliotecas. Además, se modificó la interfaz de usuario para utilizar el nuevo paquete @rjsf/fluent-ui. 2025-08-12 09:16:46 +02:00
Miguel 0c11ee3ae2 Actualización de application_events.json para incluir nuevos eventos de inicio de aplicación y ajustes en las fechas de última actualización. Se eliminaron archivos obsoletos relacionados con la integración de Chart.js y se reorganizaron las rutas de configuración en el código. Se implementaron mejoras en la gestión de esquemas y se optimizó la carga de recursos estáticos, incluyendo la favicon y logos en la interfaz. Además, se realizaron ajustes en el manejo de errores y se mejoró la estructura de directorios para una mejor organización del proyecto. 2025-08-11 16:26:20 +02:00
Miguel 593487e52f Actualización del archivo .gitignore para ignorar archivos generados automáticamente por React (Vite) en el frontend. Se añadieron múltiples entradas en application_events.json para registrar eventos de inicio de aplicación y errores de conexión al PLC. Se realizaron cambios en main.py para ajustar las rutas y mejorar la gestión de errores, además de eliminar la interfaz de usuario heredada. Se actualizaron las dependencias en package.json y se implementó un enrutador en App.jsx para la nueva SPA de React. Se modificó index.html para reflejar la transición a la nueva interfaz. 2025-08-11 15:01:53 +02:00
Miguel 5581e26d10 Version basica con Forms pero no dan una mejora a la aplicacion 2025-08-11 11:55:22 +02:00
Miguel 10df4e94bd Base de el configurador json basado en schemas 2025-08-10 01:17:14 +02:00
58 changed files with 3911 additions and 1116 deletions

File diff suppressed because it is too large Load Diff

12
.gitignore vendored
View File

@ -10,6 +10,18 @@ __pycache__/
/.doc/realtime/
# Ignorar archivos generados automáticamente por React (Vite) en frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/frontend/.env.local
/frontend/.env.*.local
/frontend/.eslintcache
/frontend/yarn.lock
/frontend/package-lock.json
/frontend/.DS_Store
# Distribution / packaging
.Python
build/

View File

@ -9019,8 +9019,332 @@
"trigger_on_true": true
}
}
},
{
"timestamp": "2025-08-10T01:12:18.990709",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-10T01:12:19.105449",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-10T01:12:19.127678",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-10T01:12:19.148987",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-10T01:23:18.751131",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-10T01:23:18.852237",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-10T01:23:18.873364",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-10T01:23:18.894713",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-10T01:45:12.463570",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-10T01:45:12.539769",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: DAR",
"details": {
"dataset_id": "DAR",
"variables_count": 3,
"streaming_count": 2,
"prefix": "gateway_phoenix"
}
},
{
"timestamp": "2025-08-10T01:45:12.552768",
"level": "info",
"event_type": "dataset_activated",
"message": "Dataset activated: Fast",
"details": {
"dataset_id": "Fast",
"variables_count": 2,
"streaming_count": 0,
"prefix": "fast"
}
},
{
"timestamp": "2025-08-10T01:45:12.562768",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 2
}
},
{
"timestamp": "2025-08-11T00:48:08.669755",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T01:05:38.323746",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:04:13.582426",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:07:53.052382",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:12:42.001877",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:18:42.038963",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T11:23:34.891879",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T12:09:11.404727",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T12:45:28.303517",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T13:12:55.081850",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T13:37:47.901634",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T13:52:48.189847",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T13:53:59.849319",
"level": "error",
"event_type": "plc_connection_failed",
"message": "Failed to connect to PLC 10.1.33.11",
"details": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2,
"error": "b' ISO : An error occurred during recv TCP : Connection timed out'"
}
},
{
"timestamp": "2025-08-11T15:17:54.090498",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-11T16:21:42.173167",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T09:13:36.619106",
"level": "info",
"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-10T00:37:46.526185",
"total_entries": 834
"last_updated": "2025-08-12T15:06:11.269817",
"total_entries": 874
}

View File

@ -0,0 +1,21 @@
{
"plc_config": {
"ip": "10.1.33.12",
"rack": 0,
"slot": 2
},
"udp_config": {
"host": "127.0.0.1",
"port": 9870
},
"sampling_interval": 0.1,
"csv_config": {
"records_directory": "records",
"rotation_enabled": true,
"max_size_mb": 1000,
"max_days": 30,
"max_hours": null,
"cleanup_interval_hours": 24,
"last_cleanup": "2025-08-09T22:43:54.224975"
}
}

View File

@ -60,10 +60,10 @@
}
},
"active_datasets": [
"DAR",
"Fast"
"Fast",
"DAR"
],
"current_dataset_id": "Fast",
"version": "1.0",
"last_update": "2025-08-10T00:37:18.103618"
"last_update": "2025-08-10T01:45:12.551768"
}

View File

@ -0,0 +1,23 @@
{
"plots": {
"plot_1": {
"name": "UR29",
"variables": [
"UR29_Brix",
"UR29_ma",
"fUR29_Brix",
"fUR29_ma"
],
"time_window": 75,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true,
"session_id": "plot_1"
}
},
"session_counter": 2,
"last_saved": "2025-08-10T00:37:46.525175",
"version": "1.0"
}

View File

@ -0,0 +1,183 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"type": "object",
"additionalProperties": false,
"properties": {
"datasets": {
"type": "object",
"title": "Datasets",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"area": {
"type": "string",
"title": "Memory Area",
"enum": [
"db",
"mw",
"m",
"pew",
"pe",
"paw",
"pa",
"e",
"a",
"mb"
]
},
"db": {
"type": [
"integer",
"null"
],
"title": "DB Number",
"minimum": 1,
"maximum": 9999
},
"offset": {
"type": "integer",
"title": "Offset",
"minimum": 0,
"maximum": 8191
},
"bit": {
"type": [
"integer",
"null"
],
"title": "Bit Position",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"title": "Data Type",
"enum": [
"real",
"int",
"bool",
"dint",
"word",
"byte",
"uint",
"udint",
"sint",
"usint"
]
},
"streaming": {
"type": "boolean",
"title": "Stream to PlotJuggler",
"default": false
}
},
"required": [
"area",
"offset",
"type"
]
}
},
"streaming_variables": {
"type": "array",
"title": "Streaming variables",
"items": {
"type": "string"
},
"default": []
},
"sampling_interval": {
"type": [
"number",
"null"
],
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
],
"title": "Created"
}
},
"required": [
"name",
"prefix",
"variables",
"streaming_variables"
]
}
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
"default": []
},
"current_dataset_id": {
"type": [
"string",
"null"
],
"title": "Current Dataset Id"
},
"version": {
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
],
"title": "Last Update"
}
},
"required": [
"datasets"
]
}

View File

@ -0,0 +1,143 @@
{
"$id": "plc.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"dependencies": {},
"description": "Schema to edit plc_config.json",
"properties": {
"csv_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"cleanup_interval_hours": {
"default": 24,
"minimum": 1,
"title": "Cleanup Interval (h)",
"type": "integer"
},
"last_cleanup": {
"title": "Last Cleanup",
"type": "string"
},
"max_days": {
"default": 30,
"minimum": 1,
"title": "Max Days",
"type": "integer"
},
"max_hours": {
"default": null,
"minimum": 1,
"title": "Max Hours",
"type": "integer"
},
"max_size_mb": {
"default": 1000,
"minimum": 1,
"title": "Max Size (MB)",
"type": "integer"
},
"records_directory": {
"default": "records",
"description": "Directory to save *.csv files",
"title": "Records Directory",
"type": "string"
},
"rotation_enabled": {
"default": true,
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
},
"title": "Rotation Active",
"type": "boolean"
}
},
"required": [
"cleanup_interval_hours",
"records_directory",
"rotation_enabled"
],
"title": "CSV Recording",
"type": "object"
},
"plc_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"ip": {
"description": "IP of PLC (S7-31x)",
"pattern": "^.+$",
"title": "PLC IP",
"type": "string"
},
"rack": {
"default": 0,
"description": "Rack of PLC",
"maximum": 7,
"minimum": 0,
"title": "Rack",
"type": "integer"
},
"slot": {
"default": 2,
"description": "Normally 2",
"maximum": 31,
"minimum": 0,
"title": "Slot",
"type": "integer"
}
},
"required": [
"ip",
"rack",
"slot"
],
"title": "PLC Configuration",
"type": "object"
},
"sampling_interval": {
"default": 0.1,
"description": "interval sampling in seconds",
"maximum": 10,
"minimum": 0.01,
"title": "Sampling Interval (s)",
"type": "number"
},
"udp_config": {
"additionalProperties": false,
"dependencies": {},
"properties": {
"host": {
"default": "127.0.0.1",
"description": "Normally this is 127.0.0.1",
"pattern": "^.+$",
"title": "UDP Host IP",
"type": "string"
},
"port": {
"default": 9870,
"maximum": 65535,
"minimum": 1,
"type": "integer"
}
},
"required": [
"host",
"port"
],
"title": "UDP Configuration",
"type": "object"
}
},
"required": [
"csv_config",
"plc_config",
"sampling_interval",
"udp_config"
],
"title": "PLC & UDP Configuration",
"type": "object"
}

View File

@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
"plots": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Plot Name",
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
"minItems": 1
},
"time_window": {
"type": "integer",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
},
"y_min": {
"type": [
"number",
"null"
],
"title": "Y Min",
"description": "Leave empty for auto"
},
"y_max": {
"type": [
"number",
"null"
],
"title": "Y Max",
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [
"string",
"null"
],
"title": "Trigger Variable"
},
"trigger_enabled": {
"type": "boolean",
"title": "Enable Trigger",
"default": false
},
"trigger_on_true": {
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"session_id": {
"type": "string",
"title": "Session Id"
}
},
"required": [
"name",
"variables",
"time_window"
]
}
},
"session_counter": {
"type": "integer",
"minimum": 0,
"default": 0
},
"last_saved": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string",
"default": "1.0"
}
},
"required": [
"plots"
]
}

View File

@ -0,0 +1,42 @@
{
"datasets": {
"ui:description": "Define datasets, their variables and streaming flags.",
"items": {
"name": {
"ui:placeholder": "Temperature Sensors"
},
"prefix": {
"ui:placeholder": "temp"
},
"sampling_interval": {
"ui:widget": "updown"
},
"enabled": {
"ui:widget": "checkbox"
},
"variables": {
"ui:description": "Variables inside this dataset",
"items": {
"area": {
"ui:widget": "select"
},
"db": {
"ui:widget": "updown"
},
"offset": {
"ui:widget": "updown"
},
"bit": {
"ui:widget": "updown"
},
"type": {
"ui:widget": "select"
},
"streaming": {
"ui:widget": "checkbox"
}
}
}
}
}
}

View File

@ -0,0 +1,156 @@
{
"csv_config": {
"cleanup_interval_hours": {
"ui:widget": "updown"
},
"last_cleanup": {},
"max_days": {
"ui:widget": "updown"
},
"max_hours": {
"ui:widget": "updown"
},
"max_size_mb": {
"ui:widget": "updown"
},
"records_directory": {
"ui:placeholder": "records"
},
"rotation_enabled": {
"ui:widget": "checkbox"
},
"ui:layout": [
[
{
"name": "cleanup_interval_hours",
"width": 3
},
{
"name": "last_cleanup",
"width": 3
},
{
"name": "max_days",
"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:column": 6
},
"rack": {
"ui:widget": "updown",
"ui:column": 3
},
"slot": {
"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": "updown"
},
"udp_config": {
"host": {
"ui:placeholder": "127.0.0.1"
},
"port": {
"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

@ -0,0 +1,21 @@
{
"plots": {
"items": {
"time_window": {
"ui:widget": "updown"
},
"y_min": {
"ui:widget": "updown"
},
"y_max": {
"ui:widget": "updown"
},
"trigger_enabled": {
"ui:widget": "checkbox"
},
"trigger_on_true": {
"ui:widget": "checkbox"
}
}
}
}

View File

@ -24,9 +24,11 @@ class ConfigManager:
"""Initialize configuration manager"""
self.logger = logger
# Configuration file paths
self.config_file = resource_path("plc_config.json")
self.datasets_file = resource_path("plc_datasets.json")
# Configuration file paths (reorganized under config/data)
data_dir = os.path.join("config", "data")
os.makedirs(resource_path(data_dir), exist_ok=True)
self.config_file = resource_path(os.path.join(data_dir, "plc_config.json"))
self.datasets_file = resource_path(os.path.join(data_dir, "plc_datasets.json"))
self.state_file = resource_path("system_state.json")
# Default configurations

View File

@ -12,6 +12,7 @@ try:
from .streamer import DataStreamer
from .event_logger import EventLogger
from .instance_manager import InstanceManager
from .schema_manager import ConfigSchemaManager
except ImportError:
# Fallback to absolute imports (when run directly)
from core.config_manager import ConfigManager
@ -19,6 +20,7 @@ except ImportError:
from core.streamer import DataStreamer
from core.event_logger import EventLogger
from core.instance_manager import InstanceManager
from core.schema_manager import ConfigSchemaManager
class PLCDataStreamer:
@ -50,6 +52,13 @@ class PLCDataStreamer:
)
self.logger.info("DataStreamer initialized successfully")
# Initialize schema manager
self.logger.info("Initializing ConfigSchemaManager...")
self.schema_manager = ConfigSchemaManager(
self.config_manager, self.data_streamer.plot_manager, self.logger
)
self.logger.info("ConfigSchemaManager initialized successfully")
self.logger.info("Initializing InstanceManager...")
self.instance_manager = InstanceManager(self.logger)
self.logger.info("InstanceManager initialized successfully")

View File

@ -227,8 +227,10 @@ class PlotManager:
self.event_logger = event_logger
self.logger = logger
# Persistent storage
self.plots_file = resource_path("plot_sessions.json")
# Persistent storage (reorganized under config/data)
self.plots_file = resource_path(
os.path.join("config", "data", "plot_sessions.json")
)
# Load existing plots from disk
self.load_plots()

231
core/schema_manager.py Normal file
View File

@ -0,0 +1,231 @@
import json
import os
from typing import Any, Dict, Optional
try:
# Relative imports when packaged
from .config_manager import ConfigManager
from .plot_manager import PlotManager
except Exception: # pragma: no cover
# Absolute imports when run directly
from core.config_manager import ConfigManager
from core.plot_manager import PlotManager
def resource_path(relative_path: str) -> str:
"""Obtener ruta absoluta para recursos (compatible con PyInstaller)."""
import sys
try:
base_path = sys._MEIPASS # type: ignore[attr-defined]
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class ConfigSchemaManager:
"""Gestor centralizado de esquemas JSON y lectura/escritura de configuraciones."""
def __init__(
self,
config_manager: ConfigManager,
plot_manager: PlotManager,
logger: Optional[Any] = None,
) -> None:
self.config_manager = config_manager
self.plot_manager = plot_manager
self.logger = logger
# Reorganized schema directories
self.schemas_dir = resource_path(os.path.join("config", "schema"))
self.ui_schemas_dir = resource_path(os.path.join("config", "schema", "ui"))
self.schemas_index: Dict[str, Dict[str, Any]] = {}
# Mapa de id -> ruta de archivo real de configuración
data_dir = resource_path(os.path.join("config", "data"))
os.makedirs(data_dir, exist_ok=True)
self.config_files: Dict[str, str] = {
"plc": os.path.join(data_dir, "plc_config.json"),
"datasets": os.path.join(data_dir, "plc_datasets.json"),
"plots": os.path.join(data_dir, "plot_sessions.json"),
}
self._load_all_schemas()
def _load_all_schemas(self) -> None:
os.makedirs(self.schemas_dir, exist_ok=True)
# Cargar todos los *.schema.json
try:
for name in os.listdir(self.schemas_dir):
if name.endswith(".schema.json"):
schema_id = name.replace(".schema.json", "")
path = os.path.join(self.schemas_dir, name)
with open(path, "r", encoding="utf-8") as f:
self.schemas_index[schema_id] = json.load(f)
if self.logger:
self.logger.info(
f"Loaded {len(self.schemas_index)} schemas from {self.schemas_dir}"
)
except Exception as e:
if self.logger:
self.logger.error(f"Error loading schemas: {e}")
def list_schemas(self) -> Dict[str, Any]:
return {
"schemas": [
{
"id": key,
"title": self.schemas_index[key].get("title", key),
"description": self.schemas_index[key].get("description"),
}
for key in sorted(self.schemas_index.keys())
]
}
def get_schema(self, schema_id: str) -> Dict[str, Any]:
if schema_id not in self.schemas_index:
raise ValueError(f"Schema '{schema_id}' not found")
return self.schemas_index[schema_id]
def get_ui_schema(self, schema_id: str) -> Optional[Dict[str, Any]]:
"""Load optional RJSF UI schema if present."""
try:
path = os.path.join(self.ui_schemas_dir, f"{schema_id}.uischema.json")
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
if self.logger:
self.logger.warning(f"Error loading UI schema for '{schema_id}': {e}")
return None
def read_config(self, config_id: str) -> Dict[str, Any]:
if config_id == "plc":
# Construir desde ConfigManager para asegurar consistencia
return {
"plc_config": self.config_manager.plc_config,
"udp_config": self.config_manager.udp_config,
"sampling_interval": self.config_manager.sampling_interval,
"csv_config": self.config_manager.csv_config,
}
elif config_id == "datasets":
return {
"datasets": self.config_manager.datasets,
"active_datasets": list(self.config_manager.active_datasets),
"current_dataset_id": self.config_manager.current_dataset_id,
"version": "1.0",
}
elif config_id == "plots":
# Leer del archivo para reflejar persistencia actual
path = self.config_files[config_id]
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {"plots": {}, "session_counter": 0, "version": "1.0"}
else:
raise ValueError(f"Unknown config id '{config_id}'")
def write_config(self, config_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
# Validación básica con jsonschema si está disponible
try:
import jsonschema # type: ignore
schema = self.get_schema(config_id)
jsonschema.validate(instance=data, schema=schema)
except ImportError:
# Si jsonschema no está instalado, solo registramos una advertencia
if self.logger:
self.logger.warning(
"jsonschema not installed; skipping schema validation"
)
except Exception as e:
raise ValueError(f"Schema validation failed: {e}")
if config_id == "plc":
# Aplicar a ConfigManager y persistir
plc_cfg = data.get("plc_config", {})
udp_cfg = data.get("udp_config", {})
sampling = data.get(
"sampling_interval", self.config_manager.sampling_interval
)
csv_cfg = data.get("csv_config", {})
# Actualizaciones atómicas
if plc_cfg:
self.config_manager.update_plc_config(
plc_cfg.get("ip", self.config_manager.plc_config.get("ip")),
int(
plc_cfg.get(
"rack", self.config_manager.plc_config.get("rack", 0)
)
),
int(
plc_cfg.get(
"slot", self.config_manager.plc_config.get("slot", 2)
)
),
)
if udp_cfg:
self.config_manager.update_udp_config(
udp_cfg.get("host", self.config_manager.udp_config.get("host")),
int(
udp_cfg.get(
"port", self.config_manager.udp_config.get("port", 9870)
)
),
)
if sampling is not None:
self.config_manager.update_sampling_interval(float(sampling))
if csv_cfg:
self.config_manager.update_csv_config(**csv_cfg)
# Guardar archivo completo para export
path = self.config_files[config_id]
with open(path, "w", encoding="utf-8") as f:
json.dump(self.read_config("plc"), f, indent=2)
return {"success": True}
if config_id == "datasets":
# Reemplazar estructuras completas de datasets de manera controlada
datasets = data.get("datasets", {})
active = set(data.get("active_datasets", []))
current = data.get("current_dataset_id")
# Sobrescribir en ConfigManager y persistir
self.config_manager.datasets = datasets
self.config_manager.active_datasets = set(active)
self.config_manager.current_dataset_id = (
current if current in datasets else None
)
self.config_manager.save_datasets()
return {"success": True}
if config_id == "plots":
# Guardar directamente y recargar gestor de plots
path = self.config_files[config_id]
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# Recargar desde persistencia
try:
self.plot_manager.load_plots()
except Exception as e:
if self.logger:
self.logger.warning(f"Could not reload plot sessions: {e}")
return {"success": True}
raise ValueError(f"Unknown config id '{config_id}'")
def export_config(self, config_id: str) -> str:
path = self.config_files.get(config_id)
if not path:
raise ValueError(f"Unknown config id '{config_id}'")
# Asegurar que el archivo está sincronizado
if config_id == "plc":
with open(path, "w", encoding="utf-8") as f:
json.dump(self.read_config("plc"), f, indent=2)
elif config_id == "datasets":
self.config_manager.save_datasets()
elif config_id == "plots":
self.plot_manager.save_plots()
return path

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PLC S7-31x Streamer & Logger - React</title>
<link rel="icon" type="image/png" href="/favicon.ico?v=5" />
<link rel="shortcut icon" type="image/png" href="/favicon.ico?v=5" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "plc-streamer-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@rjsf/core": "^5.24.12",
"@rjsf/chakra-ui": "^5.24.12",
"@rjsf/validator-ajv8": "^5.24.12",
"@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-dom": "^18.2.0",
"react-router-dom": "^6.26.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
frontend/public/record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

123
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,123 @@
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'
import PlotsPage from './pages/Plots.jsx'
import PLCConfigModal from './components/PLCConfigModal.jsx'
import DashboardPage from './pages/Dashboard.jsx'
function Home() {
return (
<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)
</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>
)
}
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'
})
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 (
<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 />
<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 />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/plots" element={<PlotsPage />} />
</Routes>
<PLCConfigModal show={showPLCModal} onClose={() => setShowPLCModal(false)} />
</Box>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
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';
// Chakra theme widgets are used by default
const uiSchema = {
plc_config: {
rack: { 'ui:widget': 'updown' },
slot: { 'ui:widget': 'updown' },
},
udp_config: {
port: { 'ui:widget': 'updown' },
},
sampling_interval: { 'ui:widget': 'updown' },
csv_config: {
max_size_mb: { 'ui:widget': 'updown' },
max_days: { 'ui:widget': 'updown' },
max_hours: { 'ui:widget': 'updown' },
cleanup_interval_hours: { 'ui:widget': 'updown' },
},
};
export default function PLCConfigModal({ show, onClose }) {
const [schema, setSchema] = useState(null);
const [formData, setFormData] = useState(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
if (show) {
Promise.all([getSchema('plc'), readConfig('plc')])
.then(([s, d]) => {
setSchema(s.schema);
setFormData(d.data);
})
.catch(() => setMsg('Error loading PLC config'));
}
}, [show]);
const handleSubmit = async ({ formData: newData }) => {
setSaving(true);
setMsg('');
try {
await writeConfig('plc', newData);
setMsg('Saved successfully');
onClose?.();
} catch (e) {
setMsg(e.message || 'Error saving PLC config');
} finally {
setSaving(false);
}
};
return (
<Modal 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

@ -0,0 +1,102 @@
import React from 'react';
// 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">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
export const UpDownWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
type="number"
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
export const TextareaWidget = ({ id, placeholder, required, readonly, disabled, label, value, onChange, onBlur, onFocus, autofocus, options, schema }) => (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Control
as="textarea"
rows={options?.rows || 3}
id={id}
placeholder={placeholder}
autoFocus={autofocus}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
onBlur={() => onBlur && onBlur(id, value)}
onFocus={() => onFocus && onFocus(id, value)}
/>
</BSForm.Group>
);
export const SelectWidget = ({ id, required, readonly, disabled, label, value, onChange, options, schema }) => {
const enumOptions = options?.enumOptions || [];
return (
<BSForm.Group className="mb-3">
{schema?.title && <BSForm.Label htmlFor={id}>{schema.title}</BSForm.Label>}
<BSForm.Select
id={id}
required={required}
disabled={disabled || readonly}
value={value ?? ''}
onChange={(event) => onChange(event.target.value)}
>
{enumOptions.map((opt) => (
<option key={String(opt.value)} value={opt.value}>
{opt.label}
</option>
))}
</BSForm.Select>
</BSForm.Group>
);
};
export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange }) => (
<BSForm.Group className="mb-3">
<BSForm.Check
type="checkbox"
id={id}
label={label}
checked={!!value}
required={required}
disabled={disabled || readonly}
onChange={(e) => onChange(e.target.checked)}
/>
</BSForm.Group>
);
// Map keys must match RJSF default widget names to override them automatically by type
export const widgets = {
TextWidget,
UpDownWidget,
SelectWidget,
CheckboxWidget,
TextareaWidget,
};

18
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
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>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<ChakraProvider theme={theme}>
<BrowserRouter basename="/app">
<App />
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>
)

View File

@ -0,0 +1,168 @@
import React, { useEffect, useMemo, useState } from 'react'
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 LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
function buildUiSchema(schema) {
if (!schema || typeof schema !== 'object') return undefined
const mapForType = (s) => {
if (!s || typeof s !== 'object') return undefined
const type = s.type
// handle oneOf/anyOf by taking first option for ui mapping
const resolved = type || (Array.isArray(s.oneOf) && s.oneOf[0]?.type) || (Array.isArray(s.anyOf) && s.anyOf[0]?.type)
if (resolved === 'string') return { 'ui:widget': '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)) {
ui[key] = mapForType(prop)
}
return ui
}
if (resolved === 'array' && s.items) {
// Apply mapping to array items when simple types
const itemUi = mapForType(s.items)
return itemUi ? { items: itemUi } : undefined
}
return undefined
}
return mapForType(schema)
}
export default function ConfigPage() {
const [schemas, setSchemas] = useState({})
const [currentId, setCurrentId] = useState('plc')
const [schema, setSchema] = useState(null)
const [uiSchema, setUiSchema] = useState(undefined)
const [formData, setFormData] = useState(null)
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const available = useMemo(() => {
if (!schemas) return []
if (Array.isArray(schemas.schemas)) return schemas.schemas.map(s => s.id)
if (schemas.schemas && typeof schemas.schemas === 'object') return Object.keys(schemas.schemas)
const ids = []
if (schemas?.plc) ids.push('plc')
if (schemas?.datasets) ids.push('datasets')
if (schemas?.plots) ids.push('plots')
return ids
}, [schemas])
const load = async (id) => {
setLoading(true)
setMessage('')
try {
const [schemaResp, dataResp] = await Promise.all([
getSchema(id),
readConfig(id),
])
setSchema(schemaResp.schema)
setUiSchema(schemaResp.ui_schema || buildUiSchema(schemaResp.schema))
setFormData(dataResp.data)
} catch (e) {
setMessage(e.message || 'Error loading schema/config')
} finally {
setLoading(false)
}
}
useEffect(() => {
listSchemas().then(setSchemas).catch(() => { })
}, [])
useEffect(() => {
if (currentId) {
load(currentId)
}
}, [currentId])
const handleSave = async ({ formData: newData }) => {
setLoading(true)
setMessage('')
try {
await writeConfig(currentId, newData)
setFormData(newData)
setMessage('Saved successfully')
} catch (e) {
setMessage(e.message || 'Error saving configuration')
} finally {
setLoading(false)
}
}
const handleFileImport = async (evt) => {
const file = evt.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const json = JSON.parse(text)
setFormData(json)
setMessage(`Imported ${file.name}`)
} catch (e) {
setMessage('Invalid JSON file')
}
}
const handleExport = () => {
const blob = new Blob([JSON.stringify(formData ?? {}, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${currentId}_config.json`
a.click()
URL.revokeObjectURL(url)
}
return (
<Container 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 => (
<MenuItem key={id} onClick={() => setCurrentId(id)}>{id}</MenuItem>
))}
</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 && (
<Alert status="info" mb={3} borderRadius="md"><AlertIcon />{message}</Alert>
)}
{schema && (
<Form
schema={schema}
formData={formData}
validator={validator}
onSubmit={handleSave}
onChange={({ formData }) => setFormData(formData)}
uiSchema={uiSchema}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<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

@ -0,0 +1,369 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
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,
listSchemas,
getSchema,
readConfig,
writeConfig,
connectPlc,
disconnectPlc,
startUdpStreaming,
stopUdpStreaming,
} from '../services/api.js'
function StatusBar({ status }) {
const plcConnected = !!status?.plc_connected
const streaming = !!status?.streaming
const csvRecording = !!status?.csv_recording
const muted = useColorModeValue('gray.600', 'gray.300')
return (
<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>
)
}
function buildUiSchema(schema) {
if (!schema || typeof schema !== 'object') return undefined
const mapForType = (s) => {
if (!s || typeof s !== 'object') return undefined
const type = s.type
const resolved = type || (Array.isArray(s.oneOf) && s.oneOf[0]?.type) || (Array.isArray(s.anyOf) && s.anyOf[0]?.type)
if (resolved === 'string') return { 'ui:widget': '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)) {
ui[key] = mapForType(prop)
}
return ui
}
if (resolved === 'array' && s.items) {
const itemUi = mapForType(s.items)
return itemUi ? { items: itemUi } : undefined
}
return undefined
}
return mapForType(schema)
}
export default function DashboardPage() {
const [status, setStatus] = useState(null)
const [statusError, setStatusError] = useState('')
const sseRef = useRef(null)
const muted = useColorModeValue('gray.600', 'gray.300')
const [schemas, setSchemas] = useState({})
const available = useMemo(() => {
if (!schemas) return []
// Accept multiple shapes from API
if (Array.isArray(schemas.schemas)) return schemas.schemas.map(s => s.id || s)
if (schemas.schemas && typeof schemas.schemas === 'object') return Object.keys(schemas.schemas)
const ids = []
if (schemas?.plc) ids.push('plc')
if (schemas?.datasets) ids.push('datasets')
if (schemas?.plots) ids.push('plots')
return ids
}, [schemas])
const [currentSchemaId, setCurrentSchemaId] = useState('plc')
const [schema, setSchema] = useState(null)
const [uiSchema, setUiSchema] = useState(undefined)
const [formData, setFormData] = useState(null)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [accordionOpen, setAccordionOpen] = useState(true)
const [events, setEvents] = useState([])
const [eventsLoading, setEventsLoading] = useState(false)
const loadStatusOnce = async () => {
try {
const data = await getStatus()
setStatus(data)
} catch (e) {
setStatusError(e.message || 'Error fetching status')
}
}
const subscribeSSE = () => {
try {
const es = new EventSource('/api/stream/status?interval=2.0')
sseRef.current = es
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.type === 'status' && payload.status) {
setStatus(payload.status)
}
} catch { /* ignore */ }
}
es.onerror = () => {
es.close()
sseRef.current = null
}
} catch { /* ignore */ }
}
const loadSchemas = async () => {
try {
const data = await listSchemas()
setSchemas(data)
} catch { /* ignore */ }
}
const loadConfig = async (id) => {
try {
const [schemaResp, dataResp] = await Promise.all([
getSchema(id),
readConfig(id),
])
setSchema(schemaResp.schema)
setUiSchema(schemaResp.ui_schema || buildUiSchema(schemaResp.schema))
setFormData(dataResp.data)
} catch (e) {
setMessage(e.message || 'Error loading config')
}
}
const saveConfig = async () => {
if (!currentSchemaId) return
setSaving(true)
setMessage('')
try {
await writeConfig(currentSchemaId, formData)
setMessage('Saved successfully')
} catch (e) {
setMessage(e.message || 'Error saving configuration')
} finally {
setSaving(false)
}
}
const loadEvents = async () => {
setEventsLoading(true)
try {
const data = await getEvents(5)
if (data?.success) setEvents(data.events || [])
} finally {
setEventsLoading(false)
}
}
useEffect(() => {
loadStatusOnce()
subscribeSSE()
loadSchemas()
loadEvents()
return () => { if (sseRef.current) sseRef.current.close() }
}, [])
useEffect(() => {
if (currentSchemaId) loadConfig(currentSchemaId)
}, [currentSchemaId])
return (
<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 && <Alert status="error" mb={3}><AlertIcon />{statusError}</Alert>}
{status && <StatusBar status={status} />}
{['plc', 'datasets', 'plots'].map((sectionId) => (
<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} />
</Box>
</Flex>
<SectionForm sectionId={sectionId} />
</CardBody>
</Card>
))}
<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>
<Button as={Link} to="/app/events" size="sm" variant="outline">Open Events</Button>
</HStack>
</Flex>
<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 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}><Text color={muted}>No events</Text></Td></Tr>
)}
</Tbody>
</Table>
</Box>
</Container>
)
}
function SectionControls({ sectionId }) {
const [busy, setBusy] = useState(false)
const [localData, setLocalData] = useState(null)
useEffect(() => {
; (async () => {
try {
const res = await readConfig(sectionId)
setLocalData(res.data)
} catch { /* ignore */ }
})()
}, [sectionId])
return (
<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]
if (!file) return
try {
const text = await file.text()
const json = JSON.parse(text)
setBusy(true)
await writeConfig(sectionId, json)
setLocalData(json)
} catch { /* ignore */ } finally { setBusy(false) }
}} />
</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)
const a = document.createElement('a')
a.href = url
a.download = `${sectionId}_config.json`
a.click()
URL.revokeObjectURL(url)
}}> 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>
</HStack>
)
}
function SectionForm({ sectionId }) {
const [localSchema, setLocalSchema] = useState(null)
const [localUi, setLocalUi] = useState(null)
const [localData, setLocalData] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
let mounted = true
; (async () => {
setLoading(true)
try {
const [schemaResp, dataResp] = await Promise.all([
getSchema(sectionId),
readConfig(sectionId),
])
if (!mounted) return
setLocalSchema(schemaResp.schema)
setLocalUi(schemaResp.ui_schema || buildUiSchema(schemaResp.schema))
setLocalData(dataResp.data)
} finally {
if (mounted) setLoading(false)
}
})()
return () => { mounted = false }
}, [sectionId])
if (loading || !localSchema) return <Text color={useColorModeValue('gray.600', 'gray.300')}>Loading {sectionId}</Text>
return (
<Form
schema={localSchema}
formData={localData}
validator={validator}
onChange={({ formData }) => setLocalData(formData)}
onSubmit={async ({ formData }) => {
setSaving(true)
try { await writeConfig(sectionId, formData) } finally { setSaving(false) }
}}
uiSchema={localUi}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
>
<div />
</Form>
)
}

View File

@ -0,0 +1,77 @@
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([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = async () => {
setLoading(true)
setError('')
try {
const data = await getEvents(100)
if (data && data.success) {
setEvents(data.events || [])
} else {
setError(data?.error || 'Unexpected response')
}
} catch (e) {
setError(e.message || 'Error fetching events')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
return (
<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>
<Button as="a" href="/api/events" target="_blank" rel="noreferrer" size="sm" variant="outline">/api/events</Button>
</HStack>
{error && <Alert status="error" mb={3}><AlertIcon />{error}</Alert>}
{loading && !error && <Alert status="info" mb={3}><AlertIcon />Cargando eventos...</Alert>}
{!loading && !error && (
<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 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>
</Box>
)}
</Container>
)
}

View File

@ -0,0 +1,12 @@
import React from 'react';
export default function PlotsPage() {
return (
<div>
<h1>Real-Time Plotting</h1>
<p>This page will contain the real-time plots.</p>
</div>
);
}

View File

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react'
import { getStatus } from '../services/api.js'
function StatusItem({ label, value }) {
return (
<div className="col-md-6 col-lg-4">
<div className="card mb-3">
<div className="card-body">
<div className="text-muted small">{label}</div>
<div className="fw-semibold">{String(value)}</div>
</div>
</div>
</div>
)
}
export default function StatusPage() {
const [status, setStatus] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const load = async () => {
setLoading(true)
setError('')
try {
const data = await getStatus()
setStatus(data)
} catch (e) {
setError(e.message || 'Error fetching status')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
return (
<div className="container py-3">
<h2 className="h4 mb-3">Status</h2>
<div className="d-flex gap-2 mb-3">
<button className="btn btn-primary btn-sm" onClick={load} disabled={loading}>
{loading ? 'Cargando...' : 'Refrescar'}
</button>
<a className="btn btn-outline-secondary btn-sm" href="/api/status" target="_blank" rel="noreferrer">/api/status</a>
</div>
{error && (
<div className="alert alert-danger">{error}</div>
)}
{!error && loading && (
<div className="alert alert-info">Cargando estado...</div>
)}
{status && (
<div className="row">
{Object.entries(status).map(([k, v]) => (
<StatusItem key={k} label={k} value={typeof v === 'object' ? JSON.stringify(v) : v} />
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,91 @@
const BASE_URL = '' // same origin (Flask serves Vite build)
function toJsonOrThrow(res) {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
return res.json()
}
export async function getStatus() {
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function getEvents(limit = 50) {
const res = await fetch(`${BASE_URL}/api/events?limit=${encodeURIComponent(limit)}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
// PLC control
export async function connectPlc() {
const res = await fetch(`${BASE_URL}/api/plc/connect`, { method: 'POST', headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function disconnectPlc() {
const res = await fetch(`${BASE_URL}/api/plc/disconnect`, { method: 'POST', headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
// UDP streaming control
export async function startUdpStreaming() {
const res = await fetch(`${BASE_URL}/api/udp/streaming/start`, { method: 'POST', headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function stopUdpStreaming() {
const res = await fetch(`${BASE_URL}/api/udp/streaming/stop`, { method: 'POST', headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
// Config schemas and data
export async function listSchemas() {
const res = await fetch(`${BASE_URL}/api/config/schemas`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function getSchema(schemaId) {
const res = await fetch(`${BASE_URL}/api/config/schema/${encodeURIComponent(schemaId)}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function readConfig(configId) {
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function writeConfig(configId, data) {
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(data ?? {}),
})
return toJsonOrThrow(res)
}
// Helpers for future modules
export async function getJson(path) {
const res = await fetch(`${BASE_URL}${path}`, { headers: { 'Accept': 'application/json' } })
return toJsonOrThrow(res)
}
export async function postJson(path, body) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body ?? {}),
})
return toJsonOrThrow(res)
}
export async function putJson(path, body) {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body ?? {}),
})
return toJsonOrThrow(res)
}

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

34
frontend/vite.config.js Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
host: true,
proxy: {
'/api': {
target: 'http://localhost:5050',
changeOrigin: true,
},
'/images': {
target: 'http://localhost:5050',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:5050',
changeOrigin: true,
},
'/favicon.ico': {
target: 'http://localhost:5050',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})

223
main.py
View File

@ -1,35 +1,36 @@
from flask import (
Flask,
render_template,
request,
jsonify,
redirect,
url_for,
send_from_directory,
Response,
stream_template,
)
import snap7
import snap7.util
from flask_cors import CORS
import json
import socket
import time
import logging
import threading
from datetime import datetime
from typing import Dict, Any, Optional, List
import struct
import os
import csv
from pathlib import Path
import atexit
import psutil
import sys
from core import PLCDataStreamer
app = Flask(__name__)
CORS(
app,
resources={
r"/api/*": {
"origins": [
"http://localhost:5173/app",
"http://127.0.0.1:5173/app",
"*",
]
}
},
)
app.secret_key = "plc_streamer_secret_key"
# React build directory (for Vite production build)
REACT_DIST_DIR = os.path.join(os.path.abspath("."), "frontend", "dist")
def resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller"""
@ -43,6 +44,12 @@ def resource_path(relative_path):
return os.path.join(base_path, relative_path)
def project_path(*parts: str) -> str:
"""Build absolute path from the project root (based on this file)."""
base_dir = os.path.abspath(os.path.dirname(__file__))
return os.path.join(base_dir, *parts)
# Global streamer instance (will be initialized in main)
streamer = None
@ -66,26 +73,176 @@ def serve_static(filename):
return send_from_directory("static", filename)
@app.route("/favicon.ico")
def serve_favicon():
"""Serve application favicon from robust locations.
Priority:
1) frontend/public/favicon.ico
2) frontend/public/record.png
3) static/icons/record.png
"""
# Use absolute paths for reliability (works in dev and bundled)
public_dir = project_path("frontend", "public")
public_favicon = os.path.join(public_dir, "favicon.ico")
public_record = os.path.join(public_dir, "record.png")
if os.path.exists(public_favicon):
return send_from_directory(public_dir, "favicon.ico")
if os.path.exists(public_record):
return send_from_directory(public_dir, "record.png")
# Fallback: static/icons
static_icons_dir = project_path("static", "icons")
if os.path.exists(os.path.join(static_icons_dir, "record.png")):
return send_from_directory(static_icons_dir, "record.png")
# Final fallback: 404
return Response("Favicon not found", status=404, mimetype="text/plain")
@app.route("/record.png")
def serve_public_record_png():
"""Serve /record.png from the React public folder with fallbacks."""
public_dir = project_path("frontend", "public")
public_record = os.path.join(public_dir, "record.png")
if os.path.exists(public_record):
return send_from_directory(public_dir, "record.png")
static_icons_dir = project_path("static", "icons")
if os.path.exists(os.path.join(static_icons_dir, "record.png")):
return send_from_directory(static_icons_dir, "record.png")
return Response("record.png not found", status=404, mimetype="text/plain")
# ==============================
# Frontend (React SPA)
# ==============================
@app.route("/assets/<path:filename>")
def serve_react_assets(filename):
"""Serve built React assets from Vite (production)."""
assets_dir = os.path.join(REACT_DIST_DIR, "assets")
return send_from_directory(assets_dir, filename)
@app.route("/")
def index():
"""Main page"""
if streamer is None:
return "Application not initialized", 503
# Get variables for the current dataset or empty dict if no current dataset
current_variables = {}
if streamer.current_dataset_id and streamer.current_dataset_id in streamer.datasets:
current_variables = streamer.datasets[streamer.current_dataset_id].get(
"variables", {}
@app.route("/app")
@app.route("/app/<path:path>")
def serve_react_index(path: str = ""):
"""Serve React SPA index (expects Vite build at frontend/dist)."""
index_path = os.path.join(REACT_DIST_DIR, "index.html")
if not os.path.exists(index_path):
return Response(
"React build not found. Run 'cd frontend && npm install && npm run build' first.",
status=500,
mimetype="text/plain",
)
return send_from_directory(REACT_DIST_DIR, "index.html")
return render_template(
"index.html",
status=streamer.get_status(),
variables=current_variables,
datasets=streamer.datasets,
current_dataset_id=streamer.current_dataset_id,
)
## Legacy UI removed after migration to React
# ==============================
# Config Schemas & Editor API
# ==============================
@app.route("/api/config/schemas", methods=["GET"])
def list_config_schemas():
"""Listar esquemas disponibles (plc, datasets, plots)."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
info = streamer.schema_manager.list_schemas()
return jsonify({"success": True, **info})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/config/schema/<schema_id>", methods=["GET"])
def get_config_schema(schema_id):
"""Obtener un esquema específico en formato JSON Schema."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
schema = streamer.schema_manager.get_schema(schema_id)
ui_schema = None
# Try load optional UI schema
try:
ui_schema = streamer.schema_manager.get_ui_schema(schema_id)
except Exception:
ui_schema = None
resp = {"success": True, "schema": schema}
if ui_schema is not None:
resp["ui_schema"] = ui_schema
return jsonify(resp)
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 404
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/config/<config_id>", methods=["GET"])
def read_config(config_id):
"""Leer configuración actual (plc/datasets/plots)."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = streamer.schema_manager.read_config(config_id)
return jsonify({"success": True, "data": data})
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 404
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/config/<config_id>", methods=["PUT"])
def write_config(config_id):
"""Sobrescribir configuración a partir del cuerpo JSON."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
payload = request.get_json(force=True, silent=False)
result = streamer.schema_manager.write_config(config_id, payload)
return jsonify({"success": True, **result})
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/config/<config_id>/export", methods=["GET"])
def export_config(config_id):
"""Exportar configuración como descarga JSON."""
error_response = check_streamer_initialized()
if error_response:
return error_response
try:
data = streamer.schema_manager.read_config(config_id)
# Preparar respuesta con cabeceras de descarga
content = json.dumps(data, indent=2)
filename = f"{config_id}_export.json"
resp = Response(content, mimetype="application/json")
resp.headers["Content-Disposition"] = f"attachment; filename={filename}"
return resp
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 404
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route("/api/plc/config", methods=["POST"])
@ -521,7 +678,7 @@ def update_variable(name):
current_dataset_id, new_name, area, db, offset, var_type, bit, was_streaming
)
return jsonify({"success": True, "message": f"Variable updated successfully"})
return jsonify({"success": True, "message": "Variable updated successfully"})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 400

View File

@ -1,21 +0,0 @@
{
"plc_config": {
"ip": "10.1.33.11",
"rack": 0,
"slot": 2
},
"udp_config": {
"host": "127.0.0.1",
"port": 9870
},
"sampling_interval": 0.1,
"csv_config": {
"records_directory": "records",
"rotation_enabled": true,
"max_size_mb": 1000,
"max_days": 30,
"max_hours": null,
"cleanup_interval_hours": 24,
"last_cleanup": "2025-08-09T22:43:54.224975"
}
}

View File

@ -1,23 +0,0 @@
{
"plots": {
"plot_1": {
"name": "UR29",
"variables": [
"UR29_Brix",
"UR29_ma",
"fUR29_Brix",
"fUR29_ma"
],
"time_window": 75,
"y_min": null,
"y_max": null,
"trigger_variable": null,
"trigger_enabled": false,
"trigger_on_true": true,
"session_id": "plot_1"
}
},
"session_counter": 2,
"last_saved": "2025-08-10T00:37:46.525175",
"version": "1.0"
}

View File

@ -1,4 +1,6 @@
Flask==2.3.3
python-snap7==1.3
psutil==5.9.5
flask-socketio==5.3.6
flask-socketio==5.3.6
jsonschema==4.22.0
Flask-Cors==4.0.0

View File

@ -0,0 +1,183 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "datasets.schema.json",
"title": "Datasets Configuration",
"description": "Schema to edit plc_datasets.json (multiple datasets and variables)",
"type": "object",
"additionalProperties": false,
"properties": {
"datasets": {
"type": "object",
"title": "Datasets",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"variables": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"area": {
"type": "string",
"title": "Memory Area",
"enum": [
"db",
"mw",
"m",
"pew",
"pe",
"paw",
"pa",
"e",
"a",
"mb"
]
},
"db": {
"type": [
"integer",
"null"
],
"title": "DB Number",
"minimum": 1,
"maximum": 9999
},
"offset": {
"type": "integer",
"title": "Offset",
"minimum": 0,
"maximum": 8191
},
"bit": {
"type": [
"integer",
"null"
],
"title": "Bit Position",
"minimum": 0,
"maximum": 7
},
"type": {
"type": "string",
"title": "Data Type",
"enum": [
"real",
"int",
"bool",
"dint",
"word",
"byte",
"uint",
"udint",
"sint",
"usint"
]
},
"streaming": {
"type": "boolean",
"title": "Stream to PlotJuggler",
"default": false
}
},
"required": [
"area",
"offset",
"type"
]
}
},
"streaming_variables": {
"type": "array",
"title": "Streaming variables",
"items": {
"type": "string"
},
"default": []
},
"sampling_interval": {
"type": [
"number",
"null"
],
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
],
"title": "Created"
}
},
"required": [
"name",
"prefix",
"variables",
"streaming_variables"
]
}
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
"default": []
},
"current_dataset_id": {
"type": [
"string",
"null"
],
"title": "Current Dataset Id"
},
"version": {
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
],
"title": "Last Update"
}
},
"required": [
"datasets"
]
}

154
schemas/plc.schema.json Normal file
View File

@ -0,0 +1,154 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plc.schema.json",
"title": "PLC & UDP Configuration",
"description": "Schema to edit plc_config.json",
"type": "object",
"additionalProperties": false,
"properties": {
"plc_config": {
"type": "object",
"title": "PLC Configuration",
"additionalProperties": false,
"properties": {
"ip": {
"type": "string",
"title": "PLC IP",
"description": "IP address of the PLC (S7-31x)",
"format": "ipv4",
"pattern": "^.+$"
},
"rack": {
"type": "integer",
"title": "Rack",
"description": "Rack number (0-7)",
"minimum": 0,
"maximum": 7,
"default": 0
},
"slot": {
"type": "integer",
"title": "Slot",
"description": "Slot number (usually 2)",
"minimum": 0,
"maximum": 31,
"default": 2
}
},
"required": [
"ip",
"rack",
"slot"
]
},
"udp_config": {
"type": "object",
"title": "UDP Configuration",
"additionalProperties": false,
"properties": {
"host": {
"type": "string",
"title": "UDP Host",
"pattern": "^.+$",
"default": "127.0.0.1"
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 9870
}
},
"required": [
"host",
"port"
]
},
"sampling_interval": {
"type": "number",
"minimum": 0.01,
"maximum": 10,
"title": "Sampling Interval (s)",
"description": "Global sampling interval in seconds",
"default": 0.1
},
"csv_config": {
"type": "object",
"title": "CSV Recording",
"additionalProperties": false,
"properties": {
"records_directory": {
"type": "string",
"title": "Records Directory",
"default": "records"
},
"rotation_enabled": {
"type": "boolean",
"title": "Rotation",
"default": true,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"max_size_mb": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Size (MB)",
"default": 1000
},
"max_days": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Days",
"default": 30
},
"max_hours": {
"type": [
"integer",
"null"
],
"minimum": 1,
"title": "Max Hours",
"default": null
},
"cleanup_interval_hours": {
"type": "integer",
"minimum": 1,
"title": "Cleanup Interval (h)",
"default": 24
},
"last_cleanup": {
"type": [
"string",
"null"
],
"title": "Last Cleanup"
}
},
"required": [
"records_directory",
"rotation_enabled",
"cleanup_interval_hours"
]
}
},
"required": [
"plc_config",
"udp_config",
"sampling_interval",
"csv_config"
]
}

100
schemas/plots.schema.json Normal file
View File

@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "plots.schema.json",
"title": "Plot Sessions",
"description": "Schema to edit plot_sessions.json (plot sessions)",
"type": "object",
"additionalProperties": false,
"properties": {
"plots": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Plot Name",
"description": "Human-readable name of the plot session"
},
"variables": {
"type": "array",
"title": "Variables",
"description": "Variables to be plotted",
"items": {
"type": "string"
},
"minItems": 1
},
"time_window": {
"type": "integer",
"title": "Time window (s)",
"description": "Time window in seconds",
"minimum": 5,
"maximum": 3600,
"default": 60
},
"y_min": {
"type": [
"number",
"null"
],
"title": "Y Min",
"description": "Leave empty for auto"
},
"y_max": {
"type": [
"number",
"null"
],
"title": "Y Max",
"description": "Leave empty for auto"
},
"trigger_variable": {
"type": [
"string",
"null"
],
"title": "Trigger Variable"
},
"trigger_enabled": {
"type": "boolean",
"title": "Enable Trigger",
"default": false
},
"trigger_on_true": {
"type": "boolean",
"title": "Trigger on True",
"default": true
},
"session_id": {
"type": "string",
"title": "Session Id"
}
},
"required": [
"name",
"variables",
"time_window"
]
}
},
"session_counter": {
"type": "integer",
"minimum": 0,
"default": 0
},
"last_saved": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string",
"default": "1.0"
}
},
"required": [
"plots"
]
}

View File

@ -1337,4 +1337,64 @@ textarea {
.tab-btn.active {
border-right-color: var(--pico-primary);
}
}
/* =============================
Config Editor (JSON Schema) UI compact layout
Works for both JSONForm (#jsonform-form) and fallback renderer (.config-editor-form)
============================= */
/* Grid layout for JSONForm-generated form */
#jsonform-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
#jsonform-form .control-group {
margin: 0;
}
#jsonform-form .form-actions {
grid-column: 1 / -1;
}
/* Grid layout for fallback renderer */
.config-editor-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.config-editor-form>.form-group {
margin: 0;
}
/* Group inner properties in a compact grid */
.config-editor-form .object-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
border: var(--pico-border-width) solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
background: var(--pico-muted-background-color);
}
.config-editor-form .object-group>.form-group {
margin: 0;
}
/* Make number inputs a bit tighter to save space */
.config-editor-form input[type="number"] {
max-width: 100%;
}
/* Responsive tweak for very small screens */
@media (max-width: 480px) {
#jsonform-form,
.config-editor-form,
.config-editor-form .object-group {
grid-template-columns: 1fr;
}
}

BIN
static/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
static/icons/record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

541
static/js/config_editor.js Normal file
View File

@ -0,0 +1,541 @@
/**
* 🧩 Dynamic JSON Config Editor
* Construye formularios en base a JSON Schema y llama a endpoints /api/config
*/
(function () {
let schemasIndex = [];
let currentSchemaId = null;
let currentData = null;
document.addEventListener('DOMContentLoaded', () => {
const tabBtn = document.querySelector('.tab-btn[data-tab="config-editor"]');
if (!tabBtn) return;
// Cargar esquemas cuando se entra al tab
tabBtn.addEventListener('click', ensureSchemasLoadedOnce);
// Listeners de controles básicos
const saveBtn = document.getElementById('btn-save-config');
if (saveBtn) saveBtn.addEventListener('click', onSave);
const exportBtn = document.getElementById('btn-export-config');
if (exportBtn) exportBtn.addEventListener('click', onExport);
const importInput = document.getElementById('import-file');
if (importInput) importInput.addEventListener('change', onImport);
});
async function ensureSchemasLoadedOnce() {
if (schemasIndex.length > 0) return;
try {
const res = await fetch('/api/config/schemas');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to list schemas');
schemasIndex = data.schemas || [];
populateSchemaSelector();
} catch (e) {
showMessage(`Error loading schemas: ${e}`, 'error');
}
}
function populateSchemaSelector() {
const selector = document.getElementById('schema-selector');
if (!selector) return;
selector.innerHTML = '';
for (const s of schemasIndex) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${iconForSchema(s.id)} ${s.title || s.id}`;
selector.appendChild(opt);
}
selector.addEventListener('change', () => loadConfigAndSchema(selector.value));
if (schemasIndex.length > 0) {
selector.value = schemasIndex[0].id;
loadConfigAndSchema(selector.value);
}
}
function iconForSchema(id) {
if (id === 'plc') return '⚙️';
if (id === 'datasets') return '📊';
if (id === 'plots') return '📈';
return '🧩';
}
async function loadConfigAndSchema(schemaId) {
currentSchemaId = schemaId;
const container = document.getElementById('config-form-container');
if (container) container.innerHTML = 'Loading...';
try {
const [schemaRes, dataRes] = await Promise.all([
fetch(`/api/config/schema/${schemaId}`),
fetch(`/api/config/${schemaId}`)
]);
const schemaData = await schemaRes.json();
const configData = await dataRes.json();
if (!schemaData.success) throw new Error(schemaData.error || 'Schema error');
if (!configData.success) throw new Error(configData.error || 'Data error');
currentData = configData.data;
if (container) container.innerHTML = '';
// Prefer JSONForm if available for a simple form UI
if (window.$ && window._ && typeof $.fn.jsonForm === 'function') {
const formEl = document.createElement('form');
formEl.id = 'jsonform-form';
container.appendChild(formEl);
const formDef = ["*", { "type": "submit", "title": "Save" }];
$(formEl).jsonForm({
schema: schemaData.schema,
form: formDef,
value: currentData,
onSubmit: function (errors, values) {
if (errors) {
showMessage('Validation errors in form', 'error');
return false;
}
doSave(values);
return false;
}
});
const saveBtn = document.getElementById('btn-save-config');
if (saveBtn) {
saveBtn.onclick = () => {
const f = document.getElementById('jsonform-form');
if (f) f.requestSubmit ? f.requestSubmit() : f.dispatchEvent(new Event('submit', { cancelable: true }));
};
}
} else {
// Fallback: minimal manual renderer based on schema
renderForm(container, schemaData.schema, currentData);
}
} catch (e) {
if (container) container.innerHTML = '';
showMessage(`Error loading editor: ${e}`, 'error');
}
}
// Renderizado muy simple basado en schema: soporta object, string, number, integer, boolean, array básica
function renderForm(container, schema, data) {
if (!container) return;
container.innerHTML = '';
const form = document.createElement('div');
form.className = 'config-editor-form';
if (schema.type === 'object' && schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
const value = data ? data[key] : undefined;
const field = renderField(key, propSchema, value, [key]);
if (field) form.appendChild(field);
}
} else {
form.textContent = 'Unsupported schema root.';
}
container.appendChild(form);
}
function renderField(label, propSchema, value, path) {
const wrapper = document.createElement('div');
wrapper.className = 'form-group';
const title = document.createElement('label');
title.textContent = propSchema.title || label;
wrapper.appendChild(title);
// Optional description/help text
if (propSchema.description) {
const help = document.createElement('small');
help.textContent = propSchema.description;
help.style.display = 'block';
help.style.color = 'var(--pico-muted-color)';
help.style.marginTop = '-0.25rem';
help.style.marginBottom = '0.25rem';
wrapper.appendChild(help);
}
const type = Array.isArray(propSchema.type) ? propSchema.type : [propSchema.type];
// Objetos
if (type.includes('object')) {
const inner = document.createElement('div');
inner.className = 'object-group';
// Caso 1: propiedades conocidas
if (propSchema.properties) {
for (const [k, s] of Object.entries(propSchema.properties)) {
const v = value ? value[k] : undefined;
const child = renderField(k, s, v, path.concat(k));
if (child) inner.appendChild(child);
}
wrapper.appendChild(inner);
return wrapper;
}
// Caso 2: additionalProperties -> colección dinámica (key -> objeto)
if (propSchema.additionalProperties && typeof propSchema.additionalProperties === 'object') {
const entries = (value && typeof value === 'object') ? Object.entries(value) : [];
const list = document.createElement('div');
list.className = 'dynamic-object-list';
function renderEntries() {
list.innerHTML = '';
for (const [entryKey, entryVal] of entries) {
const row = document.createElement('div');
row.className = 'dynamic-object-row';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.value = entryKey;
keyInput.title = 'Key';
let currentKey = entryKey;
keyInput.addEventListener('change', () => {
const newKey = keyInput.value.trim();
if (!newKey || newKey === currentKey) return;
// Renombrar clave conservando valor
const parentObj = getPathObject(path, true);
if (parentObj[newKey] !== undefined) {
showMessage('Key already exists', 'error');
keyInput.value = currentKey;
return;
}
parentObj[newKey] = parentObj[currentKey];
delete parentObj[currentKey];
currentKey = newKey;
updateEntriesFromObject(parentObj);
setPathValue(path, parentObj);
renderEntries();
});
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'secondary';
delBtn.textContent = '🗑️';
delBtn.addEventListener('click', () => {
const parentObj = getPathObject(path, true);
delete parentObj[currentKey];
updateEntriesFromObject(parentObj);
setPathValue(path, parentObj);
renderEntries();
});
const valueContainer = document.createElement('div');
valueContainer.className = 'dynamic-object-value';
const child = renderField(currentKey, propSchema.additionalProperties, entryVal, path.concat(currentKey));
row.appendChild(keyInput);
row.appendChild(delBtn);
if (child) valueContainer.appendChild(child);
row.appendChild(valueContainer);
list.appendChild(row);
}
}
function updateEntriesFromObject(parentObj) {
const arr = Object.entries(parentObj);
entries.length = 0;
arr.forEach(e => entries.push(e));
}
renderEntries();
const addWrap = document.createElement('div');
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'outline';
addBtn.textContent = ' Add';
addBtn.addEventListener('click', () => {
const key = prompt('Enter key name');
if (!key) return;
const parentObj = getPathObject(path, true);
if (parentObj[key] !== undefined) {
showMessage('Key already exists', 'error');
return;
}
parentObj[key] = defaultForSchema(propSchema.additionalProperties);
setPathValue(path, parentObj);
updateEntriesFromObject(parentObj);
renderEntries();
});
addWrap.appendChild(addBtn);
inner.appendChild(list);
inner.appendChild(addWrap);
wrapper.appendChild(inner);
return wrapper;
}
// Objeto sin propiedades definidas
const note = document.createElement('div');
note.textContent = '(object)';
wrapper.appendChild(note);
return wrapper;
}
// Boolean con toggle opcional
if (type.includes('boolean')) {
const toggle = document.createElement('button');
toggle.type = 'button';
const labels = propSchema?.['x-ui']?.toggleLabels || ['On', 'Off'];
let current = !!value;
toggle.textContent = current ? labels[0] : labels[1];
toggle.className = current ? 'outline' : 'secondary';
toggle.addEventListener('click', () => {
current = !current;
setPathValue(path, current);
toggle.textContent = current ? labels[0] : labels[1];
toggle.className = current ? 'outline' : 'secondary';
});
wrapper.appendChild(toggle);
return wrapper;
}
// Enum (select) o string simple
if (propSchema.enum) {
const select = document.createElement('select');
for (const opt of propSchema.enum) {
const o = document.createElement('option');
o.value = opt;
o.textContent = String(opt).toUpperCase();
if (value === opt) o.selected = true;
select.appendChild(o);
}
select.addEventListener('change', () => setPathValue(path, select.value));
wrapper.appendChild(select);
return wrapper;
}
if (type.includes('number') || type.includes('integer')) {
const input = document.createElement('input');
input.type = 'number';
if (typeof value === 'number') input.value = String(value);
if (typeof propSchema.minimum !== 'undefined') input.min = String(propSchema.minimum);
if (typeof propSchema.maximum !== 'undefined') input.max = String(propSchema.maximum);
if (propSchema.step) input.step = String(propSchema.step);
input.addEventListener('input', () => {
const v = input.value === '' ? null : (type.includes('integer') ? parseInt(input.value) : parseFloat(input.value));
setPathValue(path, v);
});
wrapper.appendChild(input);
return wrapper;
}
if (type.includes('array')) {
const arrWrap = document.createElement('div');
arrWrap.className = 'array-group';
const list = document.createElement('div');
const arr = Array.isArray(value) ? value : [];
function renderItems() {
list.innerHTML = '';
arr.forEach((itemVal, idx) => {
const row = document.createElement('div');
row.className = 'array-item-row';
// Soporta items string simples por ahora
const itemInput = document.createElement('input');
itemInput.type = 'text';
itemInput.value = itemVal;
itemInput.addEventListener('input', () => {
arr[idx] = itemInput.value;
setPathValue(path, arr.slice());
});
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'secondary';
delBtn.textContent = '🗑️';
delBtn.addEventListener('click', () => {
arr.splice(idx, 1);
setPathValue(path, arr.slice());
renderItems();
});
row.appendChild(itemInput);
row.appendChild(delBtn);
list.appendChild(row);
});
}
renderItems();
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'outline';
addBtn.textContent = ' Add';
addBtn.addEventListener('click', () => {
arr.push('');
setPathValue(path, arr.slice());
renderItems();
});
arrWrap.appendChild(list);
arrWrap.appendChild(addBtn);
wrapper.appendChild(arrWrap);
return wrapper;
}
// Fallback: string
const input = document.createElement('input');
input.type = 'text';
input.value = value ?? '';
input.placeholder = propSchema.placeholder || '';
input.addEventListener('input', () => setPathValue(path, input.value));
wrapper.appendChild(input);
return wrapper;
}
function setPathValue(path, v) {
if (!currentData) currentData = {};
let cursor = currentData;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
cursor = cursor[key];
}
cursor[path[path.length - 1]] = v;
}
function getPathObject(path, createIfMissing = false) {
if (!currentData) currentData = {};
let cursor = currentData;
for (let i = 0; i < path.length; i++) {
const key = path[i];
if (i === path.length - 1) {
if (typeof cursor[key] !== 'object' || cursor[key] === null) {
if (createIfMissing) cursor[key] = {};
else return {};
}
return cursor[key];
}
if (!cursor[key] || typeof cursor[key] !== 'object') {
if (createIfMissing) cursor[key] = {};
else return {};
}
cursor = cursor[key];
}
return {};
}
function defaultForSchema(schema) {
const t = Array.isArray(schema.type) ? schema.type[0] : schema.type;
if (t === 'object') {
const obj = {};
if (schema.properties) {
for (const [k, s] of Object.entries(schema.properties)) {
if (typeof s.default !== 'undefined') obj[k] = s.default;
else obj[k] = defaultForSchema(s);
}
}
return obj;
}
if (t === 'array') return [];
if (t === 'boolean') return !!schema.default;
if (t === 'number' || t === 'integer') return typeof schema.default !== 'undefined' ? schema.default : 0;
if (t === 'string') return schema.default || '';
return null;
}
async function doSave(payload) {
if (!currentSchemaId) return;
try {
const res = await fetch(`/api/config/${currentSchemaId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.success) showMessage('Configuration saved successfully', 'success');
else showMessage(result.error || 'Failed to save configuration', 'error');
} catch (e) {
showMessage(`Error saving configuration: ${e}`, 'error');
}
}
async function onSave() {
if (!currentSchemaId) return;
try {
// If JSONForm exists, its Save is already wired; fall back to currentData
let payload = currentData || {};
if (window.__jsonEditorInstance && typeof window.__jsonEditorInstance.get === 'function') {
// Legacy safety; should not happen because we no longer render JSONEditor
payload = window.__jsonEditorInstance.get();
}
await doSave(payload);
} catch (e) {
showMessage(`Error saving configuration: ${e}`, 'error');
}
}
async function onExport() {
if (!currentSchemaId) return;
try {
let val = currentData || {};
// Prefer currentData; JSONForm updates are applied on submit
if (window.__jsonEditorInstance && typeof window.__jsonEditorInstance.get === 'function') {
val = window.__jsonEditorInstance.get();
}
const blob = new Blob([JSON.stringify(val, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentSchemaId}_export.json`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
} catch (e) {
showMessage(`Error exporting configuration: ${e}`, 'error');
}
}
async function onImport(evt) {
const file = evt.target.files && evt.target.files[0];
if (!file || !currentSchemaId) return;
try {
const text = await file.text();
const json = JSON.parse(text);
currentData = json;
const formEl = document.getElementById('jsonform-form');
if (formEl && window.$ && typeof $(formEl).jsonForm === 'function') {
const schemaRes = await fetch(`/api/config/schema/${currentSchemaId}`);
const schemaData = await schemaRes.json();
if (!schemaData.success) throw new Error(schemaData.error || 'Schema error');
$(formEl).jsonForm({
schema: schemaData.schema,
form: ["*", { "type": "submit", "title": "Save" }],
value: currentData,
onSubmit: function (errors, values) {
if (errors) return showMessage('Validation errors in form', 'error');
doSave(values);
return false;
}
});
} else {
const res = await fetch(`/api/config/schema/${currentSchemaId}`);
const schemaData = await res.json();
if (!schemaData.success) throw new Error(schemaData.error || 'Schema error');
renderForm(document.getElementById('config-form-container'), schemaData.schema, currentData);
}
showMessage('JSON imported (not saved yet)', 'info');
} catch (e) {
showMessage(`Invalid JSON: ${e}`, 'error');
} finally {
evt.target.value = '';
}
}
})();

View File

@ -3,10 +3,10 @@
"should_connect": true,
"should_stream": false,
"active_datasets": [
"DAR",
"Fast"
"Fast",
"DAR"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-10T00:37:18.130615"
"last_update": "2025-08-10T01:45:12.574799"
}

View File

@ -12,6 +12,8 @@
<!-- Custom styles -->
<link rel="stylesheet" href="/static/css/styles.css">
<!-- JSONEditor (tree view) CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsoneditor@9.10.2/dist/jsoneditor.min.css">
</head>
<body>
@ -23,6 +25,10 @@
</div>
<main class="container">
<div class="info-section" style="margin: 1rem 0;">
<p><strong>Notice:</strong> Esta UI ha sido reemplazada por la SPA React. Accede a <a href="/app">/app</a>.
Esta página no se usa en producción.</p>
</div>
<!-- Header -->
<header class="header">
<h1>
@ -127,6 +133,7 @@
<nav class="tabs">
<button class="tab-btn active" data-tab="datasets">📊 Datasets & Variables</button>
<button class="tab-btn" data-tab="plotting">📈 Real-Time Plotting</button>
<button class="tab-btn" data-tab="config-editor">🧩 Config Editor</button>
<button class="tab-btn" data-tab="events">📋 Events & Logs</button>
</nav>
@ -467,6 +474,35 @@
</article>
</div>
<!-- 🧩 CONFIG EDITOR TAB -->
<div class="tab-content" id="config-editor-tab">
<article>
<header>🧩 Dynamic JSON Config Editor</header>
<div class="info-section">
<p><strong>Esquemas:</strong> Selecciona un esquema y edita PLC, Datasets o Plot Sessions con
formularios dinámicos.</p>
<p><strong>Import/Export:</strong> Puedes importar desde un archivo JSON o exportar el actual.</p>
</div>
<div class="grid">
<div>
<label>Schema</label>
<select id="schema-selector"></select>
</div>
<div class="controls" style="align-self: end; display: flex; gap: .5rem;">
<button id="btn-export-config" class="outline">⬇️ Export</button>
<label class="outline" style="margin: 0;">
⬆️ Import <input type="file" id="import-file" accept="application/json"
style="display:none;">
</label>
<button id="btn-save-config">💾 Save</button>
</div>
</div>
<div id="config-form-container" style="margin-top: 1rem;"></div>
</article>
</div>
<!-- 📈 PLOTTING TAB -->
<div class="tab-content" id="plotting-tab">
<article>
@ -760,6 +796,13 @@
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1"></script>
<script src="https://unpkg.com/chartjs-plugin-zoom@1.2.1/dist/chartjs-plugin-zoom.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0"></script>
<!-- JSONEditor (tree view) -->
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.10.2/dist/jsoneditor.min.js"></script>
<!-- JSONForm deps (jQuery + Underscore) and JSONForm -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.13.6/underscore-min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jsonform/jsonform@2.5.1/lib/deps/opt/jsv.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jsonform/jsonform@2.5.1/lib/jsonform.js"></script>
<!-- JavaScript Modules -->
<script src="/static/js/utils.js"></script>
@ -773,6 +816,7 @@
<script src="/static/js/events.js"></script>
<script src="/static/js/tabs.js"></script>
<script src="/static/js/plotting.js"></script>
<script src="/static/js/config_editor.js"></script>
<script src="/static/js/main.js"></script>