Enhance Document and Printer Components with Host Device Scanning
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced HostDeviceScanner component to facilitate network scanning for document printers and printers.
- Updated NewDocumentPrinter and NewPrinter components to integrate HostDeviceScanner, allowing users to scan for compatible devices based on connection settings.
- Enhanced ObjectInfo displays within both components to improve visibility of connection properties and device information.
- Refactored default values to include connection settings for better initial configuration.
This commit is contained in:
Tom Butcher 2026-09-14 22:50:17 +01:00
parent 2fcab27cd4
commit e32e48fa3a
3 changed files with 462 additions and 21 deletions

View File

@ -1,20 +1,42 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Flex } from 'antd'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView' import WizardView from '../../common/WizardView'
import HostDeviceScanner, {
getHostId,
isNetworkScanCompatible
} from '../../common/HostDeviceScanner'
const NewDocumentPrinter = ({ onOk, defaultValues }) => { const NewDocumentPrinter = ({ onOk, defaultValues }) => {
return ( return (
<NewObjectForm <NewObjectForm
type={'documentPrinter'} type={'documentPrinter'}
defaultValues={{ active: true, global: false, ...defaultValues }} defaultValues={{
active: true,
global: false,
connection: {
interface: 'cups',
protocol: 'ipp',
port: 631
},
...defaultValues
}}
> >
{({ handleSubmit, submitLoading, objectData, formValid }) => { {({
handleSubmit,
submitLoading,
objectData,
formValid,
form,
setObjectData
}) => {
const steps = [ const steps = [
{ {
title: 'Required', title: 'Required',
key: 'required', key: 'required',
content: ( content: (
<Flex vertical gap='middle' style={{ width: '100%' }}>
<ObjectInfo <ObjectInfo
type='documentPrinter' type='documentPrinter'
column={1} column={1}
@ -22,8 +44,32 @@ const NewDocumentPrinter = ({ onOk, defaultValues }) => {
isEditing={true} isEditing={true}
required={true} required={true}
labelWidth={100} labelWidth={100}
visibleProperties={{ 'connection.host': false }}
objectData={objectData} objectData={objectData}
/> />
{getHostId(objectData.host) &&
isNetworkScanCompatible(objectData.connection?.protocol) ? (
<HostDeviceScanner
objectType='documentPrinter'
host={objectData.host}
port={objectData.connection?.port}
protocol={objectData.connection?.protocol}
interface={objectData.connection?.interface}
form={form}
setObjectData={setObjectData}
objectData={objectData}
/>
) : null}
<ObjectInfo
type='documentPrinter'
column={1}
bordered={false}
isEditing={true}
labelWidth={100}
properties={['connection.host']}
objectData={objectData}
/>
</Flex>
) )
}, },
{ {

View File

@ -1,7 +1,12 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Flex } from 'antd'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView' import WizardView from '../../common/WizardView'
import HostDeviceScanner, {
getHostId,
isNetworkScanCompatible
} from '../../common/HostDeviceScanner'
const NewPrinter = ({ onOk, defaultValues }) => { const NewPrinter = ({ onOk, defaultValues }) => {
return ( return (
@ -16,12 +21,20 @@ const NewPrinter = ({ onOk, defaultValues }) => {
...defaultValues ...defaultValues
}} }}
> >
{({ handleSubmit, submitLoading, objectData, formValid }) => { {({
handleSubmit,
submitLoading,
objectData,
formValid,
form,
setObjectData
}) => {
const steps = [ const steps = [
{ {
title: 'Required', title: 'Required',
key: 'required', key: 'required',
content: ( content: (
<Flex vertical gap='middle' style={{ width: '100%' }}>
<ObjectInfo <ObjectInfo
type='printer' type='printer'
column={1} column={1}
@ -29,8 +42,31 @@ const NewPrinter = ({ onOk, defaultValues }) => {
bordered={false} bordered={false}
isEditing={true} isEditing={true}
required={true} required={true}
visibleProperties={{ 'moonraker.host': false }}
objectData={objectData} objectData={objectData}
/> />
{getHostId(objectData.host) &&
isNetworkScanCompatible(objectData.moonraker?.protocol) ? (
<HostDeviceScanner
objectType='printer'
host={objectData.host}
port={objectData.moonraker?.port}
protocol={objectData.moonraker?.protocol}
form={form}
setObjectData={setObjectData}
objectData={objectData}
/>
) : null}
<ObjectInfo
type='printer'
column={1}
labelWidth='100px'
bordered={false}
isEditing={true}
properties={['moonraker.host']}
objectData={objectData}
/>
</Flex>
) )
}, },
{ {

View File

@ -0,0 +1,359 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { Flex, Typography } from 'antd'
import { ApiServerContext } from '../context/ApiServerContext'
import { mergeFormData } from '../utils/Utils'
import ProgressDisplay from './ProgressDisplay'
import MissingPlaceholder from './MissingPlaceholder'
import PrinterIcon from '../../Icons/PrinterIcon'
import DocumentPrinterIcon from '../../Icons/DocumentPrinterIcon'
import ElipsisText from './ElipsisText'
const { Text } = Typography
export const getHostId = (host) => {
if (!host) return null
if (typeof host === 'string') return host
if (typeof host._id === 'string') return host._id
if (typeof host._id?._id === 'string') return host._id._id
return null
}
export const isNetworkScanCompatible = (protocol) =>
protocol !== 'serial' && protocol !== 'system'
const deviceKey = (device) => device?.ip || device?.hostname
const HostDeviceScanner = ({
objectType,
host,
port,
protocol,
interface: connectionInterface,
form,
setObjectData,
objectData
}) => {
const { connected, sendObjectAction, subscribeToObjectEvent } =
useContext(ApiServerContext)
const sendObjectActionRef = useRef(sendObjectAction)
sendObjectActionRef.current = sendObjectAction
const subscribeToObjectEventRef = useRef(subscribeToObjectEvent)
subscribeToObjectEventRef.current = subscribeToObjectEvent
const sessionIdRef = useRef(
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `scan-${Date.now()}-${Math.random().toString(16).slice(2)}`
)
const [scanning, setScanning] = useState(false)
const [progress, setProgress] = useState(0)
const [currentIP, setCurrentIP] = useState(null)
const [devices, setDevices] = useState([])
const [selectedKey, setSelectedKey] = useState(null)
const [scanError, setScanError] = useState(null)
const hostId = getHostId(host)
const compatible = isNetworkScanCompatible(protocol)
const DeviceIcon =
objectType === 'documentPrinter' ? DocumentPrinterIcon : PrinterIcon
const scanDataRef = useRef({
objectType,
port,
protocol,
interface: connectionInterface
})
scanDataRef.current = {
objectType,
port,
protocol,
interface: connectionInterface
}
useEffect(() => {
if (!hostId || connected !== true || !compatible) {
return
}
let cancelled = false
const sessionId = sessionIdRef.current
const isSessionEvent = (event) => event?.data?.sessionId === sessionId
const startScan = () => {
if (cancelled) return
setProgress(0)
setCurrentIP(null)
setScanError(null)
setScanning(true)
sendObjectActionRef.current(
hostId,
'host',
{
type: 'scanNetwork',
data: {
...scanDataRef.current,
sessionId,
loop: true
}
},
(result) => {
if (cancelled) return
if (result?.success === false) {
setScanning(false)
setScanError(result.error || 'Failed to start network scan')
}
}
)
}
const onFound = (event) => {
if (!isSessionEvent(event)) return
const device = event?.data
if (!device?.ip && !device?.hostname) return
setDevices((prev) => {
if (prev.some((item) => deviceKey(item) === deviceKey(device))) {
return prev
}
return [...prev, device]
})
}
const onProgress = (event) => {
if (!isSessionEvent(event)) return
const data = event?.data || {}
if (typeof data.progress === 'number') {
setProgress(Math.round(data.progress))
}
if (data.currentIP) {
setCurrentIP(data.currentIP)
}
}
const onComplete = (event) => {
if (!isSessionEvent(event) || cancelled) return
const data = event?.data || {}
if (data.stopped) {
setScanning(false)
return
}
if (data.success === false) {
setScanning(false)
setScanError(data.error || 'Network scan failed')
return
}
if (Array.isArray(data.devices)) {
setDevices((prev) => {
const next = [...prev]
data.devices.forEach((device) => {
if (!next.some((item) => deviceKey(item) === deviceKey(device))) {
next.push(device)
}
})
return next
})
}
setProgress(data.looping ? 0 : 100)
if (!data.looping) {
setScanning(false)
}
}
const unsubFound = subscribeToObjectEventRef.current(
hostId,
'host',
'scanNetworkFound',
onFound
)
const unsubProgress = subscribeToObjectEventRef.current(
hostId,
'host',
'scanNetworkProgress',
onProgress
)
const unsubComplete = subscribeToObjectEventRef.current(
hostId,
'host',
'scanNetworkComplete',
onComplete
)
setDevices([])
setSelectedKey(null)
startScan()
return () => {
cancelled = true
if (unsubFound) unsubFound()
if (unsubProgress) unsubProgress()
if (unsubComplete) unsubComplete()
sendObjectActionRef.current(hostId, 'host', {
type: 'scanNetworkStop',
data: { sessionId }
})
}
}, [
hostId,
connected,
compatible,
port,
protocol,
connectionInterface,
objectType
])
const applyDevice = useCallback(
(device) => {
const deviceHost = device?.hostname || device?.ip
if (!deviceHost) return
const nextValues =
objectType === 'documentPrinter'
? {
connection: {
...(objectData?.connection || {}),
host: deviceHost,
port,
protocol,
interface: connectionInterface
}
}
: {
moonraker: {
...(objectData?.moonraker || {}),
host: deviceHost,
port,
protocol
}
}
if (!objectData?.name) {
nextValues.name = deviceHost
}
form?.setFieldsValue(nextValues)
setObjectData?.((prev) => mergeFormData(prev || {}, nextValues))
setSelectedKey(deviceKey(device))
},
[
objectType,
objectData,
port,
protocol,
connectionInterface,
form,
setObjectData
]
)
const statusLabel = useMemo(() => {
if (scanError) {
return scanError
}
if (currentIP) {
return `Scanning ${currentIP}...`
}
if (scanning) {
return 'Scanning...'
}
return 'Waiting...'
}, [scanning, currentIP, scanError])
if (!hostId || !compatible) {
return null
}
return (
<Flex
vertical
gap={10}
style={{ width: '100%', marginBottom: 6, marginTop: 4 }}
>
{statusLabel ? (
<ElipsisText style={{ width: '100%', minWidth: 0 }}>
{statusLabel}
</ElipsisText>
) : null}
<ProgressDisplay
percent={Math.round(progress == 100 ? 0 : progress)}
style={{ marginBottom: 8 }}
status={scanning ? 'active' : scanError ? 'exception' : 'normal'}
/>
{devices.length === 0 ? (
<MissingPlaceholder
message={scanning ? 'Looking for devices...' : 'No devices found.'}
hasBackground={false}
mutedBorder
/>
) : (
<Flex vertical gap={8} style={{ width: '100%' }}>
{devices.map((device) => {
const key = deviceKey(device)
const selected = selectedKey === key
return (
<div
key={key}
onClick={() => applyDevice(device)}
style={{
textAlign: 'left',
cursor: 'pointer',
background: selected
? 'color-mix(in srgb, var(--color-primary) 12%, transparent)'
: 'transparent',
border: selected
? '1px solid var(--color-primary)'
: '1px solid var(--color-descriptions-border)',
borderRadius: 12,
padding: '14px 18px'
}}
>
<Flex align='center' gap={10}>
<DeviceIcon style={{ fontSize: 20 }} />
<Flex vertical style={{ minWidth: 0 }}>
<Text strong ellipsis>
{device.hostname || device.ip}
</Text>
{device.hostname && device.ip ? (
<Text type='secondary' ellipsis>
{device.ip}
</Text>
) : null}
</Flex>
</Flex>
</div>
)
})}
</Flex>
)}
</Flex>
)
}
HostDeviceScanner.propTypes = {
objectType: PropTypes.oneOf(['printer', 'documentPrinter']).isRequired,
host: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
port: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
protocol: PropTypes.string,
interface: PropTypes.string,
form: PropTypes.object,
setObjectData: PropTypes.func,
objectData: PropTypes.object
}
export default HostDeviceScanner