Compare commits

..

13 Commits

Author SHA1 Message Date
Miguel 81e5ddec57 Refactor PlotHistoricalSession and TimePointSelector components for improved UI and functionality
- Removed the range display box from PlotHistoricalSession for a cleaner layout.
- Updated TimePointSelector to support fullscreen mode with a more compact design.
- Integrated a Popover for date and time selection in fullscreen mode.
- Enhanced the DataAvailabilityBar and slider functionality for better user experience.
- Added responsive styling adjustments for both normal and fullscreen modes.
- Updated system state with the latest last_update timestamp.
2025-08-27 15:34:01 +02:00
Miguel 61d61d27d1 feat: Enhance Checkbox and Switch widgets for improved layout and error handling 2025-08-27 14:49:43 +02:00
Miguel 42cd1743e8 Enhance alignment and styling for RJSF components
- Updated LayoutObjectFieldTemplate to ensure consistent alignment of grid items with a minimum height and flex properties.
- Improved TooltipFieldTemplate to center-align switch and checkbox widgets, enhancing overall layout consistency.
- Refined CheckboxWidget and SwitchWidget styles for better alignment and text handling, ensuring labels do not overflow and maintain a single line.
2025-08-27 14:45:50 +02:00
Miguel 76c6743537 Add TooltipFieldTemplate for enhanced user experience in forms
- Integrated TooltipFieldTemplate into PlotHistoricalManager, PlotManager, and Dashboard components to provide contextual tooltips for form fields.
- Updated templates in various forms to include the new TooltipFieldTemplate alongside the existing LayoutObjectFieldTemplate.
- Enhanced TooltipFieldTemplate with theme-aware colors and improved styling for better readability.
- Adjusted system state to enable connection and updated the last update timestamp.
2025-08-27 13:04:57 +02:00
Miguel dec3a49836 feat: Enhance application event logging; add tooltips for UI fields and improve PLC configuration descriptions 2025-08-27 12:45:19 +02:00
Miguel cda40ce0ab feat: Update UI schema help texts to descriptions for consistency; add new application event logs and update system state configuration 2025-08-27 12:02:57 +02:00
Miguel 29b6e92284 feat: Enhance get_records_directory function to support optional config_manager for dynamic path retrieval 2025-08-27 10:30:28 +02:00
Miguel 01489aec59 feat: Add Playwright MCP testing suite with various automation scripts
- Implemented test client for Playwright MCP Server in `test-server.js`
- Created global automation script for Playwright MCP-like functionality in `playwright-mcp-global.js`
- Configured Playwright settings in `playwright.config.js`
- Added quick start script for testing setup in `quick-start.sh`
- Developed initial setup script for Playwright MCP configuration in `setup.js`
- Created basic connectivity and configuration tests in `tests/basic.spec.js` and `tests/configuration.spec.js`
- Implemented dashboard and plotting tests in `tests/dashboard.spec.js` and `tests/plotting.spec.js`
- Added data streaming feature tests in `tests/streaming.spec.js`
2025-08-27 10:16:08 +02:00
Miguel 1b6528977a feat: Update PLC configuration to support absolute and relative paths for records and symbols
- Changed records_directory in plc_config.json to an absolute path.
- Enhanced plc.schema.json to allow absolute and relative paths for records_directory and symbols_path with path-browser widget.
- Updated ui schema to reflect changes in records_directory and symbols_path with appropriate help texts.
- Modified ConfigManager to handle symbols_path in PLC configuration updates.
- Added functionality in PLCDataStreamer and schema_manager to manage symbols_path.
- Implemented Load Symbols button in PLCConfigManager and Dashboard components to load symbols from ASC files.
- Created new PathBrowserWidget for browsing files and directories, supporting both absolute and relative paths.
- Added SimpleFilePathWidget for simplified file path selection.
- Introduced DirectoryBrowserWidget for directory selection.
- Updated main.py to handle loading symbols from ASC files and browsing directories.
2025-08-27 09:24:41 +02:00
Miguel c251c76072 Reduce health check interval in InstanceManager to improve responsiveness; adjust font size and line height in ConsoleLogsDisplay for better readability. 2025-08-26 16:54:58 +02:00
Miguel c5cd0494ad Update routing in App component and adjust CORS settings in main.py; add project overview documentation 2025-08-26 11:22:42 +02:00
Miguel 228b0deb24 Improve error handling in PowerShell build script and update icon path in main.py to use resource from public folder. 2025-08-26 10:40:24 +02:00
Miguel ed7ff2d571 Enhance PowerShell build script with improved file handle management and retry logic for compression; add functionality to open destination folder in Explorer after build completion. 2025-08-26 10:09:36 +02:00
54 changed files with 25712 additions and 21285 deletions

