Compare commits

...

2 Commits

Author SHA1 Message Date
e32e48fa3a 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.
2026-09-14 22:50:17 +01:00
2fcab27cd4 Enhance PrinterQueue Component with CSS Styling for Vertical Steps
- Added a new CSS class for the PrinterQueue component to adjust the minimum height of the last step item in vertical step layouts.
- Updated the PrinterQueue component to include the new class, improving visual consistency and layout management in the dashboard.
2026-09-14 22:08:40 +01:00
5 changed files with 469 additions and 21 deletions

View File

@ -3350,3 +3350,9 @@ body.objectKanbanColumnResizing * {
stroke: currentColor;
stroke-width: 12px;
}
.printerQueue.ant-steps.ant-steps-vertical
> .ant-steps-item:last-child
.ant-steps-item-content {
min-height: 0;
}

View File

@ -1,29 +1,75 @@
import PropTypes from 'prop-types'
import { Flex } from 'antd'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
import HostDeviceScanner, {
getHostId,
isNetworkScanCompatible
} from '../../common/HostDeviceScanner'
const NewDocumentPrinter = ({ onOk, defaultValues }) => {
return (
<NewObjectForm
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 = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='documentPrinter'
column={1}
bordered={false}
isEditing={true}
required={true}
labelWidth={100}
objectData={objectData}
/>
<Flex vertical gap='middle' style={{ width: '100%' }}>
<ObjectInfo
type='documentPrinter'
column={1}
bordered={false}
isEditing={true}
required={true}
labelWidth={100}
visibleProperties={{ 'connection.host': false }}
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 { Flex } from 'antd'
import ObjectInfo from '../../common/ObjectInfo'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
import HostDeviceScanner, {
getHostId,
isNetworkScanCompatible
} from '../../common/HostDeviceScanner'
const NewPrinter = ({ onOk, defaultValues }) => {
return (
@ -16,21 +21,52 @@ const NewPrinter = ({ onOk, defaultValues }) => {
...defaultValues
}}
>
{({ handleSubmit, submitLoading, objectData, formValid }) => {
{({
handleSubmit,
submitLoading,
objectData,
formValid,
form,
setObjectData
}) => {
const steps = [
{
title: 'Required',
key: 'required',
content: (
<ObjectInfo
type='printer'
column={1}
labelWidth='100px'
bordered={false}
isEditing={true}
required={true}
objectData={objectData}
/>
<Flex vertical gap='middle' style={{ width: '100%' }}>
<ObjectInfo
type='printer'
column={1}
labelWidth='100px'
bordered={false}
isEditing={true}
required={true}
visibleProperties={{ 'moonraker.host': false }}
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

View File

@ -49,6 +49,7 @@ const PrinterQueue = ({ queue, currentSubJob }) => {
<Steps
current={currentIndex}
direction='vertical'
className='printerQueue'
size='small'
items={items.map((item) => ({
title: <ObjectDisplay object={item} objectType='subJob' showHyperlink />