Compare commits
6 Commits
5e2149b9d4
...
09eccf5c0b
Author | SHA1 | Date |
---|---|---|
|
09eccf5c0b | |
|
1833fff18f | |
|
0c11ee3ae2 | |
|
593487e52f | |
|
5581e26d10 | |
|
10df4e94bd |
File diff suppressed because it is too large
Load Diff
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -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 |
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function PlotsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Real-Time Plotting</h1>
|
||||
<p>This page will contain the real-time plots.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { getStatus } from '../services/api.js'
|
||||
|
||||
function StatusItem({ label, value }) {
|
||||
return (
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="text-muted small">{label}</div>
|
||||
<div className="fw-semibold">{String(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getStatus()
|
||||
setStatus(data)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Error fetching status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container py-3">
|
||||
<h2 className="h4 mb-3">Status</h2>
|
||||
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<button className="btn btn-primary btn-sm" onClick={load} disabled={loading}>
|
||||
{loading ? 'Cargando...' : 'Refrescar'}
|
||||
</button>
|
||||
<a className="btn btn-outline-secondary btn-sm" href="/api/status" target="_blank" rel="noreferrer">/api/status</a>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && loading && (
|
||||
<div className="alert alert-info">Cargando estado...</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="row">
|
||||
{Object.entries(status).map(([k, v]) => (
|
||||
<StatusItem key={k} label={k} value={typeof v === 'object' ? JSON.stringify(v) : v} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
const BASE_URL = '' // same origin (Flask serves Vite build)
|
||||
|
||||
function toJsonOrThrow(res) {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getStatus() {
|
||||
const res = await fetch(`${BASE_URL}/api/status`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getEvents(limit = 50) {
|
||||
const res = await fetch(`${BASE_URL}/api/events?limit=${encodeURIComponent(limit)}`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// PLC control
|
||||
export async function connectPlc() {
|
||||
const res = await fetch(`${BASE_URL}/api/plc/connect`, { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function disconnectPlc() {
|
||||
const res = await fetch(`${BASE_URL}/api/plc/disconnect`, { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// UDP streaming control
|
||||
export async function startUdpStreaming() {
|
||||
const res = await fetch(`${BASE_URL}/api/udp/streaming/start`, { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function stopUdpStreaming() {
|
||||
const res = await fetch(`${BASE_URL}/api/udp/streaming/stop`, { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Config schemas and data
|
||||
export async function listSchemas() {
|
||||
const res = await fetch(`${BASE_URL}/api/config/schemas`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getSchema(schemaId) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/schema/${encodeURIComponent(schemaId)}`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function readConfig(configId) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function writeConfig(configId, data) {
|
||||
const res = await fetch(`${BASE_URL}/api/config/${encodeURIComponent(configId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(data ?? {}),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Helpers for future modules
|
||||
export async function getJson(path) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function postJson(path, body) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function putJson(path, body) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
|
@ -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
223
main.py
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -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 = '';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue