2025-08-22 20:28:50 +01:00

949 lines
31 KiB
JavaScript

import { useState, useContext, useCallback, useEffect } from 'react'
import axios from 'axios'
import { useLocation } from 'react-router-dom'
import { useMediaQuery } from 'react-responsive'
import {
Button,
message,
Spin,
Flex,
Card,
Dropdown,
Space,
Descriptions,
Progress,
Modal,
Typography,
Badge,
Alert,
Popover,
Checkbox,
Collapse
} from 'antd'
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
import { PrintServerContext } from '../../context/PrintServerContext'
import PrinterTemperaturePanel from '../../common/PrinterTemperaturePanel'
import PrinterPositionPanel from '../../common/PrinterPositionPanel'
import PrinterMovementPanel from '../../common/PrinterMovementPanel'
import PrinterMiscPanel from '../../common/PrinterMiscPanel'
import PrinterState from '../../common/StateDisplay'
import { AuthContext } from '../../context/AuthContext'
import PrinterSubJobsTree from '../../common/PrinterJobsTree'
import IdDisplay from '../../common/IdDisplay'
import FilamentIcon from '../../../Icons/FilamentIcon'
import FilamentStockIcon from '../../../Icons/FilamentStockIcon'
import ReloadIcon from '../../../Icons/ReloadIcon'
import GCodeFileIcon from '../../../Icons/GCodeFileIcon'
import LoadFilamentStock from '../../Inventory/FilamentStocks/LoadFilamentStock'
import UnloadFilamentStock from '../../Inventory/FilamentStocks/UnloadFilamentStock'
import FilamentStockState from '../../common/FilamentStockState'
import useCollapseState from '../../hooks/useCollapseState'
import config from '../../../../config'
import PlayCircleIcon from '../../../Icons/PlayCircleIcon'
import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon'
import PauseCircleIcon from '../../../Icons/PauseCircleIcon'
import ExclamationOctagonIcon from '../../../Icons/ExclamationOctagonIcon'
import TimeDisplay from '../../common/TimeDisplay'
const { Text, Title } = Typography
// Helper function to parse query parameters
const useQuery = () => {
return new URLSearchParams(useLocation().search)
}
const ControlPrinter = () => {
const [messageApi] = message.useMessage()
const query = useQuery()
const printerId = query.get('printerId')
const isMobile = useMediaQuery({ maxWidth: 768 })
const [printerData, setPrinterData] = useState(null)
const [fetchLoading, setFetchLoading] = useState(true)
const [initialized, setInitialized] = useState(false)
const [loadFilamentStockModalOpen, setLoadFilamentStockModalOpen] =
useState(false)
const [unloadFilamentStockModalOpen, setUnloadFilamentStockModalOpen] =
useState(false)
const [klippyErrorModalOpen, setKlippyErrorModalOpen] = useState(false)
const [klippyErrorMessage, setKlippyErrorMessage] = useState('')
const [klippyStartupMessage, setKlippyStartupMessage] = useState('')
const [collapseState, updateCollapseState] = useCollapseState(
'ControlPrinter',
{
job: true,
filament: true,
gcodefile: true,
jobs: true
}
)
// Load visibility preferences from sessionStorage on component mount
const [componentVisibility, setComponentVisibility] = useState(() => {
const savedVisibility = sessionStorage.getItem('printerControlVisibility')
if (savedVisibility) {
return JSON.parse(savedVisibility)
}
return {
temperature: true,
movement: true,
subjobs: true
}
})
// Save visibility preferences to sessionStorage whenever they change
useEffect(() => {
sessionStorage.setItem(
'printerControlVisibility',
JSON.stringify(componentVisibility)
)
}, [componentVisibility])
const { printServer } = useContext(PrintServerContext)
const { authenticated } = useContext(AuthContext)
// Fetch printer details when the component mounts
const fetchPrinterDetails = useCallback(async () => {
if (printerId) {
setFetchLoading(true)
try {
const response = await axios.get(
`${config.backendUrl}/printers/${printerId}`,
{
headers: {
Accept: 'application/json'
},
withCredentials: true // Important for including cookies
}
)
setFetchLoading(false)
setPrinterData(response.data)
} catch (error) {
setFetchLoading(false)
if (error.response) {
messageApi.error(
'Error fetching printer data:',
error.response.status
)
} else {
messageApi.error(
'An unexpected error occurred. Please try again later.'
)
}
}
}
}, [printerId, messageApi])
// Add WebSocket event listener for real-time updates
useEffect(() => {
if (printServer && !initialized && printerId) {
setInitialized(true)
printServer.on('notify_printer_update', (statusUpdate) => {
setPrinterData((prevData) => {
if (statusUpdate?._id === printerId) {
return {
...prevData,
...statusUpdate
}
}
return prevData
})
})
// Add WebSocket event listener for filament stock updates
printServer.on('notify_filamentstock_update', (filamentStockUpdate) => {
setPrinterData((prevData) => {
if (prevData?.currentFilamentStock) {
if (
prevData?.currentFilamentStock?._id === filamentStockUpdate?._id
) {
return {
...prevData,
currentFilamentStock: {
...prevData.currentFilamentStock,
...filamentStockUpdate
}
}
}
}
return prevData
})
})
}
return () => {
if (printServer && initialized) {
printServer.off('notify_printer_update')
printServer.off('notify_filamentstock_update')
}
}
}, [printServer, initialized, printerId])
function handleEmergencyStop() {
printServer.emit('printer.emergency_stop', { printerId })
}
useEffect(() => {
if (authenticated) {
fetchPrinterDetails()
}
}, [authenticated, fetchPrinterDetails])
useEffect(() => {
const loadFilamentStock = printerData?.alerts?.find(
(alert) => alert.type === 'loadFilamentStock'
)
const klippyError = printerData?.alerts?.find(
(alert) => alert.type === 'klippyError'
)
const klippyStartup = printerData?.alerts?.find(
(alert) => alert.type === 'klippyStartup'
)
if (loadFilamentStock) {
setLoadFilamentStockModalOpen(true)
} else {
setLoadFilamentStockModalOpen(false)
}
if (klippyError) {
setKlippyErrorModalOpen(true)
setKlippyErrorMessage(klippyError.message)
} else {
setKlippyErrorModalOpen(false)
setKlippyErrorMessage('')
}
if (klippyStartup) {
setKlippyStartupMessage(klippyStartup.message)
} else {
setKlippyStartupMessage('')
}
}, [printerData?.alerts])
const actionItems = {
items: [
{
label: 'Resume Print',
key: 'resumePrint',
icon: <PlayCircleIcon />,
disabled: printerData?.state?.type !== 'paused'
},
{
label: 'Pause Print',
key: 'pausePrint',
icon: <PauseCircleIcon />,
disabled: printerData?.state?.type !== 'printing'
},
{
label: 'Cancel Print',
key: 'cancelPrint',
icon: <XMarkCircleIcon />,
disabled: !(
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'paused'
)
},
{
type: 'divider'
},
{
label: 'Queue',
key: 'queue',
children: [
{
label: 'Start Queue',
key: 'startQueue',
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error',
icon: <PlayCircleIcon />
},
{
label: 'Pause Queue',
key: 'pauseQueue',
icon: <PauseCircleIcon />
}
]
},
{
label: 'Filament',
key: 'filament',
children: [
{
label: 'Load Filament Stock',
key: 'loadFilamentStock',
icon: <FilamentStockIcon />,
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'error' ||
printerData?.state?.type === 'offline' ||
printerData?.currentFilamentStock !== null
},
{
label: 'Unload Filament Stock',
key: 'unloadFilamentStock',
icon: <FilamentStockIcon />,
disabled:
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'error' ||
printerData?.state?.type === 'offline' ||
printerData?.currentFilamentStock === null
},
{
type: 'divider'
},
{
label: 'Filament Info',
key: 'filamentInfo',
icon: <FilamentIcon />
}
]
},
{
type: 'divider'
},
{
label: 'Restart Host',
key: 'restartHost',
icon: <ReloadIcon />
},
{
label: 'Restart Firmware',
key: 'restartFirmware',
icon: <ReloadIcon />
}
],
onClick: ({ key }) => {
if (key === 'restartHost') {
printServer.emit('printer.restart', { printerId })
} else if (key === 'restartFirmware') {
printServer.emit('printer.firmware_restart', { printerId })
} else if (key === 'resumePrint') {
printServer.emit('printer.print.resume', { printerId })
} else if (key === 'pausePrint') {
printServer.emit('printer.print.pause', { printerId })
} else if (key === 'cancelPrint') {
printServer.emit('printer.print.cancel', { printerId })
} else if (key === 'startQueue') {
printServer.emit('server.job_queue.start', { printerId })
} else if (key === 'pauseQueue') {
printServer.emit('server.job_queue.pause', { printerId })
} else if (key === 'loadFilamentStock') {
setLoadFilamentStockModalOpen(true)
} else if (key === 'unloadFilamentStock') {
setUnloadFilamentStockModalOpen(true)
}
}
}
const getViewDropdownItems = () => {
return (
<Flex vertical>
<Flex vertical gap='middle' style={{ margin: '4px 8px' }}>
<Checkbox
checked={componentVisibility.temperature}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
temperature: e.target.checked
}))
}}
>
Temperature Panel
</Checkbox>
<Checkbox
checked={componentVisibility.position}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
position: e.target.checked
}))
}}
>
Position Panel
</Checkbox>
<Checkbox
checked={componentVisibility.movement}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
movement: e.target.checked
}))
}}
>
Movement Panel
</Checkbox>
<Checkbox
checked={componentVisibility.subjobs}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
subjobs: e.target.checked
}))
}}
>
Sub Jobs
</Checkbox>
<Checkbox
checked={componentVisibility.misc}
onChange={(e) => {
setComponentVisibility((prev) => ({
...prev,
misc: e.target.checked
}))
}}
>
Misc Panel
</Checkbox>
</Flex>
</Flex>
)
}
return (
<>
<Flex
gap='large'
vertical='true'
style={{ height: '100%', minHeight: 0 }}
>
<Flex justify={'space-between'}>
<Space size='middle'>
<Space size='small'>
<Dropdown menu={actionItems}>
<Button>Actions</Button>
</Dropdown>
<Popover
content={getViewDropdownItems()}
placement='bottomLeft'
arrow={false}
>
<Button>View</Button>
</Popover>
</Space>
{printerData ? (
<PrinterState
printer={printerData}
showProgress={false}
showName={false}
showControls={false}
/>
) : (
<Spin indicator={<LoadingOutlined spin />} size='small' />
)}
</Space>
<Space size='small'>
<Button
icon={<ExclamationOctagonIcon />}
danger
onClick={handleEmergencyStop}
></Button>
<Button
icon={
printerData?.state?.type === 'paused' ? (
<PlayCircleIcon />
) : (
<PauseCircleIcon />
)
}
disabled={
!(
printerData?.state?.type == 'printing' ||
printerData?.state?.type == 'paused'
)
}
onClick={() => {
if (printerData?.state?.type === 'paused') {
printServer.emit('printer.print.resume', { printerId })
} else {
printServer.emit('printer.print.pause', { printerId })
}
}}
></Button>
<Button
icon={<PlayCircleIcon />}
disabled={
printerData?.state?.type === 'printing' ||
printerData?.state?.type === 'deploying' ||
printerData?.state?.type === 'paused' ||
printerData?.state?.type === 'error'
}
onClick={() => {
printServer.emit('server.job_queue.start', { printerId })
}}
></Button>
</Space>
</Flex>
<div style={{ height: '100%', overflow: 'auto' }}>
<Flex gap={'large'} wrap>
<Flex vertical style={{ flexGrow: 1 }} gap={'large'}>
<Flex gap={16} vertical style={{ flexGrow: 1 }}>
{printerData?.alerts?.some(
(alert) => alert.type === 'klippyError'
) && <Alert message={klippyErrorMessage} type='error' />}
{printerData?.alerts?.some(
(alert) => alert.type === 'klippyStartup'
) && <Alert message={klippyStartupMessage} type='warning' />}
</Flex>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.job ? ['1'] : []}
onChange={(keys) => updateCollapseState('job', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse no-t-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Current Job
</Title>
</Flex>
}
key='1'
>
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 1,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Printer Name'>
{printerData?.name ? (
<Text>{printerData.name}</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Printer ID'>
{printerData?._id ? (
<IdDisplay
id={printerData._id}
type='printer'
longId={false}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File Name'>
{printerData?.currentJob?.gcodeFile?.name ? (
<Space>
<GCodeFileIcon />
<Text ellipsis style={{ maxWidth: 200 }}>
{printerData.currentJob?.gcodeFile?.name}
</Text>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='GCode File ID'>
{printerData?.currentJob?.gcodeFile ? (
<IdDisplay
id={printerData.currentJob.gcodeFile.id}
type='gcodeFile'
longId={false}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Print Job ID'>
{printerData?.currentJob?.id ? (
<IdDisplay
id={printerData.currentJob.id}
type='job'
longId={false}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Sub Job ID'>
{printerData?.currentSubJob?.id ? (
<IdDisplay
id={printerData.currentSubJob.number
.toString()
.padStart(6, '0')}
type='subjob'
longId={false}
showHyperlink={false}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
{printerData?.state?.type === 'printing' && (
<>
<Descriptions.Item label='Progress' span={1}>
<Progress
percent={Math.round(
(printerData.state.progress || 0) * 100
)}
status='active'
/>
</Descriptions.Item>
<Descriptions.Item label='Started At' span={1}>
{printerData?.currentSubJob?.startedAt ? (
<TimeDisplay
dateTime={printerData.currentSubJob.startedAt}
showSince={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</>
)}
<Descriptions.Item label='Print Profile'>
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
?.printSettingsId ? (
<Text ellipsis style={{ maxWidth: 200 }}>
{printerData.currentJob.gcodeFile.gcodeFileInfo.printSettingsId.replaceAll(
'"',
''
)}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Est. Print Time'>
{printerData?.currentJob?.gcodeFile?.gcodeFileInfo
?.estimatedPrintingTimeNormalMode ? (
<Text ellipsis>
{
printerData.currentJob.gcodeFile.gcodeFileInfo
.estimatedPrintingTimeNormalMode
}
</Text>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.filament ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('filament', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Loaded Filament Stock
</Title>
</Flex>
}
key='1'
>
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 1,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Filament Stock' span={1}>
{printerData?.currentFilamentStock ? (
<FilamentStockState
filamentStock={printerData?.currentFilamentStock}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Stock ID'>
{printerData?.currentFilamentStock?._id ? (
<IdDisplay
id={printerData.currentFilamentStock._id}
type='filamentstock'
longId={false}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament Name'>
{printerData?.currentFilamentStock?.filament?.name ? (
<Space>
<FilamentIcon />
<Badge
text={
printerData.currentFilamentStock.filament.name
}
color={
printerData.currentFilamentStock.filament.color
}
></Badge>
</Space>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Filament ID'>
{printerData?.currentFilamentStock?.filament ? (
<IdDisplay
id={printerData.currentFilamentStock.filament._id}
type='filament'
longId={false}
showHyperlink={true}
/>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Weight'>
{printerData?.currentFilamentStock?.currentNetWeight ? (
<div>
<Descriptions
style={{ width: isMobile ? '100%' : '250px' }}
column={2}
size={'small'}
>
<Descriptions.Item label='Net'>
{printerData.currentFilamentStock.currentNetWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
<Descriptions.Item label='Gross'>
{printerData.currentFilamentStock.currentGrossWeight.toFixed(
2
) + 'g'}
</Descriptions.Item>
</Descriptions>
</div>
) : (
<Text>n/a</Text>
)}
</Descriptions.Item>
</Descriptions>
</Spin>
</Collapse.Panel>
</Collapse>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.jobs ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('jobs', keys.length > 0)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
)}
style={{ padding: 0 }}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Printer Jobs
</Title>
</Flex>
}
key='1'
>
<PrinterSubJobsTree
subJobs={printerData?.subJobs}
loading={fetchLoading}
/>
</Collapse.Panel>
</Collapse>
</Flex>
<Flex gap={'large'} wrap vertical>
{componentVisibility.temperature && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card>
<PrinterTemperaturePanel
printerId={printerId}
></PrinterTemperaturePanel>
</Card>
</Spin>
)}
{componentVisibility.position && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card>
<PrinterPositionPanel
printerId={printerId}
showControls={true}
showMoreInfo={true}
/>
</Card>
</Spin>
)}
{componentVisibility.movement && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card>
<PrinterMovementPanel
printerId={printerId}
></PrinterMovementPanel>
</Card>
</Spin>
)}
{componentVisibility.misc && (
<Spin
indicator={<LoadingOutlined spin />}
spinning={fetchLoading}
>
<Card>
<PrinterMiscPanel printerId={printerId} />
</Card>
</Spin>
)}
</Flex>
</Flex>
</div>
</Flex>
<Modal
open={loadFilamentStockModalOpen}
footer={null}
width={700}
onCancel={() => {
setLoadFilamentStockModalOpen(false)
}}
>
<LoadFilamentStock
onOk={() => {
setLoadFilamentStockModalOpen(false)
messageApi.success('New print job created successfully.')
}}
isFilamentLoaded={false}
printer={printerData}
reset={loadFilamentStockModalOpen}
/>
</Modal>
<Modal
open={unloadFilamentStockModalOpen}
footer={null}
width={700}
onCancel={() => {
setUnloadFilamentStockModalOpen(false)
}}
>
<UnloadFilamentStock
onOk={() => {
setUnloadFilamentStockModalOpen(false)
messageApi.success('Filament unloaded successfully.')
}}
printer={printerData}
reset={unloadFilamentStockModalOpen}
/>
</Modal>
<Modal
open={klippyErrorModalOpen}
title={
<Space size={'middle'}>
<ExclamationOctagonIcon />
Klipper Error
</Space>
}
onCancel={() => setKlippyErrorModalOpen(false)}
footer={[
<Button
key='close'
onClick={() => {
setKlippyErrorModalOpen(false)
}}
>
Close
</Button>,
<Button
key='firmwareRestart'
icon={<ReloadIcon />}
onClick={() => {
printServer.emit('printer.firmware_restart', { printerId })
setKlippyErrorModalOpen(false)
}}
>
Restart Firmware
</Button>
]}
>
<Typography.Paragraph>{klippyErrorMessage}</Typography.Paragraph>
</Modal>
</>
)
}
export default ControlPrinter