59
GEMINI.md Normal file
View File

@ -0,0 +1,59 @@
# 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.

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
# 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
@ -10,7 +12,7 @@ try {
npm run build
Write-Host "Frontend build completed" -ForegroundColor Green
} catch {
Write-Host "Frontend build failed: $_" -ForegroundColor Red
Write-Host "Frontend build failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
@ -38,8 +40,15 @@ 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: $_" -ForegroundColor Red
Write-Host "PyInstaller build failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
@ -77,7 +86,67 @@ try {
if (-not $sevenZipFound) {
Write-Host "Using PowerShell built-in compression..." -ForegroundColor Cyan
$tempZip = ".\$zipName"
Compress-Archive -Path ".\dist\main\*" -DestinationPath $tempZip -Force
# 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 $_
}
}
}
# Move to destination
$zipFullPath = Join-Path $destinationPath $zipName
@ -91,8 +160,22 @@ 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: $_" -ForegroundColor Red
Write-Host "Compression failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@ -2,7 +2,7 @@
"csv_config": {
"max_days": 30,
"max_size_mb": 1000,
"records_directory": "records",
"records_directory": "C:/Trabajo/SIDEL/09 - SAE452 - Diet as Regular - San Giorgio in Bosco/Reporte/LogRecords",
"rotation_enabled": true
},
"plc_config": {

View File

@ -28,9 +28,14 @@
},
"records_directory": {
"default": "records",
"description": "Directory to save *.csv files",
"description": "Directory to save *.csv files. Use absolute path (e.g. C:\\data) or relative path (e.g. records)",
"title": "Records Directory",
"type": "string"
"type": "string",
"options": {
"widget": "path-browser",
"mode": "directory",
"title": "Select Records Directory"
}
},
"rotation_enabled": {
"default": true,
@ -72,10 +77,13 @@
},
"symbols_path": {
"title": "Symbols File Path",
"description": "Path to the ASC symbol file for this PLC",
"description": "Path to the ASC symbol file for this PLC. Use absolute path or relative path",
"type": "string",
"options": {
"widget": "file-path"
"widget": "path-browser",
"mode": "file",
"title": "Select ASC Symbol File",
"filetypes": [["ASC Files", "*.asc"], ["All Files", "*.*"]]
}
}
},

View File

@ -19,33 +19,33 @@
]
],
"id": {
"ui:help": "Unique ID for this dataset (alphanumeric, underscore, dash)",
"ui:description": "Unique ID for this dataset (alphanumeric, underscore, dash)",
"ui:placeholder": "e.g., DAR, Fast"
},
"name": {
"ui:help": "Human-readable name for this dataset",
"ui:description": "Human-readable name for this dataset",
"ui:placeholder": "e.g., Temperature Sensors, Production Line A"
},
"prefix": {
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
"ui:description": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
"ui:placeholder": "e.g., temp, line_a, sensors"
},
"enabled": {
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
"ui:description": "When enabled, this dataset will be actively sampled and recorded",
"ui:widget": "switch"
},
"sampling_interval": {
"ui:help": "Custom sampling interval in seconds (0.0110). Leave empty to use the global PLC sampling interval.",
"ui:description": "Custom sampling interval in seconds (0.0110). 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:help": "📊 Enable optimized batch reading for better performance. Disable if experiencing compatibility issues with older PLC firmware.",
"ui:description": "📊 Enable optimized batch reading for better performance. Disable if experiencing compatibility issues with older PLC firmware.",
"ui:widget": "switch"
},
"created": {
"ui:help": "Timestamp when this dataset was created",
"ui:description": "Timestamp when this dataset was created",
"ui:readonly": true,
"ui:widget": "text"
}

