Compare commits
No commits in common. "81e5ddec57b1eb77bd2b7e5027d57bbed3a3463d" and "e77a5c39327fa19fab4a302adde72b55297b91fe" have entirely different histories.
81e5ddec57
...
e77a5c3932
59
GEMINI.md
59
GEMINI.md
|
@ -1,59 +0,0 @@
|
|||
# Project Overview
|
||||
|
||||
This project is a web-based application for monitoring and logging data from a Siemens S7 PLC. It consists of a Python backend and a React frontend.
|
||||
|
||||
**Backend:**
|
||||
|
||||
* **Framework:** Flask
|
||||
* **PLC Communication:** `python-snap7`
|
||||
* **Data Processing:** pandas, numpy
|
||||
* **Functionality:**
|
||||
* Provides a REST API for interacting with the PLC.
|
||||
* Streams data from the PLC in real-time.
|
||||
* Logs data to CSV files with rotation and cleanup.
|
||||
* Manages PLC and application configurations.
|
||||
* Serves the React frontend.
|
||||
|
||||
**Frontend:**
|
||||
|
||||
* **Framework:** React
|
||||
* **Build Tool:** Vite
|
||||
* **UI Library:** Chakra UI
|
||||
* **Charting:** Chart.js
|
||||
* **Internationalization:** i18next
|
||||
* **Functionality:**
|
||||
* Provides a user interface for configuring the PLC connection and data logging.
|
||||
* Displays real-time data from the PLC in charts.
|
||||
* Allows browsing and visualizing historical data.
|
||||
|
||||
# Building and Running
|
||||
|
||||
**1. Install Dependencies:**
|
||||
|
||||
* **Backend:** `pip install -r requirements.txt`
|
||||
* **Frontend:** `cd frontend && npm install`
|
||||
|
||||
**2. Build the Frontend:**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
**3. Run the Application:**
|
||||
|
||||
* **Development:**
|
||||
1. Start the backend: `python main.py`
|
||||
2. Start the frontend dev server: `cd frontend && npm run dev`
|
||||
* **Production (standalone executable):**
|
||||
1. Build the frontend as described above.
|
||||
2. Clean previous builds: `rmdir /s /q build dist`
|
||||
3. Run PyInstaller: `conda activate snap7v12; pyinstaller main.spec --clean`
|
||||
|
||||
The production build will create a single executable file in the `dist` directory.
|
||||
|
||||
# Development Conventions
|
||||
|
||||
* The backend follows a modular structure with a `core` directory containing the main application logic.
|
||||
* The frontend is located in the `frontend` directory and follows standard React project conventions.
|
||||
* Configuration is managed through JSON files in the `config/data` directory.
|
||||
* The application uses a rotating logger system for the backend, with logs stored in the `.logs` directory.
|
38832
application_events.json
38832
application_events.json
File diff suppressed because it is too large
Load Diff
91
build.ps1
91
build.ps1
|
@ -1,8 +1,6 @@
|
|||
# PowerShell Build Script for S7 Streamer & Logger
|
||||
# Compatible with PowerShell Core and Windows PowerShell
|
||||
|
||||
|
||||
|
||||
Write-Host "Starting build process..." -ForegroundColor Green
|
||||
|
||||
# Step 1: Build Frontend
|
||||
|
@ -12,7 +10,7 @@ try {
|
|||
npm run build
|
||||
Write-Host "Frontend build completed" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "Frontend build failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Frontend build failed: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
@ -40,15 +38,8 @@ try {
|
|||
|
||||
Write-Host "Build completed successfully!" -ForegroundColor Green
|
||||
Write-Host "Results available in: dist/main/" -ForegroundColor Cyan
|
||||
|
||||
# Wait for PyInstaller to fully release file handles
|
||||
Write-Host "Waiting for file handles to be released..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
|
||||
|
||||
} catch {
|
||||
Write-Host "PyInstaller build failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "PyInstaller build failed: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
@ -86,67 +77,7 @@ try {
|
|||
if (-not $sevenZipFound) {
|
||||
Write-Host "Using PowerShell built-in compression..." -ForegroundColor Cyan
|
||||
$tempZip = ".\$zipName"
|
||||
|
||||
# Retry compression up to 3 times with delays
|
||||
$retryCount = 0
|
||||
$maxRetries = 3
|
||||
$compressionSuccess = $false
|
||||
|
||||
while ($retryCount -lt $maxRetries -and -not $compressionSuccess) {
|
||||
try {
|
||||
if ($retryCount -gt 0) {
|
||||
Write-Host "Retrying compression (attempt $($retryCount + 1)/$maxRetries)..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
# Force garbage collection to release any lingering file handles
|
||||
[System.GC]::Collect()
|
||||
[System.GC]::WaitForPendingFinalizers()
|
||||
|
||||
Compress-Archive -Path ".\dist\main\*" -DestinationPath $tempZip -Force
|
||||
$compressionSuccess = $true
|
||||
|
||||
} catch {
|
||||
$retryCount++
|
||||
Write-Host "Compression attempt $retryCount failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
|
||||
if ($retryCount -eq $maxRetries) {
|
||||
# Final attempt: try creating a copy first, then compress
|
||||
Write-Host "Trying alternative method: copy then compress..." -ForegroundColor Yellow
|
||||
$tempDir = ".\temp_build_$timestamp"
|
||||
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
}
|
||||
|
||||
# Copy files to temporary directory
|
||||
Copy-Item -Path ".\dist\main" -Destination $tempDir -Recurse -Force
|
||||
|
||||
# Wait a moment
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# Try to compress the copy
|
||||
try {
|
||||
Compress-Archive -Path "$tempDir\*" -DestinationPath $tempZip -Force
|
||||
$compressionSuccess = $true
|
||||
|
||||
# Clean up temp directory
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
|
||||
} catch {
|
||||
# Clean up temp directory
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Recurse -Force $tempDir
|
||||
}
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $compressionSuccess) {
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path ".\dist\main\*" -DestinationPath $tempZip -Force
|
||||
|
||||
# Move to destination
|
||||
$zipFullPath = Join-Path $destinationPath $zipName
|
||||
|
@ -160,22 +91,8 @@ try {
|
|||
$fileSize = (Get-Item $zipFullPath).Length / 1MB
|
||||
Write-Host "Archive size: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Gray
|
||||
|
||||
# Open Windows Explorer in the destination directory
|
||||
Write-Host "Opening destination folder..." -ForegroundColor Yellow
|
||||
try {
|
||||
# Use Windows Explorer to open the destination directory and select the ZIP file
|
||||
explorer.exe /select,"$zipFullPath"
|
||||
} catch {
|
||||
# Fallback: just open the directory
|
||||
try {
|
||||
explorer.exe "$destinationPath"
|
||||
} catch {
|
||||
Write-Host "Could not open Explorer automatically. File location: $zipFullPath" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host "Compression failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "Compression failed: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"csv_config": {
|
||||
"max_days": 30,
|
||||
"max_size_mb": 1000,
|
||||
"records_directory": "C:/Trabajo/SIDEL/09 - SAE452 - Diet as Regular - San Giorgio in Bosco/Reporte/LogRecords",
|
||||
"records_directory": "records",
|
||||
"rotation_enabled": true
|
||||
},
|
||||
"plc_config": {
|
||||
|
|
|
@ -28,14 +28,9 @@
|
|||
},
|
||||
"records_directory": {
|
||||
"default": "records",
|
||||
"description": "Directory to save *.csv files. Use absolute path (e.g. C:\\data) or relative path (e.g. records)",
|
||||
"description": "Directory to save *.csv files",
|
||||
"title": "Records Directory",
|
||||
"type": "string",
|
||||
"options": {
|
||||
"widget": "path-browser",
|
||||
"mode": "directory",
|
||||
"title": "Select Records Directory"
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"rotation_enabled": {
|
||||
"default": true,
|
||||
|
@ -77,13 +72,10 @@
|
|||
},
|
||||
"symbols_path": {
|
||||
"title": "Symbols File Path",
|
||||
"description": "Path to the ASC symbol file for this PLC. Use absolute path or relative path",
|
||||
"description": "Path to the ASC symbol file for this PLC",
|
||||
"type": "string",
|
||||
"options": {
|
||||
"widget": "path-browser",
|
||||
"mode": "file",
|
||||
"title": "Select ASC Symbol File",
|
||||
"filetypes": [["ASC Files", "*.asc"], ["All Files", "*.*"]]
|
||||
"widget": "file-path"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -19,33 +19,33 @@
|
|||
]
|
||||
],
|
||||
"id": {
|
||||
"ui:description": "Unique ID for this dataset (alphanumeric, underscore, dash)",
|
||||
"ui:help": "Unique ID for this dataset (alphanumeric, underscore, dash)",
|
||||
"ui:placeholder": "e.g., DAR, Fast"
|
||||
},
|
||||
"name": {
|
||||
"ui:description": "Human-readable name for this dataset",
|
||||
"ui:help": "Human-readable name for this dataset",
|
||||
"ui:placeholder": "e.g., Temperature Sensors, Production Line A"
|
||||
},
|
||||
"prefix": {
|
||||
"ui:description": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
|
||||
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
|
||||
"ui:placeholder": "e.g., temp, line_a, sensors"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:description": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:widget": "switch"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:description": "Custom sampling interval in seconds (0.01–10). Leave empty to use the global PLC sampling interval.",
|
||||
"ui:help": "Custom sampling interval in seconds (0.01–10). Leave empty to use the global PLC sampling interval.",
|
||||
"ui:placeholder": "Leave empty to use global interval",
|
||||
"ui:widget": "updown",
|
||||
"ui:options": { "step": 0.01, "min": 0.01, "max": 10 }
|
||||
"ui:widget": "updown",
|
||||
"ui:options": { "step": 0.01, "min": 0.01, "max": 10 }
|
||||
},
|
||||
"use_optimized_reading": {
|
||||
"ui:description": "📊 Enable optimized batch reading for better performance. Disable if experiencing compatibility issues with older PLC firmware.",
|
||||
"ui:help": "📊 Enable optimized batch reading for better performance. Disable if experiencing compatibility issues with older PLC firmware.",
|
||||
"ui:widget": "switch"
|
||||
},
|
||||
"created": {
|
||||
"ui:description": "Timestamp when this dataset was created",
|
||||
"ui:help": "Timestamp when this dataset was created",
|
||||
"ui:readonly": true,
|
||||
"ui:widget": "text"
|
||||
}
|
||||
|
|
|
@ -14,10 +14,11 @@
|
|||
"dataset_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Enter unique dataset identifier",
|
||||
"ui:description": "🆔 Unique identifier for this dataset (must match existing dataset)"
|
||||
"ui:help": "🆔 Unique identifier for this dataset (must match existing dataset)"
|
||||
},
|
||||
"variables": {
|
||||
"ui:description": "🔧 Define PLC memory locations, data types, and properties for each variable",
|
||||
"ui:description": "🔧 PLC Variable Definitions",
|
||||
"ui:help": "Define PLC memory locations, data types, and properties for each variable",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
|
@ -81,7 +82,7 @@
|
|||
],
|
||||
"configType": {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "Choose between manual configuration or symbol-based setup",
|
||||
"ui:help": "Choose between manual configuration or symbol-based setup",
|
||||
"ui:options": {
|
||||
"enumOptions": [
|
||||
{
|
||||
|
@ -98,16 +99,16 @@
|
|||
"name": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Variable name",
|
||||
"ui:description": "📝 Human-readable name for this variable"
|
||||
"ui:help": "📝 Human-readable name for this variable"
|
||||
},
|
||||
"symbol": {
|
||||
"ui:widget": "dataset-variable-symbol",
|
||||
"ui:placeholder": "Select a PLC symbol...",
|
||||
"ui:description": "🔍 Search and select a symbol from the loaded ASC file"
|
||||
"ui:help": "🔍 Search and select a symbol from the loaded ASC file"
|
||||
},
|
||||
"area": {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
|
||||
"ui:help": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
|
||||
"ui:options": {
|
||||
"enumOptions": [
|
||||
{
|
||||
|
@ -155,20 +156,22 @@
|
|||
},
|
||||
"db": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "⚠️ Data Block number (only required for DB area - will be ignored for other areas like PE, PA, MW, etc.)",
|
||||
"ui:placeholder": "1011"
|
||||
"ui:help": "⚠️ Data Block number (only required for DB area - will be ignored for other areas like PE, PA, MW, etc.)",
|
||||
"ui:placeholder": "1011",
|
||||
"ui:description": "🗃️ This field is only used when Area = 'DB (Data Block)'"
|
||||
},
|
||||
"offset": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "Byte offset within the memory area"
|
||||
"ui:help": "Byte offset within the memory area"
|
||||
},
|
||||
"bit": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "⚠️ Bit position (0-7) - only required for BOOL data type, will be ignored for other types"
|
||||
"ui:help": "⚠️ Bit position (0-7) - only required for BOOL data type, will be ignored for other types",
|
||||
"ui:description": "✅ This field is only used when Type = 'BOOL (1-bit boolean)'"
|
||||
},
|
||||
"type": {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "PLC data type",
|
||||
"ui:help": "PLC data type",
|
||||
"ui:options": {
|
||||
"enumOptions": [
|
||||
{
|
||||
|
@ -220,7 +223,7 @@
|
|||
},
|
||||
"streaming": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "📡 Enable real-time streaming to PlotJuggler for visualization"
|
||||
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,13 +14,7 @@
|
|||
},
|
||||
"records_directory": {
|
||||
"ui:column": 3,
|
||||
"ui:placeholder": "records or C:\\data",
|
||||
"ui:widget": "path-browser",
|
||||
"ui:options": {
|
||||
"mode": "directory",
|
||||
"title": "Select Records Directory"
|
||||
},
|
||||
"ui:description": "💾 Directory for CSV files. Relative paths based on app directory, absolute paths (C:\\folder) used as-is."
|
||||
"ui:placeholder": "records"
|
||||
},
|
||||
"rotation_enabled": {
|
||||
"ui:column": 3,
|
||||
|
@ -63,29 +57,20 @@
|
|||
"plc_config": {
|
||||
"ip": {
|
||||
"ui:column": 6,
|
||||
"ui:placeholder": "192.168.1.100",
|
||||
"ui:description": "🌐 IP address of the Siemens S7-300/400 PLC (e.g., 192.168.1.100)"
|
||||
"ui:placeholder": "192.168.1.100"
|
||||
},
|
||||
"rack": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "🏗️ PLC rack number (usually 0 for S7-300/400)"
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"slot": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "🔌 PLC slot number (typically 2 for CPU)"
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"symbols_path": {
|
||||
"ui:column": 12,
|
||||
"ui:widget": "path-browser",
|
||||
"ui:placeholder": "Select ASC symbol file...",
|
||||
"ui:options": {
|
||||
"mode": "file",
|
||||
"title": "Select ASC Symbol File",
|
||||
"filetypes": [["ASC Files", "*.asc"], ["All Files", "*.*"]]
|
||||
},
|
||||
"ui:description": "📁 Select the ASC symbol file from TIA Portal export. Use Load Symbols button to process."
|
||||
"ui:widget": "file-path",
|
||||
"ui:placeholder": "Select ASC symbol file..."
|
||||
},
|
||||
"ui:column": 12,
|
||||
"ui:layout": [
|
||||
|
@ -128,7 +113,7 @@
|
|||
},
|
||||
"sampling_interval": {
|
||||
"ui:column": 4,
|
||||
"ui:description": "⏱️ Time interval between UDP data transmissions for real-time streaming",
|
||||
"ui:help": "⏱️ Time interval between UDP data transmissions for real-time streaming",
|
||||
"ui:widget": "updown"
|
||||
},
|
||||
"ui:column": 12,
|
||||
|
|
|
@ -89,61 +89,61 @@
|
|||
"id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "plot_1",
|
||||
"ui:description": "🆔 Unique identifier for this plot"
|
||||
"ui:help": "🆔 Unique identifier for this plot"
|
||||
},
|
||||
"name": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "My Plot",
|
||||
"ui:description": "📊 Human-readable name for the plot"
|
||||
"ui:help": "📊 Human-readable name for the plot"
|
||||
},
|
||||
"session_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "plot_1",
|
||||
"ui:description": "🔗 Session identifier (usually same as ID)"
|
||||
"ui:help": "🔗 Session identifier (usually same as ID)"
|
||||
},
|
||||
"time_window": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "⏱️ Time window in seconds (5-3600)"
|
||||
"ui:help": "⏱️ Time window in seconds (5-3600)"
|
||||
},
|
||||
"y_min": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "📉 Minimum Y axis value (leave empty for auto)"
|
||||
"ui:help": "📉 Minimum Y axis value (leave empty for auto)"
|
||||
},
|
||||
"y_max": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "📈 Maximum Y axis value (leave empty for auto)"
|
||||
"ui:help": "📈 Maximum Y axis value (leave empty for auto)"
|
||||
},
|
||||
"trigger_variable": {
|
||||
"ui:widget": "text",
|
||||
"ui:description": "🎯 Variable name to use as trigger (optional)"
|
||||
"ui:help": "🎯 Variable name to use as trigger (optional)"
|
||||
},
|
||||
"trigger_enabled": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "✅ Enable trigger-based recording"
|
||||
"ui:help": "✅ Enable trigger-based recording"
|
||||
},
|
||||
"trigger_on_true": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "🔄 Trigger when variable becomes true (vs false)"
|
||||
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
|
||||
},
|
||||
"line_tension": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
|
||||
"ui:help": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
|
||||
},
|
||||
"stepped": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "📊 Enable stepped line style instead of curves"
|
||||
"ui:help": "📊 Enable stepped line style instead of curves"
|
||||
},
|
||||
"stacked": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "📚 Enable stacked Y-axes for multi-axis visualization"
|
||||
"ui:help": "📚 Enable stacked Y-axes for multi-axis visualization"
|
||||
},
|
||||
"point_radius": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "🔴 Size of data points (0-10)"
|
||||
"ui:help": "🔴 Size of data points (0-10)"
|
||||
},
|
||||
"point_hover_radius": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "🎯 Size of points when hovering (0-15)"
|
||||
"ui:help": "🎯 Size of points when hovering (0-15)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -14,10 +14,11 @@
|
|||
"plot_id": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Enter unique plot identifier",
|
||||
"ui:description": "🆔 Unique identifier for this plot session (must match existing plot)"
|
||||
"ui:help": "🆔 Unique identifier for this plot session (must match existing plot)"
|
||||
},
|
||||
"variables": {
|
||||
"ui:description": "🎨 Configure colors and display settings for each variable in the plot",
|
||||
"ui:description": "🎨 Plot Variable Configuration",
|
||||
"ui:help": "Configure colors and display settings for each variable in the plot",
|
||||
"ui:options": {
|
||||
"addable": true,
|
||||
"orderable": true,
|
||||
|
@ -63,16 +64,16 @@
|
|||
"variable_name": {
|
||||
"ui:widget": "variableSelector",
|
||||
"ui:placeholder": "Search and select variable from datasets...",
|
||||
"ui:description": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
},
|
||||
"label": {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Chart legend label...",
|
||||
"ui:description": "📊 Label shown in the plot legend for this variable"
|
||||
"ui:help": "📊 Label shown in the plot legend for this variable"
|
||||
},
|
||||
"color": {
|
||||
"ui:widget": "color",
|
||||
"ui:description": "🎨 Select the color for this variable in the plot",
|
||||
"ui:help": "🎨 Select the color for this variable in the plot",
|
||||
"ui:placeholder": "#3498db",
|
||||
"ui:options": {
|
||||
"presetColors": [
|
||||
|
@ -93,15 +94,15 @@
|
|||
},
|
||||
"line_width": {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "📏 Width of the line in the plot (1-10 pixels)"
|
||||
"ui:help": "📏 Width of the line in the plot (1-10 pixels)"
|
||||
},
|
||||
"y_axis": {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "📊 Which Y-axis to use for this variable (left or right)"
|
||||
"ui:help": "📊 Which Y-axis to use for this variable (left or right)"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:widget": "switch",
|
||||
"ui:description": "📊 Enable this variable to be displayed in the real-time plot"
|
||||
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,20 +23,7 @@ def resource_path(relative_path):
|
|||
|
||||
|
||||
def external_path(relative_path):
|
||||
"""Get path external to PyInstaller bundle (for records, logs, etc.)
|
||||
|
||||
Handles both absolute and relative paths:
|
||||
- If path starts with drive letter (Windows) or / (Unix), treat as absolute
|
||||
- Otherwise treat as relative to executable/script directory
|
||||
"""
|
||||
if not relative_path:
|
||||
return relative_path
|
||||
|
||||
# Check if path is absolute
|
||||
if os.path.isabs(relative_path):
|
||||
return relative_path
|
||||
|
||||
# Handle relative paths
|
||||
"""Get path external to PyInstaller bundle (for records, logs, etc.)"""
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as PyInstaller executable - use directory next to exe
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
|
@ -71,12 +58,7 @@ class ConfigManager:
|
|||
self.state_file = external_path("system_state.json")
|
||||
|
||||
# Default configurations
|
||||
self.plc_config = {
|
||||
"ip": "192.168.1.100",
|
||||
"rack": 0,
|
||||
"slot": 2,
|
||||
"symbols_path": "",
|
||||
}
|
||||
self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2}
|
||||
self.udp_config = {"host": "127.0.0.1", "port": 9870, "sampling_interval": 1.0}
|
||||
self.sampling_interval = 0.1 # Legacy fallback
|
||||
|
||||
|
@ -453,22 +435,10 @@ class ConfigManager:
|
|||
return external_path(base)
|
||||
|
||||
# PLC Configuration Methods
|
||||
def update_plc_config(
|
||||
self, ip: str, rack: int, slot: int, symbols_path: str = None
|
||||
):
|
||||
def update_plc_config(self, ip: str, rack: int, slot: int):
|
||||
"""Update PLC configuration"""
|
||||
old_config = self.plc_config.copy()
|
||||
|
||||
# Preserve existing symbols_path if not provided
|
||||
if symbols_path is None:
|
||||
symbols_path = self.plc_config.get("symbols_path", "")
|
||||
|
||||
self.plc_config = {
|
||||
"ip": ip,
|
||||
"rack": rack,
|
||||
"slot": slot,
|
||||
"symbols_path": symbols_path,
|
||||
}
|
||||
self.plc_config = {"ip": ip, "rack": rack, "slot": slot}
|
||||
self.save_configuration()
|
||||
return {"old_config": old_config, "new_config": self.plc_config}
|
||||
|
||||
|
@ -537,7 +507,9 @@ class ConfigManager:
|
|||
self.save_configuration()
|
||||
return {"old_config": old_config, "new_config": self.csv_config}
|
||||
|
||||
|
||||
def get_csv_directory_path(self) -> str:
|
||||
"""Get the configured CSV directory path"""
|
||||
return self.csv_config["records_directory"]
|
||||
|
||||
def get_csv_file_directory_path(self) -> str:
|
||||
"""Get the directory path for current day's CSV files"""
|
||||
|
|
|
@ -359,13 +359,9 @@ class PLCDataStreamer:
|
|||
)
|
||||
|
||||
# Configuration Methods
|
||||
def update_plc_config(
|
||||
self, ip: str, rack: int, slot: int, symbols_path: str = None
|
||||
):
|
||||
def update_plc_config(self, ip: str, rack: int, slot: int):
|
||||
"""Update PLC configuration"""
|
||||
config_details = self.config_manager.update_plc_config(
|
||||
ip, rack, slot, symbols_path
|
||||
)
|
||||
config_details = self.config_manager.update_plc_config(ip, rack, slot)
|
||||
self.event_logger.log_event(
|
||||
"info",
|
||||
"config_change",
|
||||
|
|
|
@ -230,10 +230,6 @@ class ConfigSchemaManager:
|
|||
"slot", self.config_manager.plc_config.get("slot", 2)
|
||||
)
|
||||
),
|
||||
plc_cfg.get(
|
||||
"symbols_path",
|
||||
self.config_manager.plc_config.get("symbols_path", ""),
|
||||
),
|
||||
)
|
||||
if udp_cfg:
|
||||
self.config_manager.update_udp_config(
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@rjsf/chakra-ui": "^5.24.12",
|
||||
|
|
|
@ -26,7 +26,7 @@ function Home() {
|
|||
<Box>
|
||||
<Heading as="h2" size="sm" mb={2}>Acciones rápidas</Heading>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Button as="a" href="/" colorScheme="blue" size="sm">Reload SPA</Button>
|
||||
<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>
|
||||
|
|
|
@ -13,15 +13,12 @@ import {
|
|||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
useToast
|
||||
IconButton
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon } from '@chakra-ui/icons'
|
||||
import { FiUpload } from 'react-icons/fi'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import TooltipFieldTemplate from './rjsf/TooltipFieldTemplate.jsx'
|
||||
import { widgets } from './rjsf/widgets.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
|
@ -38,12 +35,10 @@ export default function PLCConfigManager() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [loadingSymbols, setLoadingSymbols] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
|
@ -102,61 +97,6 @@ export default function PLCConfigManager() {
|
|||
setCurrentData(formData)
|
||||
}
|
||||
|
||||
const handleLoadSymbols = async () => {
|
||||
const symbolsPath = currentData?.plc_config?.symbols_path
|
||||
|
||||
if (!symbolsPath) {
|
||||
toast({
|
||||
title: 'No File Selected',
|
||||
description: 'Please select an ASC file first',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingSymbols(true)
|
||||
|
||||
const response = await fetch('/api/symbols/load', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
asc_file_path: symbolsPath
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: 'Symbols Loaded',
|
||||
description: `Successfully loaded ${data.symbols_count} symbols`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
setMessage(`✅ Successfully loaded ${data.symbols_count} symbols from ASC file`)
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to load symbols')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to load symbols: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
})
|
||||
setMessage(`❌ Error loading symbols: ${error.message}`)
|
||||
} finally {
|
||||
setLoadingSymbols(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading PLC configuration...</Text>
|
||||
}
|
||||
|
@ -216,18 +156,6 @@ export default function PLCConfigManager() {
|
|||
onClick={handleEdit}
|
||||
/>
|
||||
)}
|
||||
{/* Load Symbols button - always available when symbols_path is set */}
|
||||
<Button
|
||||
leftIcon={<FiUpload />}
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
variant="outline"
|
||||
onClick={handleLoadSymbols}
|
||||
isLoading={loadingSymbols}
|
||||
isDisabled={!currentData?.plc_config?.symbols_path || loadingSymbols}
|
||||
>
|
||||
Load Symbols
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
|
@ -242,10 +170,7 @@ export default function PLCConfigManager() {
|
|||
validator={validator}
|
||||
onChange={editing ? handleChange : () => { }}
|
||||
onSubmit={editing ? () => handleSave() : undefined}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={widgets}
|
||||
readonly={!editing}
|
||||
showErrorList={false}
|
||||
|
|
|
@ -45,7 +45,6 @@ import Form from '@rjsf/chakra-ui'
|
|||
import validator from '@rjsf/validator-ajv8'
|
||||
import allWidgets from './widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate'
|
||||
import TooltipFieldTemplate from './rjsf/TooltipFieldTemplate'
|
||||
import PlotHistoricalSession from './PlotHistoricalSession'
|
||||
import { useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
|
@ -233,10 +232,7 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
|
|||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={(e) => {
|
||||
const newFormData = [...formData]
|
||||
newFormData[index] = e.formData
|
||||
|
|
|
@ -633,6 +633,13 @@ export default function PlotHistoricalSession({
|
|||
dataSegments={dataSegments}
|
||||
onTimeChange={handleTimePointChange}
|
||||
/>
|
||||
<Box mt={2} p={2} bg={infoBgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<HStack justify="space-between" fontSize="sm" color={textColor}>
|
||||
<Text><strong>Range:</strong> {timeRangeSeconds}s</Text>
|
||||
<Text><strong>{t('timeSelector.from')}:</strong> {formatCentralTimeInfo().start}</Text>
|
||||
<Text><strong>{t('timeSelector.to')}:</strong> {formatCentralTimeInfo().end}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
@ -906,7 +913,6 @@ export default function PlotHistoricalSession({
|
|||
stepMinutes={1}
|
||||
dataSegments={dataSegments}
|
||||
onTimeChange={handleTimePointChange}
|
||||
isFullscreen={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
@ -39,7 +39,6 @@ import Form from '@rjsf/chakra-ui'
|
|||
import validator from '@rjsf/validator-ajv8'
|
||||
import allWidgets from './widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate'
|
||||
import TooltipFieldTemplate from './rjsf/TooltipFieldTemplate'
|
||||
import PlotRealtimeSession from './PlotRealtimeSession'
|
||||
import { useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
|
@ -559,10 +558,7 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
|
|||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
|
@ -628,10 +624,7 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
|
|||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
|
@ -1147,30 +1140,30 @@ export default function PlotManager() {
|
|||
variable_name: {
|
||||
"ui:widget": "variableSelector",
|
||||
"ui:placeholder": "Search and select variable from datasets...",
|
||||
"ui:description": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
},
|
||||
label: {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Chart legend label...",
|
||||
"ui:description": "📊 Label shown in the plot legend for this variable"
|
||||
"ui:help": "📊 Label shown in the plot legend for this variable"
|
||||
},
|
||||
color: {
|
||||
"ui:widget": "color",
|
||||
"ui:description": "🎨 Select the color for this variable in the plot",
|
||||
"ui:help": "🎨 Select the color for this variable in the plot",
|
||||
"ui:placeholder": "#3498db"
|
||||
},
|
||||
line_width: {
|
||||
"ui:widget": "updown",
|
||||
"ui:description": "📏 Thickness of the line in the plot (1-10)",
|
||||
"ui:help": "📏 Thickness of the line in the plot (1-10)",
|
||||
"ui:options": { "step": 1, "min": 1, "max": 10 }
|
||||
},
|
||||
y_axis: {
|
||||
"ui:widget": "select",
|
||||
"ui:description": "📈 Which Y-axis to use for this variable"
|
||||
"ui:help": "📈 Which Y-axis to use for this variable"
|
||||
},
|
||||
enabled: {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:description": "✅ Whether to show this variable in the plot"
|
||||
"ui:help": "✅ Whether to show this variable in the plot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1183,10 +1176,7 @@ export default function PlotManager() {
|
|||
formData={selectedPlotVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
const updatedConfig = updateSelectedPlotVariables(formData)
|
||||
savePlotVariables(updatedConfig).then(() => {
|
||||
|
|
|
@ -1,29 +1,7 @@
|
|||
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Button,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
HStack
|
||||
} from "@chakra-ui/react";
|
||||
import { CheckIcon, CalendarIcon } from "@chakra-ui/icons";
|
||||
import { Box, Flex, Text, Slider, SliderTrack, SliderFilledTrack, SliderThumb, Button, IconButton, useColorModeValue, NumberInput, NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper } from "@chakra-ui/react";
|
||||
import { CheckIcon } from "@chakra-ui/icons";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import DataAvailabilityBar from "./DataAvailabilityBar.jsx";
|
||||
|
@ -36,7 +14,6 @@ export default function TimePointSelector({
|
|||
stepMinutes = 5,
|
||||
dataSegments = [],
|
||||
onTimeChange,
|
||||
isFullscreen = false, // NEW: Enable ultra-compact mode for fullscreen
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Color mode values
|
||||
|
@ -185,417 +162,211 @@ export default function TimePointSelector({
|
|||
};
|
||||
|
||||
return (
|
||||
<Box p={isFullscreen ? 2 : 3} bg={bgColor} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
{/* Compact header with date picker and controls */}
|
||||
{!isFullscreen && (
|
||||
<Flex gap={2} align="center" justify="space-between" mb={3}>
|
||||
{/* Compact Date/Time Picker */}
|
||||
<Popover placement="bottom-start">
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<CalendarIcon />}
|
||||
title="Select date and time"
|
||||
>
|
||||
📅 {value.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={0}>
|
||||
<Box
|
||||
sx={{
|
||||
'& .react-datepicker': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px',
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
boxShadow: useColorModeValue('md', 'dark-lg'),
|
||||
display: 'flex',
|
||||
flexDirection: 'row' // Force horizontal layout
|
||||
},
|
||||
'& .react-datepicker__header': {
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
height: '40px' // Fixed header height
|
||||
},
|
||||
'& .react-datepicker__current-month': {
|
||||
color: textColor,
|
||||
lineHeight: '40px' // Center text in header
|
||||
},
|
||||
'& .react-datepicker__day-names': {
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
'& .react-datepicker__day-name': {
|
||||
color: textColor,
|
||||
height: '30px',
|
||||
lineHeight: '30px'
|
||||
},
|
||||
'& .react-datepicker__week': {
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
'& .react-datepicker__day': {
|
||||
color: textColor,
|
||||
height: '30px',
|
||||
lineHeight: '30px',
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('blue.50', 'blue.800')
|
||||
}
|
||||
},
|
||||
'& .react-datepicker__day--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white'
|
||||
},
|
||||
'& .react-datepicker__month': {
|
||||
height: '210px' // 6 weeks × 30px + 30px for day names
|
||||
},
|
||||
// Time picker specific styles - match calendar height exactly
|
||||
'& .react-datepicker__time-container': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
borderLeft: '1px solid',
|
||||
borderColor: borderColor,
|
||||
width: '100px',
|
||||
flexShrink: 0,
|
||||
height: '250px', // Match total calendar height (40px header + 210px month)
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
'& .react-datepicker__time-box': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
'& .react-datepicker__time-name': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
textAlign: 'center',
|
||||
padding: '8px',
|
||||
height: '40px',
|
||||
lineHeight: '24px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
flexShrink: 0
|
||||
},
|
||||
'& .react-datepicker__time-list': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
height: '210px', // Match month container height exactly
|
||||
maxHeight: '210px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
'& .react-datepicker__time-list-item': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
height: '26px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('gray.100', 'gray.700')
|
||||
},
|
||||
'&--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
// Ensure calendar and time picker are side by side
|
||||
'& .react-datepicker__month-container': {
|
||||
float: 'none',
|
||||
display: 'inline-block',
|
||||
height: '250px' // Match time container height
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatePicker
|
||||
selected={value}
|
||||
onChange={onPick}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={stepMinutes}
|
||||
timeCaption="Time"
|
||||
dateFormat="dd-MM-yyyy HH:mm"
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
minTime={new Date(new Date(value).setHours(0, 0, 0, 0))}
|
||||
maxTime={new Date(new Date(value).setHours(23, 59, 59, 999))}
|
||||
inline
|
||||
/>
|
||||
</Box>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Apply button when there are pending changes */}
|
||||
{hasPendingChanges && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<CheckIcon />}
|
||||
onClick={applyPendingChanges}
|
||||
>
|
||||
{t('timeSelector.applyChanges')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Time navigation slider with data availability */}
|
||||
<Box mb={isFullscreen ? 1 : 2}>
|
||||
<Box position="relative">
|
||||
{/* Data availability bar positioned above slider track */}
|
||||
<Box p={4} bg={bgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<Flex gap={4} align="center" mb={3} wrap="wrap">
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1} color={textColor}>{t('timeSelector.selectDateTime')}</Text>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="0"
|
||||
right="0"
|
||||
px="1"
|
||||
zIndex={1}
|
||||
sx={{
|
||||
'& .react-datepicker-wrapper': {
|
||||
width: 'auto'
|
||||
},
|
||||
'& .react-datepicker__input-container input': {
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: borderColor,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px blue.500'
|
||||
}
|
||||
},
|
||||
'& .react-datepicker': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
border: '1px solid',
|
||||
borderColor: borderColor,
|
||||
color: textColor
|
||||
},
|
||||
'& .react-datepicker__header': {
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor
|
||||
},
|
||||
'& .react-datepicker__day': {
|
||||
color: textColor,
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('blue.50', 'blue.800')
|
||||
}
|
||||
},
|
||||
'& .react-datepicker__day--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white'
|
||||
},
|
||||
// Time picker specific styles
|
||||
'& .react-datepicker__time-container': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
borderLeft: '1px solid',
|
||||
borderColor: borderColor
|
||||
},
|
||||
'& .react-datepicker__time-box': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800')
|
||||
},
|
||||
'& .react-datepicker__time-list': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
'& .react-datepicker__time-list-item': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('gray.100', 'gray.700')
|
||||
},
|
||||
'&--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
'& .react-datepicker__time-name': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DataAvailabilityBar
|
||||
segments={dataSegments}
|
||||
<DatePicker
|
||||
selected={value}
|
||||
onChange={onPick}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={stepMinutes}
|
||||
timeCaption="Time"
|
||||
dateFormat="dd-MM-yyyy HH:mm"
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
height="4px"
|
||||
showTooltips={true}
|
||||
// Optional: force time range when navigating
|
||||
minTime={new Date(new Date(value).setHours(0, 0, 0, 0))}
|
||||
maxTime={new Date(new Date(value).setHours(23, 59, 59, 999))}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
min={minMs}
|
||||
max={maxMs}
|
||||
step={stepMs}
|
||||
value={sliderValue}
|
||||
onChange={onSlide}
|
||||
colorScheme="blue"
|
||||
size={isFullscreen ? "sm" : "md"}
|
||||
>
|
||||
<SliderTrack bg={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<SliderFilledTrack bg="blue.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb bg="blue.500" />
|
||||
</Slider>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Compact footer with editable range and status */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={2} fontSize={isFullscreen ? "xs" : "sm"}>
|
||||
{/* Editable Range Display */}
|
||||
<HStack spacing={1} align="center">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
<strong>Range:</strong>
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={tempRangeSeconds}
|
||||
onChange={(valueString, valueNumber) => onRangeChange(isNaN(valueNumber) ? 1000 : valueNumber)}
|
||||
min={60}
|
||||
max={86400}
|
||||
size="xs"
|
||||
width={isFullscreen ? "50px" : "60px"}
|
||||
>
|
||||
<NumberInputField
|
||||
fontSize={isFullscreen ? "xs" : "sm"}
|
||||
px={1}
|
||||
py={isFullscreen ? "1px" : undefined}
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
borderColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
/>
|
||||
</NumberInput>
|
||||
<Text color={useColorModeValue('gray.600', 'gray.400')}>s</Text>
|
||||
</HStack>
|
||||
|
||||
{/* Ultra-compact controls for fullscreen */}
|
||||
{isFullscreen ? (
|
||||
<HStack spacing={2} fontSize="xs">
|
||||
{/* Minimal date picker button for fullscreen */}
|
||||
<Popover placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="outline"
|
||||
icon={<CalendarIcon />}
|
||||
title="Select date and time"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={0}>
|
||||
<Box
|
||||
sx={{
|
||||
'& .react-datepicker': {
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '14px',
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
boxShadow: useColorModeValue('md', 'dark-lg'),
|
||||
display: 'flex',
|
||||
flexDirection: 'row' // Force horizontal layout
|
||||
},
|
||||
'& .react-datepicker__header': {
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
height: '40px' // Fixed header height
|
||||
},
|
||||
'& .react-datepicker__current-month': {
|
||||
color: textColor,
|
||||
lineHeight: '40px' // Center text in header
|
||||
},
|
||||
'& .react-datepicker__day-names': {
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
'& .react-datepicker__day-name': {
|
||||
color: textColor,
|
||||
height: '30px',
|
||||
lineHeight: '30px'
|
||||
},
|
||||
'& .react-datepicker__week': {
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
'& .react-datepicker__day': {
|
||||
color: textColor,
|
||||
height: '30px',
|
||||
lineHeight: '30px',
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('blue.50', 'blue.800')
|
||||
}
|
||||
},
|
||||
'& .react-datepicker__day--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white'
|
||||
},
|
||||
'& .react-datepicker__month': {
|
||||
height: '210px' // 6 weeks × 30px + 30px for day names
|
||||
},
|
||||
// Time picker specific styles - match calendar height exactly
|
||||
'& .react-datepicker__time-container': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
borderLeft: '1px solid',
|
||||
borderColor: borderColor,
|
||||
width: '100px',
|
||||
flexShrink: 0,
|
||||
height: '250px', // Match total calendar height (40px header + 210px month)
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
'& .react-datepicker__time-box': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
'& .react-datepicker__time-name': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
textAlign: 'center',
|
||||
padding: '8px',
|
||||
height: '40px',
|
||||
lineHeight: '24px',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor,
|
||||
flexShrink: 0
|
||||
},
|
||||
'& .react-datepicker__time-list': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
height: '210px', // Match month container height exactly
|
||||
maxHeight: '210px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
'& .react-datepicker__time-list-item': {
|
||||
color: textColor,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
padding: '4px 8px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
height: '26px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('gray.100', 'gray.700')
|
||||
},
|
||||
'&--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
// Ensure calendar and time picker are side by side
|
||||
'& .react-datepicker__month-container': {
|
||||
float: 'none',
|
||||
display: 'inline-block',
|
||||
height: '250px' // Match time container height
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatePicker
|
||||
selected={value}
|
||||
onChange={onPick}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={stepMinutes}
|
||||
timeCaption="Time"
|
||||
dateFormat="dd-MM-yyyy HH:mm"
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
minTime={new Date(new Date(value).setHours(0, 0, 0, 0))}
|
||||
maxTime={new Date(new Date(value).setHours(23, 59, 59, 999))}
|
||||
inline
|
||||
/>
|
||||
</Box>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{hasPendingChanges && (
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
leftIcon={<CheckIcon />}
|
||||
onClick={applyPendingChanges}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
/* Normal mode - Step info and pending changes status */
|
||||
<HStack spacing={3} fontSize="xs">
|
||||
<Text color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Step: {stepMinutes} min
|
||||
<Box flex="1" minW="260px">
|
||||
<Text color={textColor} mb={1}>{t('timeSelector.navigateSlider')}</Text>
|
||||
|
||||
{/* Slider with integrated data availability */}
|
||||
<Box position="relative" mb={2}>
|
||||
{/* Data availability bar positioned above slider track */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="0"
|
||||
right="0"
|
||||
px="1"
|
||||
zIndex={1}
|
||||
>
|
||||
<DataAvailabilityBar
|
||||
segments={dataSegments}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
height="4px"
|
||||
showTooltips={true}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
min={minMs}
|
||||
max={maxMs}
|
||||
step={stepMs}
|
||||
value={sliderValue}
|
||||
onChange={onSlide}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<SliderTrack bg={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<SliderFilledTrack bg="blue.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb bg="blue.500" />
|
||||
</Slider>
|
||||
</Box>
|
||||
|
||||
<Flex mt={2} justify="space-between" align="center">
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
{new Date(sliderValue).toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})}
|
||||
</Text>
|
||||
{hasPendingChanges && (
|
||||
<Text color="orange.500" fontWeight="medium">
|
||||
<Text fontSize="xs" color="orange.500">
|
||||
{t('timeSelector.pendingChanges')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Range and Apply Controls */}
|
||||
<Flex mt={3} gap={3} align="center" wrap="wrap">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" color={textColor} mb={1}>
|
||||
{t('timeSelector.timeRangeSeconds')}
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={tempRangeSeconds}
|
||||
onChange={(valueString, valueNumber) => onRangeChange(isNaN(valueNumber) ? 1000 : valueNumber)}
|
||||
min={60}
|
||||
max={86400}
|
||||
size="sm"
|
||||
width="120px"
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</Box>
|
||||
|
||||
{hasPendingChanges && (
|
||||
<Box>
|
||||
<Text fontSize="sm" color="orange.500" mb={1}>
|
||||
{t('timeSelector.pendingChanges')}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<CheckIcon />}
|
||||
onClick={applyPendingChanges}
|
||||
>
|
||||
{t('timeSelector.applyChanges')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Range: {minDate.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} → {maxDate.toLocaleString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} | Step: {stepMinutes} min
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -60,21 +60,13 @@ export default function LayoutObjectFieldTemplate(props) {
|
|||
)
|
||||
)}
|
||||
{layout.map((row, rowIdx) => (
|
||||
<SimpleGrid key={rowIdx} columns={12} spacing={3} alignItems="flex-end">
|
||||
<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}`}
|
||||
minH="40px" // Minimum height for consistent alignment
|
||||
display="flex"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
{prop.content}
|
||||
</Box>
|
||||
<Box key={`${rowIdx}-${cellIdx}`} gridColumn={`span ${col}`}>{prop.content}</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Box, FormControl, FormErrorMessage, Tooltip as ChakraTooltip, useColorModeValue, Flex } from '@chakra-ui/react'
|
||||
|
||||
/**
|
||||
* Custom RJSF Field Template with integrated tooltips
|
||||
* Shows ui:description as tooltip on hover over the field
|
||||
* Removes the description text that normally appears below the field
|
||||
* Improves alignment for switches, checkboxes and handles long labels
|
||||
*/
|
||||
export default function TooltipFieldTemplate(props) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
schema,
|
||||
uiSchema
|
||||
} = props
|
||||
|
||||
// Theme-aware colors for tooltips
|
||||
// Both light and dark themes use dark backgrounds with light text for better readability
|
||||
const tooltipBg = useColorModeValue('gray.700', 'gray.600')
|
||||
const tooltipColor = useColorModeValue('white', 'white')
|
||||
|
||||
if (hidden) {
|
||||
return <div style={{ display: 'none' }}>{children}</div>
|
||||
}
|
||||
|
||||
// Get description from uiSchema if available
|
||||
const tooltipContent = uiSchema?.['ui:description'] || description
|
||||
|
||||
// Only show tooltip if there's actual content (not empty string or whitespace)
|
||||
const hasTooltipContent = tooltipContent && typeof tooltipContent === 'string' && tooltipContent.trim().length > 0
|
||||
|
||||
// Detect if this is a switch or checkbox widget based on the schema type and widget
|
||||
const widgetType = uiSchema?.['ui:widget']
|
||||
const isBoolean = schema?.type === 'boolean'
|
||||
const isSwitchOrCheckbox = isBoolean && (widgetType === 'switch' || widgetType === 'checkbox' || widgetType === 'SwitchWidget' || widgetType === 'CheckboxWidget')
|
||||
|
||||
// For switch/checkbox widgets, we need special handling to center-align them with other fields
|
||||
// These widgets already include their label, so we don't need additional label handling
|
||||
const shouldCenterAlign = isSwitchOrCheckbox
|
||||
|
||||
// If there's tooltip content, wrap just the children (input/widget) in tooltip
|
||||
const wrappedChildren = hasTooltipContent ? (
|
||||
<ChakraTooltip
|
||||
label={tooltipContent}
|
||||
hasArrow
|
||||
bg={tooltipBg}
|
||||
color={tooltipColor}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
maxW="300px"
|
||||
textAlign="center"
|
||||
placement="top"
|
||||
openDelay={300}
|
||||
closeDelay={100}
|
||||
>
|
||||
<Box w="full">
|
||||
{children}
|
||||
</Box>
|
||||
</ChakraTooltip>
|
||||
) : children
|
||||
|
||||
// Enhanced container for better alignment
|
||||
const containerProps = shouldCenterAlign ? {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
minH: '40px', // Standard input height for alignment
|
||||
w: 'full'
|
||||
} : {
|
||||
w: 'full'
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={!!errors} isRequired={required}>
|
||||
<Box {...containerProps}>
|
||||
{wrappedChildren}
|
||||
</Box>
|
||||
{errors && (
|
||||
<FormErrorMessage>
|
||||
{errors}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
{/* Intentionally removed description display since it will be shown in tooltip */}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
|
@ -132,80 +132,24 @@ export const SelectWidget = ({ id, required, readonly, disabled, label, value, o
|
|||
}
|
||||
|
||||
export const CheckboxWidget = ({ id, label, value, required, disabled, readonly, onChange, rawErrors = [] }) => (
|
||||
<FormControl
|
||||
isRequired={required}
|
||||
isDisabled={disabled}
|
||||
isReadOnly={readonly}
|
||||
isInvalid={rawErrors.length > 0}
|
||||
minH="40px" // Standard height to match other inputs
|
||||
position="relative"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
justifyContent="flex-end" // Align to bottom of container
|
||||
alignItems="center" // Center horizontally
|
||||
>
|
||||
<FormLabel
|
||||
htmlFor={id}
|
||||
mb={1} // Small margin between label and checkbox
|
||||
fontSize="sm"
|
||||
lineHeight="1.2"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap" // Force single line
|
||||
wordBreak="break-word"
|
||||
textAlign="center" // Center the label text
|
||||
maxW="100%"
|
||||
order={1} // Label first
|
||||
>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<FormControl isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
|
||||
<Checkbox
|
||||
id={id}
|
||||
isChecked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
order={2} // Checkbox second
|
||||
alignSelf="center" // Ensure checkbox is centered
|
||||
sx={{
|
||||
'& .chakra-checkbox__control': {
|
||||
alignSelf: 'center'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
{rawErrors.length > 0 && (
|
||||
<FormHelperText color="red.500" position="absolute" top="100%" left={0} mt={1} w="100%" textAlign="center">
|
||||
{rawErrors[0]}
|
||||
</FormHelperText>
|
||||
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
export const SwitchWidget = ({ id, label, value, required, disabled, readonly, onChange, rawErrors = [] }) => (
|
||||
<FormControl
|
||||
isRequired={required}
|
||||
isDisabled={disabled}
|
||||
isReadOnly={readonly}
|
||||
isInvalid={rawErrors.length > 0}
|
||||
minH="40px" // Standard height to match other inputs
|
||||
position="relative"
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
justifyContent="flex-end" // Align to bottom of container
|
||||
alignItems="center" // Center horizontally
|
||||
>
|
||||
<FormLabel
|
||||
htmlFor={id}
|
||||
mb={1} // Small margin between label and switch
|
||||
fontSize="sm"
|
||||
lineHeight="1.2"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap" // Force single line
|
||||
wordBreak="break-word"
|
||||
textAlign="center" // Center the label text
|
||||
maxW="100%"
|
||||
order={1} // Label first
|
||||
>
|
||||
<FormControl display="flex" alignItems="center" isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
|
||||
<FormLabel htmlFor={id} mb="0" mr={3}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
|
@ -213,13 +157,9 @@ export const SwitchWidget = ({ id, label, value, required, disabled, readonly, o
|
|||
isChecked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
order={2} // Switch second
|
||||
alignSelf="center" // Ensure switch is centered
|
||||
/>
|
||||
{rawErrors.length > 0 && (
|
||||
<FormHelperText color="red.500" position="absolute" top="100%" left={0} mt={1} w="100%" textAlign="center">
|
||||
{rawErrors[0]}
|
||||
</FormHelperText>
|
||||
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
import { Tooltip as ChakraTooltip } from "@chakra-ui/react"
|
||||
import * as React from "react"
|
||||
|
||||
export const Tooltip = React.forwardRef(function Tooltip(props, ref) {
|
||||
const {
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
// Debug logging
|
||||
console.log('Tooltip render:', { label, disabled, hasChildren: !!children })
|
||||
|
||||
if (disabled || !label) return children
|
||||
|
||||
return (
|
||||
<ChakraTooltip
|
||||
label={label}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</ChakraTooltip>
|
||||
)
|
||||
})
|
|
@ -2,8 +2,6 @@ import { customWidgets } from './CustomWidgets'
|
|||
import { widgets } from '../rjsf/widgets'
|
||||
import VariableSelectorWidget from '../rjsf/VariableSelectorWidget'
|
||||
import FilePathWidget from './FilePathWidget'
|
||||
import SimpleFilePathWidget from './SimpleFilePathWidget'
|
||||
import PathBrowserWidget from './PathBrowserWidget'
|
||||
import SymbolSelectorWidget from './SymbolSelectorWidget'
|
||||
import DatasetVariableSymbolWidget from './DatasetVariableSymbolWidget'
|
||||
|
||||
|
@ -29,21 +27,11 @@ export const allWidgets = {
|
|||
'variable-selector': VariableSelectorWidget,
|
||||
VariableSelectorWidget: VariableSelectorWidget,
|
||||
|
||||
// File path widget for ASC symbol files (with symbol loading)
|
||||
// File path widget for ASC symbol files
|
||||
filePath: FilePathWidget,
|
||||
'file-path': FilePathWidget,
|
||||
FilePathWidget: FilePathWidget,
|
||||
|
||||
// Simple file path widget (just browse, no extra actions)
|
||||
simpleFilePath: SimpleFilePathWidget,
|
||||
'simple-file-path': SimpleFilePathWidget,
|
||||
SimpleFilePathWidget: SimpleFilePathWidget,
|
||||
|
||||
// Generic path browser widget for files and directories
|
||||
pathBrowser: PathBrowserWidget,
|
||||
'path-browser': PathBrowserWidget,
|
||||
PathBrowserWidget: PathBrowserWidget,
|
||||
|
||||
// Symbol selector widget for PLC symbols
|
||||
symbolSelector: SymbolSelectorWidget,
|
||||
'symbol-selector': SymbolSelectorWidget,
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
import { customWidgets } from './CustomWidgets'
|
||||
import { widgets } from '../rjsf/widgets'
|
||||
import VariableSelectorWidget from '../rjsf/VariableSelectorWidget'
|
||||
import FilePathWidget from './FilePathWidget'
|
||||
import SimpleFilePathWidget from './SimpleFilePathWidget'
|
||||
import PathBrowserWidget from './PathBrowserWidget'
|
||||
import SymbolSelectorWidget from './SymbolSelectorWidget'
|
||||
import DatasetVariableSymbolWidget from './DatasetVariableSymbolWidget'
|
||||
|
||||
// Comprehensive widget collection that merges all available widgets
|
||||
// for full UI schema support with layouts
|
||||
export const allWidgets = {
|
||||
// Custom application-specific widgets
|
||||
...customWidgets,
|
||||
|
||||
// Enhanced RJSF widgets with proper styling
|
||||
...widgets,
|
||||
|
||||
// Additional widget aliases for UI schema compatibility
|
||||
updown: widgets.UpDownWidget,
|
||||
text: widgets.TextWidget,
|
||||
textarea: widgets.TextareaWidget,
|
||||
select: widgets.SelectWidget,
|
||||
checkbox: widgets.CheckboxWidget,
|
||||
switch: widgets.SwitchWidget,
|
||||
|
||||
// Variable selector aliases - use the advanced version with search and metadata
|
||||
variableSelector: VariableSelectorWidget,
|
||||
'variable-selector': VariableSelectorWidget,
|
||||
VariableSelectorWidget: VariableSelectorWidget,
|
||||
|
||||
// File path widget for ASC symbol files (with symbol loading)
|
||||
filePath: FilePathWidget,
|
||||
'file-path': FilePathWidget,
|
||||
FilePathWidget: FilePathWidget,
|
||||
|
||||
// Simple file path widget (just browse, no extra actions)
|
||||
simpleFilePath: SimpleFilePathWidget,
|
||||
'simple-file-path': SimpleFilePathWidget,
|
||||
SimpleFilePathWidget: SimpleFilePathWidget,
|
||||
|
||||
// Generic path browser widget for files and directories
|
||||
pathBrowser: PathBrowserWidget,
|
||||
'path-browser': PathBrowserWidget,
|
||||
PathBrowserWidget: PathBrowserWidget,
|
||||
|
||||
// Directory browser widget alias
|
||||
directoryBrowser: PathBrowserWidget,
|
||||
'directory-browser': PathBrowserWidget,
|
||||
|
||||
// Symbol selector widget for PLC symbols
|
||||
symbolSelector: SymbolSelectorWidget,
|
||||
'symbol-selector': SymbolSelectorWidget,
|
||||
SymbolSelectorWidget: SymbolSelectorWidget,
|
||||
|
||||
// Dataset variable symbol widget with auto-fill
|
||||
datasetVariableSymbol: DatasetVariableSymbolWidget,
|
||||
'dataset-variable-symbol': DatasetVariableSymbolWidget,
|
||||
DatasetVariableSymbolWidget: DatasetVariableSymbolWidget,
|
||||
|
||||
// PLC-specific widget aliases (if available)
|
||||
plcArea: widgets.PlcAreaWidget,
|
||||
plcDataType: widgets.PlcDataTypeWidget,
|
||||
plcNumber: widgets.PlcNumberWidget,
|
||||
plcStreaming: widgets.PlcStreamingWidget,
|
||||
plcVariableName: widgets.PlcVariableNameWidget,
|
||||
}
|
||||
|
||||
export default allWidgets
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react'
|
||||
import PathBrowserWidget from './PathBrowserWidget'
|
||||
|
||||
/**
|
||||
* Directory browser widget - preconfigured PathBrowserWidget for directories
|
||||
*/
|
||||
const DirectoryBrowserWidget = (props) => {
|
||||
const directoryOptions = {
|
||||
mode: 'directory',
|
||||
title: 'Select Directory',
|
||||
helpText: 'Relative paths are based on application directory. Absolute paths (C:\\folder) are used as-is.',
|
||||
showPathInfo: true
|
||||
}
|
||||
|
||||
return (
|
||||
<PathBrowserWidget
|
||||
{...props}
|
||||
options={{ ...directoryOptions, ...props.options }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectoryBrowserWidget
|
|
@ -1,165 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
HStack,
|
||||
Text,
|
||||
useToast,
|
||||
Icon,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import { FiFolder, FiFile } from 'react-icons/fi'
|
||||
|
||||
/**
|
||||
* Generic path browser widget for files and directories
|
||||
* Supports both absolute and relative paths
|
||||
* Can be configured for file or directory selection via schema options
|
||||
*/
|
||||
const PathBrowserWidget = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
readonly,
|
||||
required,
|
||||
placeholder,
|
||||
schema = {},
|
||||
uiSchema = {}
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
// Configuration options from schema or uiSchema
|
||||
const schemaOptions = schema.options || {}
|
||||
const uiOptions = uiSchema['ui:options'] || {}
|
||||
const options = { ...schemaOptions, ...uiOptions }
|
||||
|
||||
const {
|
||||
mode = 'file', // 'file' or 'directory'
|
||||
title = mode === 'file' ? 'Select File' : 'Select Directory',
|
||||
filetypes = mode === 'file' ? [['All Files', '*.*']] : undefined,
|
||||
showPathInfo = true
|
||||
} = options
|
||||
|
||||
const helpText = uiSchema['ui:description'] || schema.description
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const endpoint = mode === 'file' ? '/api/utils/browse-file' : '/api/utils/browse-directory'
|
||||
const body = mode === 'file'
|
||||
? { title, filetypes }
|
||||
: { title }
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const pathKey = mode === 'file' ? 'file_path' : 'directory_path'
|
||||
|
||||
if (data.success && data[pathKey]) {
|
||||
onChange(data[pathKey])
|
||||
toast({
|
||||
title: `${mode === 'file' ? 'File' : 'Directory'} Selected`,
|
||||
description: `Selected: ${data[pathKey]}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
} else if (data.cancelled) {
|
||||
// User cancelled - no action needed
|
||||
} else {
|
||||
throw new Error(data.error || `Failed to select ${mode}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to browse ${mode}: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPathInfo = (path) => {
|
||||
if (!path) return null
|
||||
|
||||
// Check if path is absolute (starts with drive letter on Windows or / on Unix)
|
||||
const isAbsolute = /^([a-zA-Z]:|\/)/.test(path)
|
||||
const pathType = isAbsolute ? 'Absolute' : 'Relative'
|
||||
const fileName = path.split(/[\\\/]/).pop()
|
||||
|
||||
return { isAbsolute, pathType, fileName }
|
||||
}
|
||||
|
||||
const pathInfo = getPathInfo(value)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label && (
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
{label} {required && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} mb={showPathInfo && pathInfo ? 2 : 0}>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || `Enter ${mode} path or browse...`}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
flex={1}
|
||||
/>
|
||||
|
||||
<Tooltip label={`Browse for ${mode}`}>
|
||||
<Button
|
||||
leftIcon={<Icon as={mode === 'file' ? FiFile : FiFolder} />}
|
||||
onClick={handleBrowse}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled || readonly}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* Path information display */}
|
||||
{showPathInfo && pathInfo && (
|
||||
<Box>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>
|
||||
<Icon as={mode === 'file' ? FiFile : FiFolder} mr={1} />
|
||||
{pathInfo.fileName}
|
||||
</Text>
|
||||
<Text>
|
||||
Path Type: <Text as="span" fontWeight="medium">{pathInfo.pathType}</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{helpText && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
{helpText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PathBrowserWidget
|
|
@ -1,165 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
HStack,
|
||||
Text,
|
||||
useToast,
|
||||
Icon,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import { FiFolder, FiFile } from 'react-icons/fi'
|
||||
|
||||
/**
|
||||
* Generic path browser widget for files and directories
|
||||
* Supports both absolute and relative paths
|
||||
* Can be configured for file or directory selection via schema options
|
||||
*/
|
||||
const PathBrowserWidget = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
readonly,
|
||||
required,
|
||||
placeholder,
|
||||
schema = {},
|
||||
uiSchema = {}
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
// Configuration options from schema or uiSchema
|
||||
const schemaOptions = schema.options || {}
|
||||
const uiOptions = uiSchema['ui:options'] || {}
|
||||
const options = { ...schemaOptions, ...uiOptions }
|
||||
|
||||
const {
|
||||
mode = 'file', // 'file' or 'directory'
|
||||
title = mode === 'file' ? 'Select File' : 'Select Directory',
|
||||
filetypes = mode === 'file' ? [['All Files', '*.*']] : undefined,
|
||||
showPathInfo = true
|
||||
} = options
|
||||
|
||||
const helpText = uiSchema['ui:description'] || schema.description
|
||||
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const endpoint = mode === 'file' ? '/api/utils/browse-file' : '/api/utils/browse-directory'
|
||||
const body = mode === 'file'
|
||||
? { title, filetypes }
|
||||
: { title }
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const pathKey = mode === 'file' ? 'file_path' : 'directory_path'
|
||||
|
||||
if (data.success && data[pathKey]) {
|
||||
onChange(data[pathKey])
|
||||
toast({
|
||||
title: `${mode === 'file' ? 'File' : 'Directory'} Selected`,
|
||||
description: `Selected: ${data[pathKey]}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
} else if (data.cancelled) {
|
||||
// User cancelled - no action needed
|
||||
} else {
|
||||
throw new Error(data.error || `Failed to select ${mode}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to browse ${mode}: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPathInfo = (path) => {
|
||||
if (!path) return null
|
||||
|
||||
// Check if path is absolute (starts with drive letter on Windows or / on Unix)
|
||||
const isAbsolute = /^([a-zA-Z]:|\/)/.test(path)
|
||||
const pathType = isAbsolute ? 'Absolute' : 'Relative'
|
||||
const fileName = path.split(/[\\\/]/).pop()
|
||||
|
||||
return { isAbsolute, pathType, fileName }
|
||||
}
|
||||
|
||||
const pathInfo = getPathInfo(value)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label && (
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
{label} {required && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} mb={showPathInfo && pathInfo ? 2 : 0}>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || `Enter ${mode} path or browse...`}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
flex={1}
|
||||
/>
|
||||
|
||||
<Tooltip label={`Browse for ${mode}`}>
|
||||
<Button
|
||||
leftIcon={<Icon as={mode === 'file' ? FiFile : FiFolder} />}
|
||||
onClick={handleBrowse}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled || readonly}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* Path information display */}
|
||||
{showPathInfo && pathInfo && (
|
||||
<Box>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>
|
||||
<Icon as={mode === 'file' ? FiFile : FiFolder} mr={1} />
|
||||
{pathInfo.fileName}
|
||||
</Text>
|
||||
<Text>
|
||||
Path Type: <Text as="span" fontWeight="medium">{pathInfo.pathType}</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{helpText && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
{helpText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PathBrowserWidget
|
|
@ -1,159 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
HStack,
|
||||
Text,
|
||||
useToast,
|
||||
Icon,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import { FiFolder, FiFile } from 'react-icons/fi'
|
||||
|
||||
/**
|
||||
* Simple file path widget with browse functionality
|
||||
* Simplified version without specific actions like symbol loading
|
||||
*/
|
||||
const SimpleFilePathWidget = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
readonly,
|
||||
required,
|
||||
placeholder,
|
||||
schema = {},
|
||||
uiSchema = {}
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
// Configuration options from schema
|
||||
const schemaOptions = schema.options || {}
|
||||
const uiOptions = uiSchema['ui:options'] || {}
|
||||
const options = { ...schemaOptions, ...uiOptions }
|
||||
|
||||
const {
|
||||
title = 'Select File',
|
||||
filetypes = [['All Files', '*.*']],
|
||||
showPathInfo = true
|
||||
} = options
|
||||
|
||||
const helpText = uiSchema['ui:description'] || schema.description
|
||||
|
||||
const handleBrowseFile = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
const response = await fetch('/api/utils/browse-file', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
filetypes
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.file_path) {
|
||||
onChange(data.file_path)
|
||||
toast({
|
||||
title: 'File Selected',
|
||||
description: `Selected: ${data.file_path}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
} else if (data.cancelled) {
|
||||
// User cancelled - no action needed
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to select file')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: `Failed to browse file: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPathInfo = (path) => {
|
||||
if (!path) return null
|
||||
|
||||
// Check if path is absolute (starts with drive letter on Windows or / on Unix)
|
||||
const isAbsolute = /^([a-zA-Z]:|\/)/.test(path)
|
||||
const pathType = isAbsolute ? 'Absolute' : 'Relative'
|
||||
const fileName = path.split(/[\\\/]/).pop()
|
||||
|
||||
return { isAbsolute, pathType, fileName }
|
||||
}
|
||||
|
||||
const pathInfo = getPathInfo(value)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{label && (
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
{label} {required && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} mb={showPathInfo && pathInfo ? 2 : 0}>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'Enter file path or browse...'}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
flex={1}
|
||||
/>
|
||||
|
||||
<Tooltip label="Browse for file">
|
||||
<Button
|
||||
leftIcon={<Icon as={FiFolder} />}
|
||||
onClick={handleBrowseFile}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled || readonly}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* Path information display */}
|
||||
{showPathInfo && pathInfo && (
|
||||
<Box>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>
|
||||
<Icon as={FiFile} mr={1} />
|
||||
{pathInfo.fileName}
|
||||
</Text>
|
||||
<Text>
|
||||
Path Type: <Text as="span" fontWeight="medium">{pathInfo.pathType}</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Help text */}
|
||||
{helpText && (
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
{helpText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default SimpleFilePathWidget
|
|
@ -15,7 +15,7 @@ createRoot(document.getElementById('root')).render(
|
|||
<React.StrictMode>
|
||||
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
|
||||
<ChakraProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter basename="/app">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ChakraProvider>
|
||||
|
|
|
@ -54,14 +54,12 @@ import {
|
|||
ModalCloseButton
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
||||
import { FiUpload } from 'react-icons/fi'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import PlotManager from '../components/PlotManager'
|
||||
import PlotHistoricalManager from '../components/PlotHistoricalManager'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import TooltipFieldTemplate from '../components/rjsf/TooltipFieldTemplate'
|
||||
import CsvFileBrowser from '../components/CsvFileBrowser'
|
||||
import { VariableProvider, useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
|
@ -565,10 +563,7 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
|
|||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
|
@ -668,10 +663,7 @@ function CollapsibleArrayForm({ data, schema, uiSchema, onSave, title, icon, get
|
|||
formData={formData}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => onSave(formData)}
|
||||
onChange={({ formData }) => setFormData(formData)}
|
||||
>
|
||||
|
@ -1072,7 +1064,6 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const toast = useToast()
|
||||
const [loadingSymbols, setLoadingSymbols] = useState(false)
|
||||
|
||||
const handleImportConfig = (importedData) => {
|
||||
onFormChange(importedData)
|
||||
|
@ -1084,59 +1075,6 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
})
|
||||
}
|
||||
|
||||
const handleLoadSymbols = async () => {
|
||||
const symbolsPath = formData?.plc_config?.symbols_path
|
||||
|
||||
if (!symbolsPath) {
|
||||
toast({
|
||||
title: 'No File Selected',
|
||||
description: 'Please select an ASC file first in the configuration',
|
||||
status: 'warning',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingSymbols(true)
|
||||
|
||||
const response = await fetch('/api/symbols/load', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
asc_file_path: symbolsPath
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: 'Symbols Loaded Successfully',
|
||||
description: `Successfully loaded ${data.symbols_count} symbols from ASC file`,
|
||||
status: 'success',
|
||||
duration: 4000,
|
||||
isClosable: true,
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to load symbols')
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Error Loading Symbols',
|
||||
description: `Failed to load symbols: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
})
|
||||
} finally {
|
||||
setLoadingSymbols(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!schemaData?.schema || !formData) {
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
|
@ -1157,27 +1095,12 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
{t('config.subtitle')}
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack spacing={3}>
|
||||
<ImportExportButtons
|
||||
data={formData}
|
||||
onImport={handleImportConfig}
|
||||
exportFilename="plc_config.json"
|
||||
configType="object"
|
||||
/>
|
||||
{/* Load Symbols button */}
|
||||
<Button
|
||||
leftIcon={<FiUpload />}
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
variant="outline"
|
||||
onClick={handleLoadSymbols}
|
||||
isLoading={loadingSymbols}
|
||||
isDisabled={!formData?.plc_config?.symbols_path || loadingSymbols}
|
||||
title="Load symbols from the configured ASC file"
|
||||
>
|
||||
🔄 Load Symbols
|
||||
</Button>
|
||||
</HStack>
|
||||
<ImportExportButtons
|
||||
data={formData}
|
||||
onImport={handleImportConfig}
|
||||
exportFilename="plc_config.json"
|
||||
configType="object"
|
||||
/>
|
||||
</Flex>
|
||||
{message && (
|
||||
<Alert status="success" mt={2}>
|
||||
|
@ -1193,10 +1116,7 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
formData={formData}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData }) => onFormChange(formData)}
|
||||
onSubmit={({ formData }) => onSave(formData)}
|
||||
>
|
||||
|
@ -1608,10 +1528,7 @@ function DatasetManager() {
|
|||
formData={selectedDatasetVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{
|
||||
ObjectFieldTemplate: LayoutObjectFieldTemplate,
|
||||
FieldTemplate: TooltipFieldTemplate
|
||||
}}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
updateSelectedDatasetVariables(formData)
|
||||
saveVariables(variablesConfig).then(() => {
|
||||
|
@ -1742,9 +1659,9 @@ function ConsoleLogsDisplay({ logs, loading, onRefresh }) {
|
|||
p={4}
|
||||
height="600px"
|
||||
overflowY="scroll"
|
||||
fontFamily="mono"
|
||||
fontSize="xs"
|
||||
lineHeight="1.3"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
lineHeight="1.4"
|
||||
color={logColor}
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': {
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
99
main.py
99
main.py
|
@ -121,8 +121,8 @@ CORS(
|
|||
resources={
|
||||
r"/api/*": {
|
||||
"origins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5173/app",
|
||||
"http://127.0.0.1:5173/app",
|
||||
"*",
|
||||
]
|
||||
}
|
||||
|
@ -160,25 +160,15 @@ def project_path(*parts: str) -> str:
|
|||
return os.path.join(base_dir, *parts)
|
||||
|
||||
|
||||
def get_records_directory(config_manager=None):
|
||||
"""Get the correct records directory path for dev and PyInstaller exe
|
||||
|
||||
Args:
|
||||
config_manager: Optional ConfigManager instance to read records_directory
|
||||
from config. If not provided, defaults to 'records' subdir
|
||||
"""
|
||||
if config_manager:
|
||||
# Use configured records directory (can be relative or absolute)
|
||||
return config_manager.get_csv_directory_path()
|
||||
def get_records_directory():
|
||||
"""Get the correct records directory path for both development and PyInstaller exe"""
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as PyInstaller executable - records should be next to the exe
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
return os.path.join(executable_dir, "records")
|
||||
else:
|
||||
# Fallback behavior for when config_manager is not available
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as PyInstaller executable - records should be next to the exe
|
||||
executable_dir = os.path.dirname(sys.executable)
|
||||
return os.path.join(executable_dir, "records")
|
||||
else:
|
||||
# Running as script - use current directory
|
||||
return os.path.join(os.path.dirname(__file__), "records")
|
||||
# Running as script - use current directory
|
||||
return os.path.join(os.path.dirname(__file__), "records")
|
||||
|
||||
|
||||
# React build directory (for Vite production build)
|
||||
|
@ -485,9 +475,8 @@ def update_plc_config():
|
|||
ip = data.get("ip", "10.1.33.11")
|
||||
rack = int(data.get("rack", 0))
|
||||
slot = int(data.get("slot", 2))
|
||||
symbols_path = data.get("symbols_path", "")
|
||||
|
||||
streamer.update_plc_config(ip, rack, slot, symbols_path)
|
||||
streamer.update_plc_config(ip, rack, slot)
|
||||
return jsonify({"success": True, "message": "PLC configuration updated"})
|
||||
|
||||
except Exception as e:
|
||||
|
@ -2106,8 +2095,7 @@ def get_historical_data():
|
|||
return jsonify({"error": f"Time calculation failed: {str(e)}"}), 500
|
||||
|
||||
# Get relevant CSV files for cache checking
|
||||
config_mgr = streamer.config_manager if streamer else None
|
||||
records_dir = get_records_directory(config_mgr)
|
||||
records_dir = get_records_directory()
|
||||
csv_files = []
|
||||
|
||||
if os.path.exists(records_dir):
|
||||
|
@ -2449,8 +2437,7 @@ def get_historical_date_range():
|
|||
import glob
|
||||
|
||||
# Get records directory
|
||||
config_mgr = streamer.config_manager if streamer else None
|
||||
records_dir = get_records_directory(config_mgr)
|
||||
records_dir = get_records_directory()
|
||||
|
||||
if not os.path.exists(records_dir):
|
||||
return (
|
||||
|
@ -2585,8 +2572,7 @@ def get_historical_data_segments():
|
|||
try:
|
||||
import glob
|
||||
|
||||
config_mgr = streamer.config_manager if streamer else None
|
||||
records_dir = get_records_directory(config_mgr)
|
||||
records_dir = get_records_directory()
|
||||
|
||||
if not os.path.exists(records_dir):
|
||||
return (
|
||||
|
@ -3372,8 +3358,7 @@ def main():
|
|||
time.sleep(2)
|
||||
|
||||
# Setup and run the system tray icon
|
||||
# Use the icon from public folder (included in frontend/dist build)
|
||||
icon_path = resource_path(os.path.join("frontend", "dist", "record.png"))
|
||||
icon_path = project_path("frontend", "src", "assets", "logo", "record.png")
|
||||
try:
|
||||
image = Image.open(icon_path)
|
||||
menu = pystray.Menu(
|
||||
|
@ -3562,43 +3547,6 @@ def browse_file():
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/utils/browse-directory", methods=["POST"])
|
||||
def browse_directory():
|
||||
"""Open directory dialog to browse for directories."""
|
||||
try:
|
||||
if not TKINTER_AVAILABLE:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Directory browser not available. Please enter the directory path manually.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
title = data.get("title", "Select Directory")
|
||||
|
||||
# Create a temporary tkinter root window
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide the root window
|
||||
root.attributes("-topmost", True) # Bring to front
|
||||
|
||||
# Open directory dialog
|
||||
directory_path = filedialog.askdirectory(title=title)
|
||||
|
||||
root.destroy() # Clean up
|
||||
|
||||
if directory_path:
|
||||
return jsonify({"success": True, "directory_path": directory_path})
|
||||
else:
|
||||
return jsonify({"success": True, "cancelled": True})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/symbols/load", methods=["POST"])
|
||||
def load_symbols():
|
||||
"""Load symbols from ASC file and save to JSON."""
|
||||
|
@ -3606,26 +3554,12 @@ def load_symbols():
|
|||
data = request.get_json()
|
||||
asc_file_path = data.get("asc_file_path")
|
||||
|
||||
# If no explicit path provided, try to get from plc_config
|
||||
if not asc_file_path and streamer:
|
||||
symbols_path = streamer.config_manager.plc_config.get("symbols_path", "")
|
||||
if symbols_path:
|
||||
# Handle absolute vs relative paths
|
||||
if os.path.isabs(symbols_path):
|
||||
asc_file_path = symbols_path
|
||||
else:
|
||||
asc_file_path = external_path(symbols_path)
|
||||
|
||||
if not asc_file_path:
|
||||
return (
|
||||
jsonify({"success": False, "error": "ASC file path is required"}),
|
||||
400,
|
||||
)
|
||||
|
||||
# Handle absolute vs relative paths for provided asc_file_path
|
||||
if not os.path.isabs(asc_file_path):
|
||||
asc_file_path = external_path(asc_file_path)
|
||||
|
||||
if not os.path.exists(asc_file_path):
|
||||
return (
|
||||
jsonify(
|
||||
|
@ -3839,8 +3773,7 @@ def process_symbol_variables():
|
|||
def get_csv_files():
|
||||
"""Get structured list of CSV files organized by dataset, date, and hour"""
|
||||
try:
|
||||
config_mgr = streamer.config_manager if streamer else None
|
||||
records_dir = get_records_directory(config_mgr)
|
||||
records_dir = get_records_directory()
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({"files": [], "tree": []})
|
||||
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"playwright-automation": {
|
||||
"command": "node",
|
||||
"args": ["src/server.js"],
|
||||
"cwd": "testing/mcp-server",
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"last_state": {
|
||||
"should_connect": true,
|
||||
"should_stream": false,
|
||||
"should_stream": true,
|
||||
"active_datasets": [
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-27T15:19:56.923648"
|
||||
"last_update": "2025-08-25T18:40:24.478882",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Screenshots y videos
|
||||
screenshots/*.png
|
||||
videos/*.webm
|
||||
|
||||
# Reportes y logs
|
||||
*.json
|
||||
*.log
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env.local
|
|
@ -1,291 +0,0 @@
|
|||
# Playwright MCP Automation for PLC Streamer
|
||||
|
||||
Este directorio contiene la configuración completa de Playwright para automatización y testing del proyecto PLC Streamer, con funcionalidad similar a MCP (Model Context Protocol) para interacción con navegadores.
|
||||
|
||||
## 🚀 Instalación Global
|
||||
|
||||
### 1. Instalar Playwright globalmente
|
||||
```bash
|
||||
npm install -g playwright
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### 2. Configurar para uso en otros proyectos
|
||||
```bash
|
||||
# Copiar el script global a un directorio en PATH
|
||||
cp playwright-mcp-global.js ~/.local/bin/playwright-mcp
|
||||
chmod +x ~/.local/bin/playwright-mcp
|
||||
|
||||
# O en Windows, copiar a un directorio en PATH
|
||||
copy playwright-mcp-global.js C:\tools\playwright-mcp.js
|
||||
```
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```
|
||||
testing/
|
||||
├── package.json # Dependencias de testing
|
||||
├── playwright.config.js # Configuración principal
|
||||
├── browser-automation.js # Automatización específica del proyecto
|
||||
├── playwright-mcp-global.js # Script global reutilizable
|
||||
├── tests/ # Tests específicos
|
||||
│ ├── dashboard.spec.js
|
||||
│ ├── configuration.spec.js
|
||||
│ ├── streaming.spec.js
|
||||
│ └── plotting.spec.js
|
||||
├── screenshots/ # Capturas de pantalla
|
||||
├── videos/ # Grabaciones de video
|
||||
└── README.md # Este archivo
|
||||
```
|
||||
|
||||
## 🛠️ Uso Local (Proyecto PLC Streamer)
|
||||
|
||||
### Ejecutar Tests
|
||||
```bash
|
||||
cd testing
|
||||
|
||||
# Ejecutar todos los tests
|
||||
npm test
|
||||
|
||||
# Ejecutar con interfaz visual
|
||||
npm run test:headed
|
||||
|
||||
# Ejecutar con modo debug
|
||||
npm run test:debug
|
||||
|
||||
# Ejecutar con UI de Playwright
|
||||
npm run ui
|
||||
```
|
||||
|
||||
### Generar Código de Test
|
||||
```bash
|
||||
# Generar código interactivo
|
||||
npm run codegen
|
||||
|
||||
# Generar código específico para la app
|
||||
npm run codegen:app
|
||||
```
|
||||
|
||||
### Automatización Específica del Proyecto
|
||||
```bash
|
||||
# Ejecutar automatización completa
|
||||
node browser-automation.js
|
||||
|
||||
# O importar en otro script
|
||||
import PLCStreamerBrowserAutomation from './browser-automation.js';
|
||||
|
||||
const automation = new PLCStreamerBrowserAutomation();
|
||||
await automation.initBrowser('chromium');
|
||||
await automation.testConfigurationFlow();
|
||||
```
|
||||
|
||||
## 🌐 Uso Global (Otros Proyectos)
|
||||
|
||||
### Script CLI Global
|
||||
```bash
|
||||
# Ejecutar tests básicos
|
||||
node playwright-mcp-global.js test --url http://localhost:3000 --browser chromium
|
||||
|
||||
# Capturar screenshots
|
||||
node playwright-mcp-global.js capture --url http://localhost:8080 --output ./captures
|
||||
|
||||
# Monitorear performance
|
||||
node playwright-mcp-global.js monitor --url http://localhost:3000 --duration 120
|
||||
```
|
||||
|
||||
### Ejemplo de Integración en Otros Proyectos
|
||||
```javascript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
// Para React apps
|
||||
const testReactApp = async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.waitForSelector('[data-testid="app"]');
|
||||
|
||||
// Tu lógica de testing aquí
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
// Para Vue apps
|
||||
const testVueApp = async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('http://localhost:8080');
|
||||
await page.waitForSelector('#app');
|
||||
|
||||
// Tu lógica de testing aquí
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
```
|
||||
|
||||
## 🔧 Funcionalidades MCP-like
|
||||
|
||||
### 1. Automatización de Navegador
|
||||
- Control completo del navegador (Chromium, Firefox, WebKit)
|
||||
- Interacción con elementos de la página
|
||||
- Manejo de formularios y eventos
|
||||
- Navegación automática entre páginas
|
||||
|
||||
### 2. Captura de Datos
|
||||
- Screenshots automáticos
|
||||
- Grabación de video de sesiones
|
||||
- Métricas de performance
|
||||
- Logs de consola y errores
|
||||
|
||||
### 3. Monitoreo en Tiempo Real
|
||||
- Estado de conexión PLC
|
||||
- Performance de la aplicación
|
||||
- Uso de memoria
|
||||
- Tiempos de carga
|
||||
|
||||
### 4. Reporting Automático
|
||||
- Generación de reportes JSON
|
||||
- Métricas de performance
|
||||
- Estados de la aplicación
|
||||
- Capturas de evidencia
|
||||
|
||||
## 📊 Tests Específicos del PLC Streamer
|
||||
|
||||
### Dashboard Tests (`dashboard.spec.js`)
|
||||
- Carga correcta de la aplicación
|
||||
- Navegación entre tabs
|
||||
- Estado de conexión PLC
|
||||
- Elementos de interfaz
|
||||
|
||||
### Configuration Tests (`configuration.spec.js`)
|
||||
- Formularios de configuración
|
||||
- Validación de datos
|
||||
- Guardado de configuraciones
|
||||
- Manejo de errores
|
||||
|
||||
### Streaming Tests (`streaming.spec.js`)
|
||||
- Control de streaming
|
||||
- Gestión de variables
|
||||
- Configuración de datasets
|
||||
- Estados de streaming
|
||||
|
||||
### Plotting Tests (`plotting.spec.js`)
|
||||
- Creación de plots
|
||||
- Actualización en tiempo real
|
||||
- Exportación de datos
|
||||
- Gestión de sesiones
|
||||
|
||||
## 🚀 Comandos Rápidos
|
||||
|
||||
```bash
|
||||
# Setup inicial
|
||||
npm install
|
||||
|
||||
# Test completo con reporte
|
||||
npm test -- --reporter=html
|
||||
|
||||
# Debug test específico
|
||||
npx playwright test dashboard.spec.js --debug
|
||||
|
||||
# Generar código para nueva funcionalidad
|
||||
npx playwright codegen http://localhost:5173/app
|
||||
|
||||
# Ejecutar con todos los navegadores
|
||||
npx playwright test --project=chromium --project=firefox --project=webkit
|
||||
|
||||
# Solo móviles
|
||||
npx playwright test --project="Mobile Chrome" --project="Mobile Safari"
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Ver Reports HTML
|
||||
```bash
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
### Ver Trace Viewer
|
||||
```bash
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
### Modo Debug Interactivo
|
||||
```bash
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
## 🌟 Características Avanzadas
|
||||
|
||||
### 1. Test Paralelo
|
||||
Los tests se ejecutan en paralelo por defecto para mayor velocidad.
|
||||
|
||||
### 2. Auto-waiting
|
||||
Playwright espera automáticamente a que los elementos estén listos.
|
||||
|
||||
### 3. Cross-browser
|
||||
Soporte nativo para Chromium, Firefox y WebKit.
|
||||
|
||||
### 4. Mobile Testing
|
||||
Tests en viewports móviles incluidos.
|
||||
|
||||
### 5. Video Recording
|
||||
Grabación automática de videos en fallos.
|
||||
|
||||
### 6. Network Interception
|
||||
Capacidad de interceptar y modificar requests de red.
|
||||
|
||||
## 🔧 Configuración Personalizada
|
||||
|
||||
### Modificar `playwright.config.js`
|
||||
```javascript
|
||||
export default defineConfig({
|
||||
// Tu configuración personalizada
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 2,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Variables de Entorno
|
||||
```bash
|
||||
# Configurar URLs dinámicamente
|
||||
FRONTEND_URL=http://localhost:3000 npm test
|
||||
BACKEND_URL=http://localhost:8000 npm test
|
||||
```
|
||||
|
||||
## 📝 Integración con CI/CD
|
||||
|
||||
### GitHub Actions Example
|
||||
```yaml
|
||||
- name: Install Playwright
|
||||
run: npx playwright install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
```
|
||||
|
||||
## 🤝 Contribuir
|
||||
|
||||
1. Agregar nuevos tests en `/tests/`
|
||||
2. Extender `browser-automation.js` con nuevas funcionalidades
|
||||
3. Actualizar configuración en `playwright.config.js`
|
||||
4. Documentar cambios en este README
|
||||
|
||||
## 📚 Recursos Adicionales
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [Playwright Test](https://playwright.dev/docs/test-intro)
|
||||
- [Playwright Inspector](https://playwright.dev/docs/inspector)
|
||||
- [Playwright Trace Viewer](https://playwright.dev/docs/trace-viewer)
|
|
@ -1,255 +0,0 @@
|
|||
import { chromium, firefox, webkit } from 'playwright';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* PLC Streamer Browser Automation and Testing Suite
|
||||
* This class provides MCP-like functionality for browser automation
|
||||
*/
|
||||
class PLCStreamerBrowserAutomation {
|
||||
constructor() {
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
this.baseURL = 'http://localhost:5173';
|
||||
this.backendURL = 'http://localhost:5000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize browser instance
|
||||
* @param {string} browserType - 'chromium', 'firefox', or 'webkit'
|
||||
* @param {object} options - Browser launch options
|
||||
*/
|
||||
async initBrowser(browserType = 'chromium', options = {}) {
|
||||
const browserLaunchers = {
|
||||
chromium: chromium,
|
||||
firefox: firefox,
|
||||
webkit: webkit
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
headless: false,
|
||||
slowMo: 100,
|
||||
args: ['--disable-web-security', '--disable-features=VizDisplayCompositor']
|
||||
};
|
||||
|
||||
this.browser = await browserLaunchers[browserType].launch({
|
||||
...defaultOptions,
|
||||
...options
|
||||
});
|
||||
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
recordVideo: { dir: 'videos/' }
|
||||
});
|
||||
|
||||
this.page = await this.context.newPage();
|
||||
|
||||
// Add console logging
|
||||
this.page.on('console', msg => {
|
||||
console.log(`Browser Console [${msg.type()}]: ${msg.text()}`);
|
||||
});
|
||||
|
||||
// Add error logging
|
||||
this.page.on('pageerror', error => {
|
||||
console.error(`Browser Error: ${error.message}`);
|
||||
});
|
||||
|
||||
return this.page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the PLC Streamer application
|
||||
*/
|
||||
async navigateToApp() {
|
||||
await this.page.goto(`${this.baseURL}/app`);
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
return this.page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backend server is running
|
||||
*/
|
||||
async checkBackendStatus() {
|
||||
try {
|
||||
const response = await this.page.request.get(`${this.backendURL}/api/status`);
|
||||
return response.ok();
|
||||
} catch (error) {
|
||||
console.error('Backend not reachable:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor PLC connection status
|
||||
*/
|
||||
async monitorPLCConnection() {
|
||||
await this.navigateToApp();
|
||||
|
||||
const connectionStatus = await this.page.locator('[data-testid="connection-status"]').textContent();
|
||||
console.log(`PLC Connection Status: ${connectionStatus}`);
|
||||
|
||||
return connectionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automated configuration testing
|
||||
*/
|
||||
async testConfigurationFlow() {
|
||||
await this.navigateToApp();
|
||||
|
||||
// Navigate to configuration
|
||||
await this.page.click('text=Configuration');
|
||||
await this.page.waitForSelector('[data-testid="configuration-panel"]');
|
||||
|
||||
// Test PLC configuration
|
||||
const ipInput = this.page.locator('input[name*="ip"]').first();
|
||||
await ipInput.clear();
|
||||
await ipInput.fill('192.168.1.100');
|
||||
|
||||
// Save configuration
|
||||
await this.page.click('button:has-text("Save")');
|
||||
|
||||
// Wait for save confirmation
|
||||
await this.page.waitForSelector('text*=saved', { timeout: 5000 });
|
||||
|
||||
console.log('Configuration test completed successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automated streaming test
|
||||
*/
|
||||
async testStreamingFlow() {
|
||||
await this.navigateToApp();
|
||||
|
||||
// Navigate to streaming
|
||||
await this.page.click('text=Data Streaming');
|
||||
await this.page.waitForSelector('[data-testid="streaming-panel"]');
|
||||
|
||||
// Start streaming
|
||||
await this.page.click('button:has-text("Start Streaming")');
|
||||
|
||||
// Wait a bit for streaming to initialize
|
||||
await this.page.waitForTimeout(3000);
|
||||
|
||||
// Stop streaming
|
||||
await this.page.click('button:has-text("Stop Streaming")');
|
||||
|
||||
console.log('Streaming test completed successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take screenshots for documentation
|
||||
*/
|
||||
async captureScreenshots() {
|
||||
await this.navigateToApp();
|
||||
|
||||
const tabs = ['Dashboard', 'Configuration', 'Data Streaming', 'Plots'];
|
||||
|
||||
for (const tab of tabs) {
|
||||
await this.page.click(`text=${tab}`);
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
const screenshot = await this.page.screenshot({
|
||||
path: `screenshots/${tab.toLowerCase().replace(' ', '_')}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
console.log(`Screenshot saved: ${tab}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance monitoring
|
||||
*/
|
||||
async monitorPerformance() {
|
||||
await this.navigateToApp();
|
||||
|
||||
// Start performance monitoring
|
||||
await this.page.evaluate(() => performance.mark('test-start'));
|
||||
|
||||
// Navigate through all tabs
|
||||
const tabs = ['Configuration', 'Data Streaming', 'Plots', 'Dashboard'];
|
||||
|
||||
for (const tab of tabs) {
|
||||
const startTime = performance.now();
|
||||
await this.page.click(`text=${tab}`);
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
const endTime = performance.now();
|
||||
|
||||
console.log(`Tab ${tab} load time: ${endTime - startTime}ms`);
|
||||
}
|
||||
|
||||
// Get performance metrics
|
||||
const metrics = await this.page.evaluate(() => {
|
||||
return JSON.stringify(performance.getEntriesByType('navigation'));
|
||||
});
|
||||
|
||||
await fs.writeFile('performance-metrics.json', metrics);
|
||||
console.log('Performance metrics saved');
|
||||
|
||||
return JSON.parse(metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test report
|
||||
*/
|
||||
async generateReport() {
|
||||
const backendStatus = await this.checkBackendStatus();
|
||||
const plcStatus = await this.monitorPLCConnection();
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
backendStatus: backendStatus ? 'Online' : 'Offline',
|
||||
plcConnectionStatus: plcStatus,
|
||||
browserInfo: await this.page.evaluate(() => navigator.userAgent),
|
||||
url: this.page.url()
|
||||
};
|
||||
|
||||
await fs.writeFile('automation-report.json', JSON.stringify(report, null, 2));
|
||||
console.log('Report generated: automation-report.json');
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
async cleanup() {
|
||||
if (this.page) await this.page.close();
|
||||
if (this.context) await this.context.close();
|
||||
if (this.browser) await this.browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
export default PLCStreamerBrowserAutomation;
|
||||
|
||||
// CLI usage example
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const automation = new PLCStreamerBrowserAutomation();
|
||||
|
||||
try {
|
||||
await automation.initBrowser('chromium', { headless: false });
|
||||
|
||||
console.log('Starting automated testing...');
|
||||
|
||||
// Run all tests
|
||||
await automation.testConfigurationFlow();
|
||||
await automation.testStreamingFlow();
|
||||
await automation.captureScreenshots();
|
||||
await automation.monitorPerformance();
|
||||
await automation.generateReport();
|
||||
|
||||
console.log('Automation completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Automation failed:', error);
|
||||
} finally {
|
||||
await automation.cleanup();
|
||||
}
|
||||
}
|
|
@ -1,163 +0,0 @@
|
|||
import PLCStreamerBrowserAutomation from './browser-automation.js';
|
||||
|
||||
/**
|
||||
* Ejemplo de uso de la automatización de Playwright para PLC Streamer
|
||||
*/
|
||||
async function runExample() {
|
||||
const automation = new PLCStreamerBrowserAutomation();
|
||||
|
||||
try {
|
||||
console.log('🚀 Iniciando automatización de ejemplo...');
|
||||
|
||||
// Inicializar navegador
|
||||
await automation.initBrowser('chromium', {
|
||||
headless: false,
|
||||
slowMo: 500 // Ralentizar para ver las acciones
|
||||
});
|
||||
|
||||
console.log('✅ Navegador inicializado');
|
||||
|
||||
// Verificar que el backend esté funcionando
|
||||
const backendStatus = await automation.checkBackendStatus();
|
||||
console.log(`🔧 Estado del backend: ${backendStatus ? 'Online' : 'Offline'}`);
|
||||
|
||||
if (!backendStatus) {
|
||||
console.log('⚠️ Backend no disponible. Asegúrate de que esté ejecutándose en puerto 5000');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navegar a la aplicación
|
||||
console.log('🌐 Navegando a la aplicación...');
|
||||
await automation.navigateToApp();
|
||||
|
||||
// Monitorear estado PLC
|
||||
console.log('🔌 Verificando estado de conexión PLC...');
|
||||
const plcStatus = await automation.monitorPLCConnection();
|
||||
console.log(`PLC Status: ${plcStatus}`);
|
||||
|
||||
// Test de configuración
|
||||
console.log('⚙️ Ejecutando test de configuración...');
|
||||
await automation.testConfigurationFlow();
|
||||
|
||||
// Test de streaming
|
||||
console.log('📊 Ejecutando test de streaming...');
|
||||
await automation.testStreamingFlow();
|
||||
|
||||
// Capturar screenshots
|
||||
console.log('📸 Capturando screenshots...');
|
||||
await automation.captureScreenshots();
|
||||
|
||||
// Monitorear performance
|
||||
console.log('⚡ Analizando performance...');
|
||||
await automation.monitorPerformance();
|
||||
|
||||
// Generar reporte
|
||||
console.log('📋 Generando reporte...');
|
||||
const report = await automation.generateReport();
|
||||
|
||||
console.log('🎉 Automatización completada exitosamente!');
|
||||
console.log('📊 Reporte:', report);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error en la automatización:', error);
|
||||
} finally {
|
||||
// Cleanup
|
||||
await automation.cleanup();
|
||||
console.log('🧹 Recursos liberados');
|
||||
}
|
||||
}
|
||||
|
||||
// Función para testing específico
|
||||
async function testSpecificFeature(feature) {
|
||||
const automation = new PLCStreamerBrowserAutomation();
|
||||
|
||||
try {
|
||||
await automation.initBrowser('chromium', { headless: false });
|
||||
await automation.navigateToApp();
|
||||
|
||||
switch (feature) {
|
||||
case 'configuration':
|
||||
await automation.testConfigurationFlow();
|
||||
break;
|
||||
case 'streaming':
|
||||
await automation.testStreamingFlow();
|
||||
break;
|
||||
case 'screenshots':
|
||||
await automation.captureScreenshots();
|
||||
break;
|
||||
case 'performance':
|
||||
await automation.monitorPerformance();
|
||||
break;
|
||||
default:
|
||||
console.log('Características disponibles: configuration, streaming, screenshots, performance');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
await automation.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Función para monitoreo continuo
|
||||
async function continuousMonitoring(durationMinutes = 5) {
|
||||
const automation = new PLCStreamerBrowserAutomation();
|
||||
|
||||
try {
|
||||
await automation.initBrowser('chromium', { headless: true });
|
||||
await automation.navigateToApp();
|
||||
|
||||
const endTime = Date.now() + (durationMinutes * 60 * 1000);
|
||||
|
||||
console.log(`🔄 Iniciando monitoreo continuo por ${durationMinutes} minutos...`);
|
||||
|
||||
while (Date.now() < endTime) {
|
||||
const status = await automation.monitorPLCConnection();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
console.log(`[${timestamp}] PLC Status: ${status}`);
|
||||
|
||||
// Esperar 30 segundos antes del siguiente check
|
||||
await new Promise(resolve => setTimeout(resolve, 30000));
|
||||
}
|
||||
|
||||
console.log('✅ Monitoreo continuo completado');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error en monitoreo:', error);
|
||||
} finally {
|
||||
await automation.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar argumentos de línea de comandos
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
// Ejecutar ejemplo completo
|
||||
runExample();
|
||||
} else if (args[0] === 'test' && args[1]) {
|
||||
// Ejecutar test específico
|
||||
testSpecificFeature(args[1]);
|
||||
} else if (args[0] === 'monitor') {
|
||||
// Ejecutar monitoreo continuo
|
||||
const duration = args[1] ? parseInt(args[1]) : 5;
|
||||
continuousMonitoring(duration);
|
||||
} else {
|
||||
console.log(`
|
||||
Uso:
|
||||
node example.js # Ejecutar ejemplo completo
|
||||
node example.js test <feature> # Ejecutar test específico
|
||||
node example.js monitor [minutes] # Monitoreo continuo
|
||||
|
||||
Características disponibles:
|
||||
- configuration
|
||||
- streaming
|
||||
- screenshots
|
||||
- performance
|
||||
|
||||
Ejemplos:
|
||||
node example.js test configuration
|
||||
node example.js monitor 10
|
||||
`);
|
||||
}
|
|
@ -1,186 +0,0 @@
|
|||
# Playwright MCP Server para GitHub Copilot
|
||||
|
||||
Este es un servidor MCP (Model Context Protocol) que permite a GitHub Copilot controlar navegadores web usando Playwright para automatizar tu aplicación PLC Streamer.
|
||||
|
||||
## 🎯 ¿Qué hace esto?
|
||||
|
||||
Permite que GitHub Copilot:
|
||||
- **Lance navegadores** automáticamente
|
||||
- **Navegue por tu aplicación** PLC Streamer
|
||||
- **Interactúe con elementos** (clicks, llenar formularios, etc.)
|
||||
- **Capture screenshots** y datos
|
||||
- **Monitoree el estado PLC** en tiempo real
|
||||
- **Ejecute tests automáticos** de tu aplicación
|
||||
|
||||
## 🚀 Configuración para GitHub Copilot
|
||||
|
||||
### 1. Configuración Global de MCP
|
||||
|
||||
Crea o edita el archivo de configuración MCP en tu sistema:
|
||||
|
||||
**Windows:**
|
||||
```
|
||||
%APPDATA%\Code\User\globalStorage\github.copilot-chat\mcpServers.json
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```
|
||||
~/Library/Application Support/Code/User/globalStorage/github.copilot-chat/mcpServers.json
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```
|
||||
~/.config/Code/User/globalStorage/github.copilot-chat/mcpServers.json
|
||||
```
|
||||
|
||||
### 2. Contenido del archivo de configuración:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright-automation": {
|
||||
"command": "node",
|
||||
"args": ["src/server.js"],
|
||||
"cwd": "D:\\Proyectos\\Scripts\\Siemens\\S7_snap7_Streamer_n_Log\\testing\\mcp-server",
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ Importante:** Cambia la ruta `cwd` por tu ruta absoluta real del proyecto.
|
||||
|
||||
### 3. Reiniciar VS Code
|
||||
|
||||
Después de agregar la configuración, reinicia VS Code para que GitHub Copilot detecte el servidor MCP.
|
||||
|
||||
## 🧪 Probar el Servidor
|
||||
|
||||
### Prueba manual:
|
||||
```bash
|
||||
cd testing/mcp-server
|
||||
npm test
|
||||
```
|
||||
|
||||
### Verificar conexión:
|
||||
```bash
|
||||
cd testing/mcp-server
|
||||
npm start
|
||||
# El servidor debería mostrar: "Playwright MCP Server running on stdio"
|
||||
```
|
||||
|
||||
## 💬 Comandos para GitHub Copilot
|
||||
|
||||
Una vez configurado, puedes usar estos comandos con GitHub Copilot:
|
||||
|
||||
### Básicos:
|
||||
```
|
||||
@copilot Abre un navegador y navega a mi aplicación PLC Streamer
|
||||
@copilot Toma una captura de pantalla de la aplicación
|
||||
@copilot Obtén el estado de conexión del PLC
|
||||
@copilot Haz click en el tab "Configuration"
|
||||
```
|
||||
|
||||
### Automatización PLC:
|
||||
```
|
||||
@copilot Configura el PLC con IP 192.168.1.100
|
||||
@copilot Inicia el streaming de datos
|
||||
@copilot Monitorea el estado del streaming por 30 segundos
|
||||
@copilot Crea un plot con las variables de temperatura
|
||||
```
|
||||
|
||||
### Testing automatizado:
|
||||
```
|
||||
@copilot Ejecuta un test completo de la aplicación
|
||||
@copilot Verifica que todos los tabs funcionen correctamente
|
||||
@copilot Prueba la configuración PLC con diferentes IPs
|
||||
@copilot Captura métricas de performance de la aplicación
|
||||
```
|
||||
|
||||
### Debugging:
|
||||
```
|
||||
@copilot Navega a la aplicación y dime si hay errores en la consola
|
||||
@copilot Verifica si el backend está respondiendo
|
||||
@copilot Toma screenshots de todos los tabs de la aplicación
|
||||
```
|
||||
|
||||
## 🛠️ Herramientas Disponibles
|
||||
|
||||
El servidor MCP proporciona estas herramientas a Copilot:
|
||||
|
||||
### Navegador:
|
||||
- `launch_browser` - Lanzar navegador
|
||||
- `navigate_to` - Navegar a URL
|
||||
- `close_browser` - Cerrar navegador
|
||||
- `take_screenshot` - Capturar pantalla
|
||||
- `get_page_info` - Info de la página
|
||||
|
||||
### Interacción:
|
||||
- `click_element` - Hacer click
|
||||
- `fill_input` - Llenar campos
|
||||
- `get_text` - Obtener texto
|
||||
- `wait_for_element` - Esperar elemento
|
||||
- `execute_script` - Ejecutar JavaScript
|
||||
|
||||
### PLC Específico:
|
||||
- `get_plc_status` - Estado PLC
|
||||
- `test_configuration` - Probar config PLC
|
||||
- `monitor_streaming` - Monitorear streaming
|
||||
- `capture_performance` - Métricas de performance
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Error: "No se puede conectar al servidor MCP"
|
||||
1. Verifica que la ruta en `mcpServers.json` sea correcta
|
||||
2. Asegúrate de que Node.js esté instalado
|
||||
3. Reinicia VS Code completamente
|
||||
|
||||
### Error: "Herramientas no disponibles"
|
||||
1. Verifica que el servidor inicie sin errores
|
||||
2. Revisa los logs en la consola de VS Code
|
||||
3. Ejecuta la prueba manual: `npm test`
|
||||
|
||||
### El navegador no se abre:
|
||||
1. Verifica que Playwright esté instalado: `npx playwright install`
|
||||
2. Prueba manualmente: `node src/test-server.js`
|
||||
|
||||
### La aplicación no responde:
|
||||
1. Asegúrate de que el frontend esté corriendo: `npm run dev` en `frontend/`
|
||||
2. Verifica que el backend esté activo (puerto 5000)
|
||||
|
||||
## 📊 Ejemplo de Uso Completo
|
||||
|
||||
```bash
|
||||
# 1. Usuario pregunta a Copilot:
|
||||
"Abre mi aplicación PLC Streamer, configura el PLC con IP 192.168.1.50,
|
||||
inicia el streaming y toma una captura de pantalla"
|
||||
|
||||
# 2. Copilot ejecutará automáticamente:
|
||||
- launch_browser()
|
||||
- navigate_to("http://localhost:5173/app")
|
||||
- click_element("Configuration")
|
||||
- test_configuration({ip: "192.168.1.50"})
|
||||
- click_element("Data Streaming")
|
||||
- monitor_streaming({action: "start"})
|
||||
- take_screenshot()
|
||||
```
|
||||
|
||||
## 🔒 Seguridad
|
||||
|
||||
- El servidor solo acepta conexiones locales
|
||||
- No expone datos sensibles del PLC
|
||||
- Las sesiones de navegador se aíslan automáticamente
|
||||
- Los screenshots se guardan localmente
|
||||
|
||||
## 📈 Logs y Debugging
|
||||
|
||||
Los logs del servidor aparecen en:
|
||||
- Consola de VS Code (panel "Output" → "GitHub Copilot Chat")
|
||||
- Terminal donde ejecutes `npm start`
|
||||
- Archivos de error en `testing/mcp-server/logs/`
|
||||
|
||||
---
|
||||
|
||||
¡Con esta configuración, GitHub Copilot puede controlar tu aplicación PLC Streamer como si fuera un asistente experto en automatización industrial! 🏭🤖
|
|
@ -1,952 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Playwright MCP Server
|
||||
* Provides browser automation capabilities to GitHub Copilot via MCP protocol
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { chromium, firefox, webkit } from 'playwright';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class PlaywrightMCPServer {
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'playwright-automation',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.browsers = new Map();
|
||||
this.pages = new Map();
|
||||
this.sessions = new Map();
|
||||
|
||||
this.setupTools();
|
||||
}
|
||||
|
||||
setupTools() {
|
||||
// Tool: Launch Browser
|
||||
this.server.setRequestHandler('tools/call', async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
switch (name) {
|
||||
case 'launch_browser':
|
||||
return await this.launchBrowser(args);
|
||||
|
||||
case 'navigate_to':
|
||||
return await this.navigateTo(args);
|
||||
|
||||
case 'click_element':
|
||||
return await this.clickElement(args);
|
||||
|
||||
case 'fill_input':
|
||||
return await this.fillInput(args);
|
||||
|
||||
case 'get_text':
|
||||
return await this.getText(args);
|
||||
|
||||
case 'take_screenshot':
|
||||
return await this.takeScreenshot(args);
|
||||
|
||||
case 'wait_for_element':
|
||||
return await this.waitForElement(args);
|
||||
|
||||
case 'get_page_info':
|
||||
return await this.getPageInfo(args);
|
||||
|
||||
case 'close_browser':
|
||||
return await this.closeBrowser(args);
|
||||
|
||||
case 'execute_script':
|
||||
return await this.executeScript(args);
|
||||
|
||||
case 'get_plc_status':
|
||||
return await this.getPLCStatus(args);
|
||||
|
||||
case 'test_configuration':
|
||||
return await this.testConfiguration(args);
|
||||
|
||||
case 'monitor_streaming':
|
||||
return await this.monitorStreaming(args);
|
||||
|
||||
case 'capture_performance':
|
||||
return await this.capturePerformance(args);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// List available tools
|
||||
this.server.setRequestHandler('tools/list', async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'launch_browser',
|
||||
description: 'Launch a new browser instance',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
browserType: {
|
||||
type: 'string',
|
||||
enum: ['chromium', 'firefox', 'webkit'],
|
||||
default: 'chromium',
|
||||
description: 'Browser type to launch'
|
||||
},
|
||||
headless: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run browser in headless mode'
|
||||
},
|
||||
viewport: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: { type: 'number', default: 1920 },
|
||||
height: { type: 'number', default: 1080 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'navigate_to',
|
||||
description: 'Navigate to a URL',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
url: { type: 'string', description: 'URL to navigate to' },
|
||||
waitUntil: {
|
||||
type: 'string',
|
||||
enum: ['load', 'domcontentloaded', 'networkidle'],
|
||||
default: 'load'
|
||||
}
|
||||
},
|
||||
required: ['sessionId', 'url']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click_element',
|
||||
description: 'Click on an element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
selector: { type: 'string', description: 'CSS selector for the element' },
|
||||
text: { type: 'string', description: 'Text content to match (alternative to selector)' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fill_input',
|
||||
description: 'Fill an input field',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
selector: { type: 'string', description: 'CSS selector for the input' },
|
||||
value: { type: 'string', description: 'Value to fill' },
|
||||
clear: { type: 'boolean', default: true, description: 'Clear field before filling' }
|
||||
},
|
||||
required: ['sessionId', 'selector', 'value']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_text',
|
||||
description: 'Get text content from an element',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
selector: { type: 'string', description: 'CSS selector for the element' }
|
||||
},
|
||||
required: ['sessionId', 'selector']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'take_screenshot',
|
||||
description: 'Take a screenshot of the page',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
path: { type: 'string', description: 'Path to save screenshot' },
|
||||
fullPage: { type: 'boolean', default: false, description: 'Capture full page' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wait_for_element',
|
||||
description: 'Wait for an element to appear',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
selector: { type: 'string', description: 'CSS selector for the element' },
|
||||
timeout: { type: 'number', default: 30000, description: 'Timeout in milliseconds' },
|
||||
state: {
|
||||
type: 'string',
|
||||
enum: ['attached', 'detached', 'visible', 'hidden'],
|
||||
default: 'visible'
|
||||
}
|
||||
},
|
||||
required: ['sessionId', 'selector']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_page_info',
|
||||
description: 'Get information about the current page',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'close_browser',
|
||||
description: 'Close a browser session',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'execute_script',
|
||||
description: 'Execute JavaScript on the page',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
script: { type: 'string', description: 'JavaScript code to execute' }
|
||||
},
|
||||
required: ['sessionId', 'script']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_plc_status',
|
||||
description: 'Get PLC connection status from the PLC Streamer app',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'test_configuration',
|
||||
description: 'Test PLC configuration in the app',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
ip: { type: 'string', description: 'PLC IP address' },
|
||||
rack: { type: 'number', description: 'PLC rack number' },
|
||||
slot: { type: 'number', description: 'PLC slot number' }
|
||||
},
|
||||
required: ['sessionId', 'ip']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'monitor_streaming',
|
||||
description: 'Monitor data streaming status',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' },
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['start', 'stop', 'status'],
|
||||
description: 'Streaming action to perform'
|
||||
}
|
||||
},
|
||||
required: ['sessionId', 'action']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'capture_performance',
|
||||
description: 'Capture performance metrics from the application',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', description: 'Browser session ID' }
|
||||
},
|
||||
required: ['sessionId']
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async launchBrowser(args) {
|
||||
const { browserType = 'chromium', headless = false, viewport = { width: 1920, height: 1080 } } = args;
|
||||
|
||||
try {
|
||||
const browsers = { chromium, firefox, webkit };
|
||||
const browser = await browsers[browserType].launch({
|
||||
headless,
|
||||
slowMo: 100,
|
||||
args: ['--disable-web-security', '--disable-features=VizDisplayCompositor']
|
||||
});
|
||||
|
||||
const context = await browser.newContext({ viewport });
|
||||
const page = await context.newPage();
|
||||
|
||||
const sessionId = uuidv4();
|
||||
|
||||
this.browsers.set(sessionId, browser);
|
||||
this.pages.set(sessionId, page);
|
||||
this.sessions.set(sessionId, {
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
browserType,
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
// Add console and error logging
|
||||
page.on('console', msg => {
|
||||
console.log(`[${sessionId}] Console [${msg.type()}]: ${msg.text()}`);
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
console.error(`[${sessionId}] Page Error: ${error.message}`);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
sessionId,
|
||||
browserType,
|
||||
message: `Browser ${browserType} launched successfully`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async navigateTo(args) {
|
||||
const { sessionId, url, waitUntil = 'load' } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
await page.goto(url, { waitUntil });
|
||||
|
||||
const title = await page.title();
|
||||
const currentUrl = page.url();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
url: currentUrl,
|
||||
message: `Navigated to ${url}`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async clickElement(args) {
|
||||
const { sessionId, selector, text } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
let locator;
|
||||
if (text) {
|
||||
locator = page.getByText(text);
|
||||
} else if (selector) {
|
||||
locator = page.locator(selector);
|
||||
} else {
|
||||
throw new Error('Either selector or text must be provided');
|
||||
}
|
||||
|
||||
await locator.click();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Clicked element ${selector || text}`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fillInput(args) {
|
||||
const { sessionId, selector, value, clear = true } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const input = page.locator(selector);
|
||||
|
||||
if (clear) {
|
||||
await input.clear();
|
||||
}
|
||||
|
||||
await input.fill(value);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Filled input ${selector} with value`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getText(args) {
|
||||
const { sessionId, selector } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const text = await page.locator(selector).textContent();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
text,
|
||||
selector
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshot(args) {
|
||||
const { sessionId, path, fullPage = false } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const screenshotPath = path || `screenshots/screenshot-${sessionId}-${Date.now()}.png`;
|
||||
|
||||
await page.screenshot({
|
||||
path: screenshotPath,
|
||||
fullPage
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
path: screenshotPath,
|
||||
message: 'Screenshot captured successfully'
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(args) {
|
||||
const { sessionId, selector, timeout = 30000, state = 'visible' } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
await page.locator(selector).waitFor({ state, timeout });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Element ${selector} is ${state}`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getPageInfo(args) {
|
||||
const { sessionId } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const title = await page.title();
|
||||
const url = page.url();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
title,
|
||||
url,
|
||||
viewport
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async closeBrowser(args) {
|
||||
const { sessionId } = args;
|
||||
|
||||
try {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
await session.browser.close();
|
||||
|
||||
this.browsers.delete(sessionId);
|
||||
this.pages.delete(sessionId);
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `Browser session ${sessionId} closed`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeScript(args) {
|
||||
const { sessionId, script } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const result = await page.evaluate(script);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
result
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PLC-specific tools
|
||||
async getPLCStatus(args) {
|
||||
const { sessionId } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Navigate to PLC Streamer app if not already there
|
||||
const currentUrl = page.url();
|
||||
if (!currentUrl.includes('localhost:5173')) {
|
||||
await page.goto('http://localhost:5173/app');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
const statusElement = page.locator('[data-testid="connection-status"]');
|
||||
const status = await statusElement.textContent().catch(() => 'Unknown');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
plcStatus: status,
|
||||
timestamp: new Date().toISOString()
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async testConfiguration(args) {
|
||||
const { sessionId, ip, rack = 0, slot = 1 } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Navigate to configuration tab
|
||||
await page.click('text=Configuration');
|
||||
await page.waitForSelector('[data-testid="configuration-panel"]');
|
||||
|
||||
// Fill PLC configuration
|
||||
const ipInput = page.locator('input[name*="ip"]').first();
|
||||
await ipInput.clear();
|
||||
await ipInput.fill(ip);
|
||||
|
||||
if (rack !== undefined) {
|
||||
const rackInput = page.locator('input[name*="rack"]').first();
|
||||
await rackInput.clear();
|
||||
await rackInput.fill(rack.toString());
|
||||
}
|
||||
|
||||
if (slot !== undefined) {
|
||||
const slotInput = page.locator('input[name*="slot"]').first();
|
||||
await slotInput.clear();
|
||||
await slotInput.fill(slot.toString());
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForSelector('text*=saved', { timeout: 5000 });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: `PLC configuration updated: IP=${ip}, Rack=${rack}, Slot=${slot}`
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async monitorStreaming(args) {
|
||||
const { sessionId, action } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Navigate to streaming tab
|
||||
await page.click('text=Data Streaming');
|
||||
await page.waitForSelector('[data-testid="streaming-panel"]');
|
||||
|
||||
let result;
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await page.click('button:has-text("Start Streaming")');
|
||||
result = 'Streaming started';
|
||||
break;
|
||||
case 'stop':
|
||||
await page.click('button:has-text("Stop Streaming")');
|
||||
result = 'Streaming stopped';
|
||||
break;
|
||||
case 'status':
|
||||
// Get current streaming status
|
||||
const statusText = await page.locator('[data-testid="streaming-status"]').textContent().catch(() => 'Unknown');
|
||||
result = `Streaming status: ${statusText}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
action,
|
||||
result
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async capturePerformance(args) {
|
||||
const { sessionId } = args;
|
||||
|
||||
try {
|
||||
const page = this.pages.get(sessionId);
|
||||
if (!page) {
|
||||
throw new Error(`No browser session found with ID: ${sessionId}`);
|
||||
}
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const memory = performance.memory;
|
||||
|
||||
return {
|
||||
loadTime: navigation ? navigation.loadEventEnd - navigation.loadEventStart : 0,
|
||||
domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0,
|
||||
memory: memory ? {
|
||||
usedJSHeapSize: memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.jsHeapSizeLimit
|
||||
} : null,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
metrics
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('Playwright MCP Server running on stdio');
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server
|
||||
const server = new PlaywrightMCPServer();
|
||||
server.start().catch(console.error);
|
|
@ -1,140 +0,0 @@
|
|||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Test client for Playwright MCP Server
|
||||
*/
|
||||
async function testMCPServer() {
|
||||
console.log('🚀 Starting Playwright MCP Server test...');
|
||||
|
||||
try {
|
||||
// Spawn the server process
|
||||
const serverProcess = spawn('node', ['src/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'inherit']
|
||||
});
|
||||
|
||||
// Create client transport
|
||||
const transport = new StdioClientTransport({
|
||||
stdin: serverProcess.stdin,
|
||||
stdout: serverProcess.stdout
|
||||
});
|
||||
|
||||
// Create client
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'playwright-test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
);
|
||||
|
||||
// Connect to server
|
||||
console.log('📡 Connecting to MCP server...');
|
||||
await client.connect(transport);
|
||||
console.log('✅ Connected to MCP server');
|
||||
|
||||
// List available tools
|
||||
console.log('🔧 Getting available tools...');
|
||||
const tools = await client.request('tools/list', {});
|
||||
console.log(`Found ${tools.tools.length} tools:`);
|
||||
tools.tools.forEach(tool => {
|
||||
console.log(` - ${tool.name}: ${tool.description}`);
|
||||
});
|
||||
|
||||
// Test launching browser
|
||||
console.log('🌐 Testing browser launch...');
|
||||
const launchResult = await client.request('tools/call', {
|
||||
name: 'launch_browser',
|
||||
arguments: {
|
||||
browserType: 'chromium',
|
||||
headless: false
|
||||
}
|
||||
});
|
||||
|
||||
const launchData = JSON.parse(launchResult.content[0].text);
|
||||
console.log('Browser launch result:', launchData);
|
||||
|
||||
if (launchData.success) {
|
||||
const sessionId = launchData.sessionId;
|
||||
console.log(`✅ Browser launched with session ID: ${sessionId}`);
|
||||
|
||||
// Test navigation
|
||||
console.log('🧭 Testing navigation...');
|
||||
const navResult = await client.request('tools/call', {
|
||||
name: 'navigate_to',
|
||||
arguments: {
|
||||
sessionId,
|
||||
url: 'http://localhost:5173/app'
|
||||
}
|
||||
});
|
||||
|
||||
const navData = JSON.parse(navResult.content[0].text);
|
||||
console.log('Navigation result:', navData);
|
||||
|
||||
// Test taking screenshot
|
||||
console.log('📸 Testing screenshot...');
|
||||
const screenshotResult = await client.request('tools/call', {
|
||||
name: 'take_screenshot',
|
||||
arguments: {
|
||||
sessionId,
|
||||
path: 'test-screenshot.png'
|
||||
}
|
||||
});
|
||||
|
||||
const screenshotData = JSON.parse(screenshotResult.content[0].text);
|
||||
console.log('Screenshot result:', screenshotData);
|
||||
|
||||
// Test getting page info
|
||||
console.log('ℹ️ Testing page info...');
|
||||
const infoResult = await client.request('tools/call', {
|
||||
name: 'get_page_info',
|
||||
arguments: {
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
|
||||
const infoData = JSON.parse(infoResult.content[0].text);
|
||||
console.log('Page info result:', infoData);
|
||||
|
||||
// Test PLC status (if app is running)
|
||||
console.log('🔌 Testing PLC status...');
|
||||
const plcResult = await client.request('tools/call', {
|
||||
name: 'get_plc_status',
|
||||
arguments: {
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
|
||||
const plcData = JSON.parse(plcResult.content[0].text);
|
||||
console.log('PLC status result:', plcData);
|
||||
|
||||
// Close browser
|
||||
console.log('🔒 Closing browser...');
|
||||
await client.request('tools/call', {
|
||||
name: 'close_browser',
|
||||
arguments: {
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ All tests completed successfully!');
|
||||
|
||||
// Close client connection
|
||||
await client.close();
|
||||
serverProcess.kill();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run test if this file is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
testMCPServer();
|
||||
}
|
|
@ -1,190 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Global Playwright MCP-like Automation Script
|
||||
* This script can be used across multiple projects
|
||||
*/
|
||||
|
||||
import { chromium, firefox, webkit } from 'playwright';
|
||||
import { program } from 'commander';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
program
|
||||
.name('playwright-mcp')
|
||||
.description('Playwright MCP-like automation tool for web applications')
|
||||
.version('1.0.0');
|
||||
|
||||
program
|
||||
.command('test')
|
||||
.description('Run automated tests')
|
||||
.option('-b, --browser <type>', 'Browser type (chromium, firefox, webkit)', 'chromium')
|
||||
.option('-h, --headless', 'Run in headless mode', false)
|
||||
.option('-u, --url <url>', 'Base URL to test', 'http://localhost:3000')
|
||||
.action(async (options) => {
|
||||
const { browser, headless, url } = options;
|
||||
console.log(`Running tests with ${browser} browser on ${url}`);
|
||||
|
||||
// Your test logic here
|
||||
await runTests({ browser, headless, url });
|
||||
});
|
||||
|
||||
program
|
||||
.command('capture')
|
||||
.description('Capture screenshots of application')
|
||||
.option('-b, --browser <type>', 'Browser type', 'chromium')
|
||||
.option('-u, --url <url>', 'URL to capture', 'http://localhost:3000')
|
||||
.option('-o, --output <dir>', 'Output directory', './screenshots')
|
||||
.action(async (options) => {
|
||||
await captureScreenshots(options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('monitor')
|
||||
.description('Monitor application performance')
|
||||
.option('-b, --browser <type>', 'Browser type', 'chromium')
|
||||
.option('-u, --url <url>', 'URL to monitor', 'http://localhost:3000')
|
||||
.option('-d, --duration <seconds>', 'Monitoring duration', '60')
|
||||
.action(async (options) => {
|
||||
await monitorPerformance(options);
|
||||
});
|
||||
|
||||
async function runTests({ browser, headless, url }) {
|
||||
const browserInstance = await launchBrowser(browser, { headless });
|
||||
const context = await browserInstance.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Basic connectivity test
|
||||
const title = await page.title();
|
||||
console.log(`Page title: ${title}`);
|
||||
|
||||
// Check for common elements
|
||||
const links = await page.locator('a').count();
|
||||
const buttons = await page.locator('button').count();
|
||||
|
||||
console.log(`Found ${links} links and ${buttons} buttons`);
|
||||
|
||||
// Performance metrics
|
||||
const metrics = await page.evaluate(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
return {
|
||||
loadTime: navigation.loadEventEnd - navigation.loadEventStart,
|
||||
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
|
||||
firstPaint: performance.getEntriesByName('first-paint')[0]?.startTime || 0
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Performance metrics:', metrics);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browserInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function captureScreenshots({ browser, url, output }) {
|
||||
const browserInstance = await launchBrowser(browser, { headless: true });
|
||||
const context = await browserInstance.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Create output directory
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
|
||||
// Full page screenshot
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'full-page.png'),
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// Viewport screenshot
|
||||
await page.screenshot({
|
||||
path: path.join(output, 'viewport.png')
|
||||
});
|
||||
|
||||
console.log(`Screenshots saved to ${output}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Screenshot capture failed:', error);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browserInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function monitorPerformance({ browser, url, duration }) {
|
||||
const browserInstance = await launchBrowser(browser, { headless: true });
|
||||
const context = await browserInstance.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const metrics = [];
|
||||
const startTime = Date.now();
|
||||
const durationMs = parseInt(duration) * 1000;
|
||||
|
||||
try {
|
||||
await page.goto(url);
|
||||
|
||||
// Monitor performance every 5 seconds
|
||||
const interval = setInterval(async () => {
|
||||
const currentMetrics = await page.evaluate(() => {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
memory: performance.memory ? {
|
||||
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
||||
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
|
||||
} : null,
|
||||
timing: performance.timing
|
||||
};
|
||||
});
|
||||
|
||||
metrics.push(currentMetrics);
|
||||
console.log(`Memory usage: ${(currentMetrics.memory?.usedJSHeapSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
}, 5000);
|
||||
|
||||
// Stop monitoring after duration
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
}, durationMs);
|
||||
|
||||
// Wait for monitoring to complete
|
||||
await new Promise(resolve => setTimeout(resolve, durationMs));
|
||||
|
||||
// Save metrics
|
||||
await fs.writeFile('performance-monitor.json', JSON.stringify(metrics, null, 2));
|
||||
console.log('Performance monitoring completed. Data saved to performance-monitor.json');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Performance monitoring failed:', error);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browserInstance.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function launchBrowser(type, options = {}) {
|
||||
const browsers = {
|
||||
chromium: chromium,
|
||||
firefox: firefox,
|
||||
webkit: webkit
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
headless: false,
|
||||
slowMo: 100
|
||||
};
|
||||
|
||||
return await browsers[type].launch({ ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
program.parse();
|
|
@ -1,83 +0,0 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
/* Take screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
/* Record video on failure */
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
|
||||
/* Test against branded browsers. */
|
||||
{
|
||||
name: 'Microsoft Edge',
|
||||
use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
},
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev',
|
||||
cwd: '../frontend',
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: 'python main.py',
|
||||
cwd: '..',
|
||||
port: 5000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
}
|
||||
],
|
||||
});
|
|
@ -1,27 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Script de inicio rápido para testing con Playwright
|
||||
|
||||
echo "🚀 Iniciando PLC Streamer Testing Suite"
|
||||
|
||||
# Verificar que los servidores estén corriendo
|
||||
echo "🔍 Verificando servidores..."
|
||||
|
||||
# Check frontend
|
||||
if curl -f http://localhost:5173 >/dev/null 2>&1; then
|
||||
echo "✅ Frontend (5173) running"
|
||||
else
|
||||
echo "❌ Frontend no está corriendo en puerto 5173"
|
||||
echo "💡 Ejecuta: cd ../frontend && npm run dev"
|
||||
fi
|
||||
|
||||
# Check backend
|
||||
if curl -f http://localhost:5000/api/status >/dev/null 2>&1; then
|
||||
echo "✅ Backend (5000) running"
|
||||
else
|
||||
echo "❌ Backend no está corriendo en puerto 5000"
|
||||
echo "💡 Ejecuta: cd .. && python main.py"
|
||||
fi
|
||||
|
||||
echo "🎬 Iniciando tests..."
|
||||
npm run test:headed
|
175
testing/setup.js
175
testing/setup.js
|
@ -1,175 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script de configuración inicial para Playwright MCP
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
console.log('🚀 Configurando Playwright MCP para PLC Streamer...');
|
||||
|
||||
async function setup() {
|
||||
try {
|
||||
// 1. Verificar instalación de Playwright
|
||||
console.log('📦 Verificando instalación de Playwright...');
|
||||
try {
|
||||
execSync('npx playwright --version', { stdio: 'pipe' });
|
||||
console.log('✅ Playwright instalado');
|
||||
} catch (error) {
|
||||
console.log('⚠️ Instalando Playwright...');
|
||||
execSync('npm install', { stdio: 'inherit' });
|
||||
execSync('npx playwright install', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// 2. Crear directorios necesarios
|
||||
console.log('📁 Creando directorios...');
|
||||
const dirs = ['screenshots', 'videos', 'test-results', 'playwright-report'];
|
||||
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
console.log(`✅ Directorio creado: ${dir}`);
|
||||
} catch (error) {
|
||||
console.log(`ℹ️ Directorio ya existe: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verificar que los servidores estén configurados
|
||||
console.log('🔧 Verificando configuración de servidores...');
|
||||
|
||||
// Check frontend
|
||||
const frontendPath = '../frontend/package.json';
|
||||
try {
|
||||
await fs.access(frontendPath);
|
||||
console.log('✅ Frontend encontrado');
|
||||
} catch (error) {
|
||||
console.log('⚠️ Frontend no encontrado en ruta esperada');
|
||||
}
|
||||
|
||||
// Check backend
|
||||
const backendPath = '../main.py';
|
||||
try {
|
||||
await fs.access(backendPath);
|
||||
console.log('✅ Backend encontrado');
|
||||
} catch (error) {
|
||||
console.log('⚠️ Backend no encontrado en ruta esperada');
|
||||
}
|
||||
|
||||
// 4. Crear archivo de configuración de entorno
|
||||
console.log('⚙️ Creando configuración de entorno...');
|
||||
const envConfig = {
|
||||
FRONTEND_URL: 'http://localhost:5173',
|
||||
BACKEND_URL: 'http://localhost:5000',
|
||||
PLAYWRIGHT_HEADLESS: 'false',
|
||||
PLAYWRIGHT_SLOWMO: '100',
|
||||
SCREENSHOT_PATH: './screenshots',
|
||||
VIDEO_PATH: './videos'
|
||||
};
|
||||
|
||||
await fs.writeFile('.env',
|
||||
Object.entries(envConfig)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
);
|
||||
console.log('✅ Archivo .env creado');
|
||||
|
||||
// 5. Crear script de inicio rápido
|
||||
console.log('🎯 Creando script de inicio rápido...');
|
||||
const quickStart = `#!/bin/bash
|
||||
|
||||
# Script de inicio rápido para testing con Playwright
|
||||
|
||||
echo "🚀 Iniciando PLC Streamer Testing Suite"
|
||||
|
||||
# Verificar que los servidores estén corriendo
|
||||
echo "🔍 Verificando servidores..."
|
||||
|
||||
# Check frontend
|
||||
if curl -f http://localhost:5173 >/dev/null 2>&1; then
|
||||
echo "✅ Frontend (5173) running"
|
||||
else
|
||||
echo "❌ Frontend no está corriendo en puerto 5173"
|
||||
echo "💡 Ejecuta: cd ../frontend && npm run dev"
|
||||
fi
|
||||
|
||||
# Check backend
|
||||
if curl -f http://localhost:5000/api/status >/dev/null 2>&1; then
|
||||
echo "✅ Backend (5000) running"
|
||||
else
|
||||
echo "❌ Backend no está corriendo en puerto 5000"
|
||||
echo "💡 Ejecuta: cd .. && python main.py"
|
||||
fi
|
||||
|
||||
echo "🎬 Iniciando tests..."
|
||||
npm run test:headed
|
||||
`;
|
||||
|
||||
await fs.writeFile('quick-start.sh', quickStart);
|
||||
console.log('✅ Script quick-start.sh creado');
|
||||
|
||||
// 6. Crear archivo gitignore para testing
|
||||
console.log('📝 Configurando .gitignore...');
|
||||
const gitignore = `# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# Screenshots y videos
|
||||
screenshots/*.png
|
||||
videos/*.webm
|
||||
|
||||
# Reportes y logs
|
||||
*.json
|
||||
*.log
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env.local
|
||||
`;
|
||||
|
||||
await fs.writeFile('.gitignore', gitignore);
|
||||
console.log('✅ .gitignore configurado');
|
||||
|
||||
// 7. Mensaje final
|
||||
console.log(`
|
||||
🎉 ¡Configuración completada!
|
||||
|
||||
📋 Próximos pasos:
|
||||
|
||||
1. Asegúrate de que los servidores estén corriendo:
|
||||
Frontend: cd ../frontend && npm run dev
|
||||
Backend: cd .. && python main.py
|
||||
|
||||
2. Ejecutar tests básicos:
|
||||
npm test
|
||||
|
||||
3. Ejecutar ejemplo interactivo:
|
||||
npm run example
|
||||
|
||||
4. Generar código de test:
|
||||
npm run codegen:app
|
||||
|
||||
5. Monitoreo continuo:
|
||||
npm run monitor
|
||||
|
||||
📚 Ver README.md para más información y ejemplos.
|
||||
|
||||
🔧 Archivos creados:
|
||||
- .env (configuración)
|
||||
- .gitignore (exclusiones)
|
||||
- quick-start.sh (inicio rápido)
|
||||
|
||||
Happy testing! 🧪
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error durante la configuración:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
|
@ -1,11 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('basic connectivity test', async ({ page }) => {
|
||||
// Test básico de conectividad
|
||||
await page.goto('http://localhost:5173');
|
||||
|
||||
// Verificar que la página cargue con el título correcto
|
||||
await expect(page).toHaveTitle(/PLC S7-31x Streamer/);
|
||||
|
||||
console.log('✅ Conectividad básica funcionando');
|
||||
});
|
|
@ -1,54 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('PLC Configuration Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app');
|
||||
// Navigate to Configuration tab
|
||||
await page.click('text=Configuration');
|
||||
});
|
||||
|
||||
test('should display PLC configuration form', async ({ page }) => {
|
||||
// Check if PLC configuration section is visible
|
||||
await expect(page.locator('text=PLC Configuration')).toBeVisible();
|
||||
|
||||
// Check for common configuration fields
|
||||
await expect(page.locator('input[name*="ip"]')).toBeVisible();
|
||||
await expect(page.locator('input[name*="rack"]')).toBeVisible();
|
||||
await expect(page.locator('input[name*="slot"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to modify PLC settings', async ({ page }) => {
|
||||
// Find IP address input field
|
||||
const ipInput = page.locator('input[name*="ip"]').first();
|
||||
|
||||
// Clear and set new IP
|
||||
await ipInput.clear();
|
||||
await ipInput.fill('192.168.1.100');
|
||||
|
||||
// Verify the value was set
|
||||
await expect(ipInput).toHaveValue('192.168.1.100');
|
||||
});
|
||||
|
||||
test('should validate configuration data', async ({ page }) => {
|
||||
// Try to enter invalid IP address
|
||||
const ipInput = page.locator('input[name*="ip"]').first();
|
||||
await ipInput.clear();
|
||||
await ipInput.fill('invalid-ip');
|
||||
|
||||
// Look for validation error
|
||||
await expect(page.locator('text*=invalid')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should save configuration changes', async ({ page }) => {
|
||||
// Modify a setting
|
||||
const ipInput = page.locator('input[name*="ip"]').first();
|
||||
await ipInput.clear();
|
||||
await ipInput.fill('192.168.1.101');
|
||||
|
||||
// Click save button
|
||||
await page.click('button:has-text("Save")');
|
||||
|
||||
// Look for success message
|
||||
await expect(page.locator('text*=saved')).toBeVisible();
|
||||
});
|
||||
});
|
|
@ -1,48 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('PLC Streamer Application', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the application
|
||||
await page.goto('/app');
|
||||
});
|
||||
|
||||
test('should load the main dashboard', async ({ page }) => {
|
||||
// Wait for the main content to load
|
||||
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
|
||||
|
||||
// Check if the title is correct
|
||||
await expect(page).toHaveTitle(/PLC Streamer/);
|
||||
});
|
||||
|
||||
test('should display navigation tabs', async ({ page }) => {
|
||||
// Check if main navigation tabs are present
|
||||
await expect(page.locator('text=Dashboard')).toBeVisible();
|
||||
await expect(page.locator('text=Configuration')).toBeVisible();
|
||||
await expect(page.locator('text=Data Streaming')).toBeVisible();
|
||||
await expect(page.locator('text=Plots')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to navigate between tabs', async ({ page }) => {
|
||||
// Click on Configuration tab
|
||||
await page.click('text=Configuration');
|
||||
await expect(page.locator('[data-testid="configuration-panel"]')).toBeVisible();
|
||||
|
||||
// Click on Data Streaming tab
|
||||
await page.click('text=Data Streaming');
|
||||
await expect(page.locator('[data-testid="streaming-panel"]')).toBeVisible();
|
||||
|
||||
// Click on Plots tab
|
||||
await page.click('text=Plots');
|
||||
await expect(page.locator('[data-testid="plots-panel"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show PLC connection status', async ({ page }) => {
|
||||
// Look for connection status indicator
|
||||
const connectionStatus = page.locator('[data-testid="connection-status"]');
|
||||
await expect(connectionStatus).toBeVisible();
|
||||
|
||||
// Status should be either "Connected" or "Disconnected"
|
||||
const statusText = await connectionStatus.textContent();
|
||||
expect(['Connected', 'Disconnected', 'Connecting']).toContain(statusText?.trim());
|
||||
});
|
||||
});
|
|
@ -1,77 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Real-time Plotting', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app');
|
||||
// Navigate to Plots tab
|
||||
await page.click('text=Plots');
|
||||
});
|
||||
|
||||
test('should display plot management interface', async ({ page }) => {
|
||||
// Check for plot controls
|
||||
await expect(page.locator('text=Plot Management')).toBeVisible();
|
||||
await expect(page.locator('button:has-text("Create Plot")')).toBeVisible();
|
||||
|
||||
// Check for existing plots list
|
||||
await expect(page.locator('[data-testid="plots-list"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create a new plot', async ({ page }) => {
|
||||
// Click create plot button
|
||||
await page.click('button:has-text("Create Plot")');
|
||||
|
||||
// Fill in plot details
|
||||
await page.fill('input[name="plotName"]', 'Test Plot');
|
||||
await page.fill('input[name="description"]', 'Test plot for automation');
|
||||
|
||||
// Select variables for the plot
|
||||
await page.check('input[name="variables"][value="Temperature"]');
|
||||
await page.check('input[name="variables"][value="Pressure"]');
|
||||
|
||||
// Save the plot
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Verify plot was created
|
||||
await expect(page.locator('text=Test Plot')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display chart canvas', async ({ page }) => {
|
||||
// Check if chart canvas is present
|
||||
await expect(page.locator('canvas')).toBeVisible();
|
||||
|
||||
// Verify chart is rendered (check for Chart.js specific elements)
|
||||
const canvas = page.locator('canvas').first();
|
||||
await expect(canvas).toHaveAttribute('style', /.*width.*height.*/);
|
||||
});
|
||||
|
||||
test('should update plot in real-time', async ({ page }) => {
|
||||
// Start a plot session
|
||||
await page.click('button:has-text("Start Plot")');
|
||||
|
||||
// Wait for some time to allow data to update
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Check if chart has data points (this is basic, real implementation would check canvas content)
|
||||
const canvas = page.locator('canvas').first();
|
||||
|
||||
// Verify canvas is still present and potentially has been updated
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// Stop the plot
|
||||
await page.click('button:has-text("Stop Plot")');
|
||||
});
|
||||
|
||||
test('should export plot data', async ({ page }) => {
|
||||
// Set up download handler
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
// Click export button
|
||||
await page.click('button:has-text("Export")');
|
||||
|
||||
// Wait for download to start
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Verify download file name
|
||||
expect(download.suggestedFilename()).toContain('.csv');
|
||||
});
|
||||
});
|
|
@ -1,58 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Data Streaming Features', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/app');
|
||||
// Navigate to Data Streaming tab
|
||||
await page.click('text=Data Streaming');
|
||||
});
|
||||
|
||||
test('should display streaming controls', async ({ page }) => {
|
||||
// Check for streaming control buttons
|
||||
await expect(page.locator('button:has-text("Start Streaming")')).toBeVisible();
|
||||
await expect(page.locator('button:has-text("Stop Streaming")')).toBeVisible();
|
||||
|
||||
// Check for dataset configuration
|
||||
await expect(page.locator('text=Dataset Configuration')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to start and stop streaming', async ({ page }) => {
|
||||
// Start streaming
|
||||
await page.click('button:has-text("Start Streaming")');
|
||||
|
||||
// Wait for streaming to start (check for status change)
|
||||
await expect(page.locator('text*=streaming')).toBeVisible();
|
||||
|
||||
// Stop streaming
|
||||
await page.click('button:has-text("Stop Streaming")');
|
||||
|
||||
// Verify streaming stopped
|
||||
await expect(page.locator('text*=stopped')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display dataset variables', async ({ page }) => {
|
||||
// Check if dataset variables table is present
|
||||
await expect(page.locator('[data-testid="dataset-variables"]')).toBeVisible();
|
||||
|
||||
// Check for variable columns
|
||||
await expect(page.locator('text=Variable Name')).toBeVisible();
|
||||
await expect(page.locator('text=Address')).toBeVisible();
|
||||
await expect(page.locator('text=Type')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow adding new variables', async ({ page }) => {
|
||||
// Click add variable button
|
||||
await page.click('button:has-text("Add Variable")');
|
||||
|
||||
// Fill in variable details
|
||||
await page.fill('input[name="name"]', 'TestVariable');
|
||||
await page.fill('input[name="address"]', 'DB1.DBD0');
|
||||
await page.selectOption('select[name="type"]', 'REAL');
|
||||
|
||||
// Save the variable
|
||||
await page.click('button:has-text("Save")');
|
||||
|
||||
// Verify variable was added
|
||||
await expect(page.locator('text=TestVariable')).toBeVisible();
|
||||
});
|
||||
});
|
|
@ -31,7 +31,7 @@ class InstanceManager:
|
|||
lock_file: str = "plc_streamer.lock",
|
||||
health_endpoint: str = "/api/health",
|
||||
check_timeout: float = 3.0,
|
||||
check_interval: float = 1.0,
|
||||
check_interval: float = 5.0,
|
||||
):
|
||||
"""
|
||||
Initialize the instance manager
|
||||
|
|
Loading…
Reference in New Issue