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.
This commit is contained in:
parent
c251c76072
commit
1b6528977a
22013
application_events.json
22013
application_events.json
File diff suppressed because it is too large
Load Diff
|
@ -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": {
|
||||
|
|
|
@ -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", "*.*"]]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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:help": "💾 Directory for CSV files. Relative paths based on app directory, absolute paths (C:\\folder) used as-is."
|
||||
},
|
||||
"rotation_enabled": {
|
||||
"ui:column": 3,
|
||||
|
@ -69,8 +75,14 @@
|
|||
},
|
||||
"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:help": "📁 Select the ASC symbol file from TIA Portal export. Use Load Symbols button to process."
|
||||
},
|
||||
"ui:column": 12,
|
||||
"ui:layout": [
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -13,9 +13,11 @@ 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'
|
||||
|
@ -35,10 +37,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 +101,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 +215,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}>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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:help'] || 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
|
|
@ -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:help'] || 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
|
|
@ -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:help'] || 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
|
|
@ -54,6 +54,7 @@ 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'
|
||||
|
@ -1064,6 +1065,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 +1077,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 +1150,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}>
|
||||
|
|
54
main.py
54
main.py
|
@ -475,8 +475,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:
|
||||
|
@ -3548,6 +3549,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."""
|
||||
|
@ -3555,12 +3593,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(
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"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",
|
||||
"last_update": "2025-08-27T09:24:15.915232",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
Loading…
Reference in New Issue