View File

@ -14,11 +14,10 @@
"dataset_id": {
"ui:widget": "text",
"ui:placeholder": "Enter unique dataset identifier",
"ui:help": "🆔 Unique identifier for this dataset (must match existing dataset)"
"ui:description": "🆔 Unique identifier for this dataset (must match existing dataset)"
},
"variables": {
"ui:description": "🔧 PLC Variable Definitions",
"ui:help": "Define PLC memory locations, data types, and properties for each variable",
"ui:description": "🔧 Define PLC memory locations, data types, and properties for each variable",
"ui:options": {
"addable": true,
"orderable": true,
@ -82,7 +81,7 @@
],
"configType": {
"ui:widget": "select",
"ui:help": "Choose between manual configuration or symbol-based setup",
"ui:description": "Choose between manual configuration or symbol-based setup",
"ui:options": {
"enumOptions": [
{
@ -99,16 +98,16 @@
"name": {
"ui:widget": "text",
"ui:placeholder": "Variable name",
"ui:help": "📝 Human-readable name for this variable"
"ui:description": "📝 Human-readable name for this variable"
},
"symbol": {
"ui:widget": "dataset-variable-symbol",
"ui:placeholder": "Select a PLC symbol...",
"ui:help": "🔍 Search and select a symbol from the loaded ASC file"
"ui:description": "🔍 Search and select a symbol from the loaded ASC file"
},
"area": {
"ui:widget": "select",
"ui:help": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
"ui:description": "PLC memory area (DB=DataBlock, MW=MemoryWord, etc.)",
"ui:options": {
"enumOptions": [
{
@ -156,22 +155,20 @@
},
"db": {
"ui:widget": "updown",
"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)'"
"ui:description": "⚠️ Data Block number (only required for DB area - will be ignored for other areas like PE, PA, MW, etc.)",
"ui:placeholder": "1011"
},
"offset": {
"ui:widget": "updown",
"ui:help": "Byte offset within the memory area"
"ui:description": "Byte offset within the memory area"
},
"bit": {
"ui:widget": "updown",
"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)'"
"ui:description": "⚠️ Bit position (0-7) - only required for BOOL data type, will be ignored for other types"
},
"type": {
"ui:widget": "select",
"ui:help": "PLC data type",
"ui:description": "PLC data type",
"ui:options": {
"enumOptions": [
{
@ -223,7 +220,7 @@
},
"streaming": {
"ui:widget": "switch",
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
"ui:description": "📡 Enable real-time streaming to PlotJuggler for visualization"
}
}
}

View File

@ -14,7 +14,13 @@
},
"records_directory": {
"ui:column": 3,
"ui:placeholder": "records"
"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."
},
"rotation_enabled": {
"ui:column": 3,
@ -57,20 +63,29 @@
"plc_config": {
"ip": {
"ui:column": 6,
"ui:placeholder": "192.168.1.100"
"ui:placeholder": "192.168.1.100",
"ui:description": "🌐 IP address of the Siemens S7-300/400 PLC (e.g., 192.168.1.100)"
},
"rack": {
"ui:column": 3,
"ui:widget": "updown"
"ui:widget": "updown",
"ui:description": "🏗️ PLC rack number (usually 0 for S7-300/400)"
},
"slot": {
"ui:column": 3,
"ui:widget": "updown"
"ui:widget": "updown",
"ui:description": "🔌 PLC slot number (typically 2 for CPU)"
},
"symbols_path": {
"ui:column": 12,
"ui:widget": "file-path",
"ui:placeholder": "Select ASC symbol file..."
"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:column": 12,
"ui:layout": [
@ -113,7 +128,7 @@
},
"sampling_interval": {
"ui:column": 4,
"ui:help": "⏱️ Time interval between UDP data transmissions for real-time streaming",
"ui:description": "⏱️ Time interval between UDP data transmissions for real-time streaming",
"ui:widget": "updown"
},
"ui:column": 12,

View File

@ -89,61 +89,61 @@
"id": {
"ui:widget": "text",
"ui:placeholder": "plot_1",
"ui:help": "🆔 Unique identifier for this plot"
"ui:description": "🆔 Unique identifier for this plot"
},
"name": {
"ui:widget": "text",
"ui:placeholder": "My Plot",
"ui:help": "📊 Human-readable name for the plot"
"ui:description": "📊 Human-readable name for the plot"
},
"session_id": {
"ui:widget": "text",
"ui:placeholder": "plot_1",
"ui:help": "🔗 Session identifier (usually same as ID)"
"ui:description": "🔗 Session identifier (usually same as ID)"
},
"time_window": {
"ui:widget": "updown",
"ui:help": "⏱️ Time window in seconds (5-3600)"
"ui:description": "⏱️ Time window in seconds (5-3600)"
},
"y_min": {
"ui:widget": "updown",
"ui:help": "📉 Minimum Y axis value (leave empty for auto)"
"ui:description": "📉 Minimum Y axis value (leave empty for auto)"
},
"y_max": {
"ui:widget": "updown",
"ui:help": "📈 Maximum Y axis value (leave empty for auto)"
"ui:description": "📈 Maximum Y axis value (leave empty for auto)"
},
"trigger_variable": {
"ui:widget": "text",
"ui:help": "🎯 Variable name to use as trigger (optional)"
"ui:description": "🎯 Variable name to use as trigger (optional)"
},
"trigger_enabled": {
"ui:widget": "switch",
"ui:help": "✅ Enable trigger-based recording"
"ui:description": "✅ Enable trigger-based recording"
},
"trigger_on_true": {
"ui:widget": "switch",
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
"ui:description": "🔄 Trigger when variable becomes true (vs false)"
},
"line_tension": {
"ui:widget": "updown",
"ui:help": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
"ui:description": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
},
"stepped": {
"ui:widget": "switch",
"ui:help": "📊 Enable stepped line style instead of curves"
"ui:description": "📊 Enable stepped line style instead of curves"
},
"stacked": {
"ui:widget": "switch",
"ui:help": "📚 Enable stacked Y-axes for multi-axis visualization"
"ui:description": "📚 Enable stacked Y-axes for multi-axis visualization"
},
"point_radius": {
"ui:widget": "updown",
"ui:help": "🔴 Size of data points (0-10)"
"ui:description": "🔴 Size of data points (0-10)"
},
"point_hover_radius": {
"ui:widget": "updown",
"ui:help": "🎯 Size of points when hovering (0-15)"
"ui:description": "🎯 Size of points when hovering (0-15)"
}
}
},

View File

@ -14,11 +14,10 @@
"plot_id": {
"ui:widget": "text",
"ui:placeholder": "Enter unique plot identifier",
"ui:help": "🆔 Unique identifier for this plot session (must match existing plot)"
"ui:description": "🆔 Unique identifier for this plot session (must match existing plot)"
},
"variables": {
"ui:description": "🎨 Plot Variable Configuration",
"ui:help": "Configure colors and display settings for each variable in the plot",
"ui:description": "🎨 Configure colors and display settings for each variable in the plot",
"ui:options": {
"addable": true,
"orderable": true,
@ -64,16 +63,16 @@
"variable_name": {
"ui:widget": "variableSelector",
"ui:placeholder": "Search and select variable from datasets...",
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
"ui:description": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
},
"label": {
"ui:widget": "text",
"ui:placeholder": "Chart legend label...",
"ui:help": "📊 Label shown in the plot legend for this variable"
"ui:description": "📊 Label shown in the plot legend for this variable"
},
"color": {
"ui:widget": "color",
"ui:help": "🎨 Select the color for this variable in the plot",
"ui:description": "🎨 Select the color for this variable in the plot",
"ui:placeholder": "#3498db",
"ui:options": {
"presetColors": [
@ -94,15 +93,15 @@
},
"line_width": {
"ui:widget": "updown",
"ui:help": "📏 Width of the line in the plot (1-10 pixels)"
"ui:description": "📏 Width of the line in the plot (1-10 pixels)"
},
"y_axis": {
"ui:widget": "select",
"ui:help": "📊 Which Y-axis to use for this variable (left or right)"
"ui:description": "📊 Which Y-axis to use for this variable (left or right)"
},
"enabled": {
"ui:widget": "switch",
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
"ui:description": "📊 Enable this variable to be displayed in the real-time plot"
}
}
}

View File

@ -23,7 +23,20 @@ def resource_path(relative_path):
def external_path(relative_path):
"""Get path external to PyInstaller bundle (for records, logs, etc.)"""
"""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
if getattr(sys, "frozen", False):
# Running as PyInstaller executable - use directory next to exe
executable_dir = os.path.dirname(sys.executable)
@ -58,7 +71,12 @@ class ConfigManager:
self.state_file = external_path("system_state.json")
# Default configurations
self.plc_config = {"ip": "192.168.1.100", "rack": 0, "slot": 2}
self.plc_config = {
"ip": "192.168.1.100",
"rack": 0,
"slot": 2,
"symbols_path": "",
}
self.udp_config = {"host": "127.0.0.1", "port": 9870, "sampling_interval": 1.0}
self.sampling_interval = 0.1 # Legacy fallback
@ -435,10 +453,22 @@ class ConfigManager:
return external_path(base)
# PLC Configuration Methods
def update_plc_config(self, ip: str, rack: int, slot: int):
def update_plc_config(
self, ip: str, rack: int, slot: int, symbols_path: str = None
):
"""Update PLC configuration"""
old_config = self.plc_config.copy()
self.plc_config = {"ip": ip, "rack": rack, "slot": slot}
# 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.save_configuration()
return {"old_config": old_config, "new_config": self.plc_config}
@ -507,9 +537,7 @@ 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"""

View File

@ -359,9 +359,13 @@ class PLCDataStreamer:
)
# Configuration Methods
def update_plc_config(self, ip: str, rack: int, slot: int):
def update_plc_config(
self, ip: str, rack: int, slot: int, symbols_path: str = None
):
"""Update PLC configuration"""
config_details = self.config_manager.update_plc_config(ip, rack, slot)
config_details = self.config_manager.update_plc_config(
ip, rack, slot, symbols_path
)
self.event_logger.log_event(
"info",
"config_change",

View File

@ -230,6 +230,10 @@ 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(

View File

@ -10,6 +10,7 @@
},
"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",

View File

@ -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="/app" colorScheme="blue" size="sm">Reload SPA</Button>
<Button as="a" href="/" 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>

View File

@ -13,12 +13,15 @@ import {
AlertIcon,
useColorModeValue,
Badge,
IconButton
IconButton,
useToast
} 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'
@ -35,10 +38,12 @@ 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()
@ -97,6 +102,61 @@ 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>
}
@ -156,6 +216,18 @@ 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}>
@ -170,7 +242,10 @@ export default function PLCConfigManager() {
validator={validator}
onChange={editing ? handleChange : () => { }}
onSubmit={editing ? () => handleSave() : undefined}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
widgets={widgets}
readonly={!editing}
showErrorList={false}

View File

@ -45,6 +45,7 @@ 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'
@ -232,7 +233,10 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
formData={item}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onChange={(e) => {
const newFormData = [...formData]
newFormData[index] = e.formData

View File

@ -633,13 +633,6 @@ 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>
)}
@ -913,6 +906,7 @@ export default function PlotHistoricalSession({
stepMinutes={1}
dataSegments={dataSegments}
onTimeChange={handleTimePointChange}
isFullscreen={true}
/>
</Box>
)}

View File

@ -39,6 +39,7 @@ 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'
@ -558,7 +559,10 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
formData={item}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
>
<div></div> {/* Prevents form buttons from showing */}
@ -624,7 +628,10 @@ function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon,
formData={item}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
>
<div></div> {/* Prevents form buttons from showing */}
@ -1140,30 +1147,30 @@ export default function PlotManager() {
variable_name: {
"ui:widget": "variableSelector",
"ui:placeholder": "Search and select variable from datasets...",
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
"ui:description": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
},
label: {
"ui:widget": "text",
"ui:placeholder": "Chart legend label...",
"ui:help": "📊 Label shown in the plot legend for this variable"
"ui:description": "📊 Label shown in the plot legend for this variable"
},
color: {
"ui:widget": "color",
"ui:help": "🎨 Select the color for this variable in the plot",
"ui:description": "🎨 Select the color for this variable in the plot",
"ui:placeholder": "#3498db"
},
line_width: {
"ui:widget": "updown",
"ui:help": "📏 Thickness of the line in the plot (1-10)",
"ui:description": "📏 Thickness of the line in the plot (1-10)",
"ui:options": { "step": 1, "min": 1, "max": 10 }
},
y_axis: {
"ui:widget": "select",
"ui:help": "📈 Which Y-axis to use for this variable"
"ui:description": "📈 Which Y-axis to use for this variable"
},
enabled: {
"ui:widget": "checkbox",
"ui:help": "✅ Whether to show this variable in the plot"
"ui:description": "✅ Whether to show this variable in the plot"
}
}
}
@ -1176,7 +1183,10 @@ export default function PlotManager() {
formData={selectedPlotVars}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onSubmit={({ formData }) => {
const updatedConfig = updateSelectedPlotVariables(formData)
savePlotVariables(updatedConfig).then(() => {

View File

@ -1,7 +1,29 @@
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 } from "@chakra-ui/react";
import { CheckIcon } from "@chakra-ui/icons";
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 DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import DataAvailabilityBar from "./DataAvailabilityBar.jsx";
@ -14,6 +36,7 @@ export default function TimePointSelector({
stepMinutes = 5,
dataSegments = [],
onTimeChange,
isFullscreen = false, // NEW: Enable ultra-compact mode for fullscreen
}) {
const { t } = useTranslation();
// Color mode values
@ -162,211 +185,417 @@ export default function TimePointSelector({
};
return (
<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 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
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')
}
}}
position="absolute"
top="-8px"
left="0"
right="0"
px="1"
zIndex={1}
>
<DatePicker
selected={value}
onChange={onPick}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={stepMinutes}
timeCaption="Time"
dateFormat="dd-MM-yyyy HH:mm"
<DataAvailabilityBar
segments={dataSegments}
minDate={minDate}
maxDate={maxDate}
// 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))}
height="4px"
showTooltips={true}
/>
</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>
<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
})}
{/* 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
</Text>
{hasPendingChanges && (
<Text fontSize="xs" color="orange.500">
<Text color="orange.500" fontWeight="medium">
{t('timeSelector.pendingChanges')}
</Text>
)}
</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>
</HStack>
)}
</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>
);
}

View File

@ -60,13 +60,21 @@ export default function LayoutObjectFieldTemplate(props) {
)
)}
{layout.map((row, rowIdx) => (
<SimpleGrid key={rowIdx} columns={12} spacing={3}>
<SimpleGrid key={rowIdx} columns={12} spacing={3} alignItems="flex-end">
{row.map((cell, cellIdx) => {
const prop = propMap.get(cell.name)
if (!prop) return null
const col = Math.min(Math.max(cell.width || 12, 1), 12)
return (
<Box key={`${rowIdx}-${cellIdx}`} gridColumn={`span ${col}`}>{prop.content}</Box>
<Box
key={`${rowIdx}-${cellIdx}`}
gridColumn={`span ${col}`}
minH="40px" // Minimum height for consistent alignment
display="flex"
alignItems="flex-end"
>
{prop.content}
</Box>
)
})}
</SimpleGrid>

View File

@ -0,0 +1,96 @@
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>
)
}

View File

@ -132,24 +132,80 @@ 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}>
<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>
<Checkbox
id={id}
isChecked={!!value}
onChange={(e) => onChange(e.target.checked)}
colorScheme="blue"
>
{label}
</Checkbox>
order={2} // Checkbox second
alignSelf="center" // Ensure checkbox is centered
sx={{
'& .chakra-checkbox__control': {
alignSelf: 'center'
}
}}
/>
{rawErrors.length > 0 && (
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
<FormHelperText color="red.500" position="absolute" top="100%" left={0} mt={1} w="100%" textAlign="center">
{rawErrors[0]}
</FormHelperText>
)}
</FormControl>
)
export const SwitchWidget = ({ id, label, value, required, disabled, readonly, onChange, rawErrors = [] }) => (
<FormControl display="flex" alignItems="center" isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
<FormLabel htmlFor={id} mb="0" mr={3}>
<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
>
{label}
</FormLabel>
<Switch
@ -157,9 +213,13 @@ 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">{rawErrors[0]}</FormHelperText>
<FormHelperText color="red.500" position="absolute" top="100%" left={0} mt={1} w="100%" textAlign="center">
{rawErrors[0]}
</FormHelperText>
)}
</FormControl>
)

View File

@ -0,0 +1,25 @@
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>
)
})

View File

@ -2,6 +2,8 @@ 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'
@ -27,11 +29,21 @@ export const allWidgets = {
'variable-selector': VariableSelectorWidget,
VariableSelectorWidget: VariableSelectorWidget,
// File path widget for ASC symbol files
// 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,
// Symbol selector widget for PLC symbols
symbolSelector: SymbolSelectorWidget,
'symbol-selector': SymbolSelectorWidget,

View File

@ -0,0 +1,69 @@
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

View File

@ -0,0 +1,23 @@
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

View File

@ -0,0 +1,165 @@
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

View File

@ -0,0 +1,165 @@
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

View File

@ -0,0 +1,159 @@
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

View File

@ -15,7 +15,7 @@ createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<ChakraProvider theme={theme}>
<BrowserRouter basename="/app">
<BrowserRouter>
<App />
</BrowserRouter>
</ChakraProvider>

View File

@ -54,12 +54,14 @@ 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'
@ -563,7 +565,10 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
formData={item}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
>
<div></div> {/* Prevents form buttons from showing */}
@ -663,7 +668,10 @@ function CollapsibleArrayForm({ data, schema, uiSchema, onSave, title, icon, get
formData={formData}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onSubmit={({ formData }) => onSave(formData)}
onChange={({ formData }) => setFormData(formData)}
>
@ -1064,6 +1072,7 @@ 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)
@ -1075,6 +1084,59 @@ 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}>
@ -1095,12 +1157,27 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
{t('config.subtitle')}
</Text>
</Box>
<ImportExportButtons
data={formData}
onImport={handleImportConfig}
exportFilename="plc_config.json"
configType="object"
/>
<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>
</Flex>
{message && (
<Alert status="success" mt={2}>
@ -1116,7 +1193,10 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
formData={formData}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onChange={({ formData }) => onFormChange(formData)}
onSubmit={({ formData }) => onSave(formData)}
>
@ -1528,7 +1608,10 @@ function DatasetManager() {
formData={selectedDatasetVars}
validator={validator}
widgets={allWidgets}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
templates={{
ObjectFieldTemplate: LayoutObjectFieldTemplate,
FieldTemplate: TooltipFieldTemplate
}}
onSubmit={({ formData }) => {
updateSelectedDatasetVariables(formData)
saveVariables(variablesConfig).then(() => {
@ -1659,9 +1742,9 @@ function ConsoleLogsDisplay({ logs, loading, onRefresh }) {
p={4}
height="600px"
overflowY="scroll"
fontFamily="mono"
fontSize="sm"
lineHeight="1.4"
fontFamily="mono"
fontSize="xs"
lineHeight="1.3"
color={logColor}
sx={{
'&::-webkit-scrollbar': {

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

99
main.py
View File

@ -121,8 +121,8 @@ CORS(
resources={
r"/api/*": {
"origins": [
"http://localhost:5173/app",
"http://127.0.0.1:5173/app",
"http://localhost:5173",
"http://127.0.0.1:5173",
"*",
]
}
@ -160,15 +160,25 @@ def project_path(*parts: str) -> str:
return os.path.join(base_dir, *parts)
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")
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()
else:
# Running as script - use current directory
return os.path.join(os.path.dirname(__file__), "records")
# 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")
# React build directory (for Vite production build)
@ -475,8 +485,9 @@ 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)
streamer.update_plc_config(ip, rack, slot, symbols_path)
return jsonify({"success": True, "message": "PLC configuration updated"})
except Exception as e:
@ -2095,7 +2106,8 @@ def get_historical_data():
return jsonify({"error": f"Time calculation failed: {str(e)}"}), 500
# Get relevant CSV files for cache checking
records_dir = get_records_directory()
config_mgr = streamer.config_manager if streamer else None
records_dir = get_records_directory(config_mgr)
csv_files = []
if os.path.exists(records_dir):
@ -2437,7 +2449,8 @@ def get_historical_date_range():
import glob
# Get records directory
records_dir = get_records_directory()
config_mgr = streamer.config_manager if streamer else None
records_dir = get_records_directory(config_mgr)
if not os.path.exists(records_dir):
return (
@ -2572,7 +2585,8 @@ def get_historical_data_segments():
try:
import glob
records_dir = get_records_directory()
config_mgr = streamer.config_manager if streamer else None
records_dir = get_records_directory(config_mgr)
if not os.path.exists(records_dir):
return (
@ -3358,7 +3372,8 @@ def main():
time.sleep(2)
# Setup and run the system tray icon
icon_path = project_path("frontend", "src", "assets", "logo", "record.png")
# Use the icon from public folder (included in frontend/dist build)
icon_path = resource_path(os.path.join("frontend", "dist", "record.png"))
try:
image = Image.open(icon_path)
menu = pystray.Menu(
@ -3547,6 +3562,43 @@ 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."""
@ -3554,12 +3606,26 @@ 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(
@ -3773,7 +3839,8 @@ def process_symbol_variables():
def get_csv_files():
"""Get structured list of CSV files organized by dataset, date, and hour"""
try:
records_dir = get_records_directory()
config_mgr = streamer.config_manager if streamer else None
records_dir = get_records_directory(config_mgr)
if not os.path.exists(records_dir):
return jsonify({"files": [], "tree": []})

12
mcp-config.json Normal file
View File

@ -0,0 +1,12 @@
{
"mcpServers": {
"playwright-automation": {
"command": "node",
"args": ["src/server.js"],
"cwd": "testing/mcp-server",
"env": {
"NODE_ENV": "production"
}
}
}
}

View File

@ -1,12 +1,11 @@
{
"last_state": {
"should_connect": true,
"should_stream": true,
"should_stream": false,
"active_datasets": [
"DAR"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-25T18:40:24.478882",
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
"last_update": "2025-08-27T15:19:56.923648"
}

18
testing/.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# 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

291
testing/README.md Normal file
View File

@ -0,0 +1,291 @@
# 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)

View File

@ -0,0 +1,255 @@
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();
}
}

163
testing/example.js Normal file
View File

@ -0,0 +1,163 @@
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
`);
}

View File

@ -0,0 +1,186 @@
# 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! 🏭🤖

View File

@ -0,0 +1,952 @@
#!/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);

View File

@ -0,0 +1,140 @@
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();
}

View File

@ -0,0 +1,190 @@
#!/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();

View File

@ -0,0 +1,83 @@
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,
}
],
});

27
testing/quick-start.sh Normal file
View File

@ -0,0 +1,27 @@
#!/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 Normal file
View File

@ -0,0 +1,175 @@
#!/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();

View File

@ -0,0 +1,11 @@
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');
});

View File

@ -0,0 +1,54 @@
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();
});
});

View File

@ -0,0 +1,48 @@
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());
});
});

View File

@ -0,0 +1,77 @@
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');
});
});

View File

@ -0,0 +1,58 @@
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();
});
});

View File

@ -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 = 5.0,
check_interval: float = 1.0,
):
"""
Initialize the instance manager