diff --git a/src/components/Dashboard/Management/DocumentPrinters/NewDocumentPrinter.jsx b/src/components/Dashboard/Management/DocumentPrinters/NewDocumentPrinter.jsx index 20c77821..50fdd259 100644 --- a/src/components/Dashboard/Management/DocumentPrinters/NewDocumentPrinter.jsx +++ b/src/components/Dashboard/Management/DocumentPrinters/NewDocumentPrinter.jsx @@ -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 ( - {({ handleSubmit, submitLoading, objectData, formValid }) => { + {({ + handleSubmit, + submitLoading, + objectData, + formValid, + form, + setObjectData + }) => { const steps = [ { title: 'Required', key: 'required', content: ( - + + + {getHostId(objectData.host) && + isNetworkScanCompatible(objectData.connection?.protocol) ? ( + + ) : null} + + ) }, { diff --git a/src/components/Dashboard/Production/Printers/NewPrinter.jsx b/src/components/Dashboard/Production/Printers/NewPrinter.jsx index f58e9818..c2c79d69 100644 --- a/src/components/Dashboard/Production/Printers/NewPrinter.jsx +++ b/src/components/Dashboard/Production/Printers/NewPrinter.jsx @@ -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: ( - + + + {getHostId(objectData.host) && + isNetworkScanCompatible(objectData.moonraker?.protocol) ? ( + + ) : null} + + ) }, { diff --git a/src/components/Dashboard/common/HostDeviceScanner.jsx b/src/components/Dashboard/common/HostDeviceScanner.jsx new file mode 100644 index 00000000..c0d60db7 --- /dev/null +++ b/src/components/Dashboard/common/HostDeviceScanner.jsx @@ -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 ( + + {statusLabel ? ( + + {statusLabel} + + ) : null} + + + + {devices.length === 0 ? ( + + ) : ( + + {devices.map((device) => { + const key = deviceKey(device) + const selected = selectedKey === key + return ( +
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' + }} + > + + + + + {device.hostname || device.ip} + + {device.hostname && device.ip ? ( + + {device.ip} + + ) : null} + + +
+ ) + })} +
+ )} +
+ ) +} + +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