Compare commits

...

10 Commits

18 changed files with 361 additions and 120 deletions

View File

@ -314,3 +314,7 @@ body {
.ant-select-selection-item .ant-tag {
margin-left: 1px !important;
}
.ant-badge.ant-badge-status {
line-height: 18.5px;
}

View File

@ -68,12 +68,14 @@ const NewDocumentJob = ({ onOk, defaultValues = {} }) => {
}
onSubmit={async () => {
const newDocumentJob = await handleSubmit()
if (newDocumentJob.sendToFile == true) {
sendObjectAction(newDocumentJob._id, 'documentJob', {
type: 'print',
await sendObjectAction(
newDocumentJob.documentPrinter._id,
'documentPrinter',
{
type: 'deploy',
data: newDocumentJob
})
}
}
)
if (onOk) {
onOk()
}

View File

@ -22,6 +22,7 @@ import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import HostOTP from './HostOtp.jsx'
import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import PrinterIcon from '../../../Icons/PrinterIcon.jsx'
import DocumentPrinterIcon from '../../../Icons/DocumentPrinterIcon.jsx'
const log = loglevel.getLogger('HostInfo')
log.setLevel(config.logLevel)
@ -34,6 +35,7 @@ const HostInfo = () => {
const [collapseState, updateCollapseState] = useCollapseState('HostInfo', {
info: true,
printers: true,
documentPrinters: true,
notes: true,
auditLogs: true
})
@ -95,6 +97,7 @@ const HostInfo = () => {
items={[
{ key: 'info', label: 'Host Information' },
{ key: 'printers', label: 'Printers' },
{ key: 'documentPrinters', label: 'Document Printers' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
@ -149,7 +152,6 @@ const HostInfo = () => {
style={{ height: '100%' }}
ref={objectFormRef}
onStateChange={(state) => {
console.log('Got edit form state change', state)
setEditFormState((prev) => ({ ...prev, ...state }))
}}
>
@ -188,6 +190,28 @@ const HostInfo = () => {
/>
)}
</InfoCollapse>
<InfoCollapse
title='Document Printers'
icon={<DocumentPrinterIcon />}
active={collapseState.documentPrinters}
onToggle={(expanded) =>
updateCollapseState('documentPrinters', expanded)
}
collapseKey='documentPrinters'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='documentPrinter'
masterFilter={{ 'host._id': hostId }}
visibleColumns={{
host: false,
'host._id': false
}}
/>
)}
</InfoCollapse>
<InfoCollapse
title='Notes'

View File

@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react'
import { Select } from 'antd'
import PropTypes from 'prop-types'
const CustomSelect = ({
placeholder,
disabled,
options = [],
value,
onChange,
...rest
}) => {
const prevOptionsRef = useRef(options)
useEffect(() => {
// Check if options have changed
const optionsChanged =
JSON.stringify(prevOptionsRef.current) !== JSON.stringify(options)
if (optionsChanged && value !== undefined && value !== null) {
// Check if current value exists in new options
const valueExists =
Array.isArray(options) &&
options.some((option) => option.value === value)
// If value doesn't exist in new options, clear it
if (!valueExists && onChange) {
onChange(undefined)
}
}
// Update the ref with current options
prevOptionsRef.current = options
}, [options, value, onChange])
return (
<Select
placeholder={placeholder}
disabled={disabled}
options={Array.isArray(options) ? options : []}
value={value}
onChange={onChange}
{...rest}
/>
)
}
CustomSelect.propTypes = {
placeholder: PropTypes.string,
disabled: PropTypes.bool,
options: PropTypes.array,
value: PropTypes.any,
onChange: PropTypes.func
}
export default CustomSelect

View File

@ -30,13 +30,15 @@ import { getModelByName } from '../../../database/ObjectModels'
const ObjectForm = forwardRef(
({ id, type, style, children, onEdit, onStateChange }, ref) => {
const [objectData, setObjectData] = useState(null)
const [serverObjectData, setServerObjectData] = useState(null)
const serverObjectData = useRef(null)
const onStateChangeRef = useRef(onStateChange)
const [fetchLoading, setFetchLoading] = useState(true)
const [editLoading, setEditLoading] = useState(false)
const [lock, setLock] = useState({})
const [initialized, setInitialized] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [formValid, setFormValid] = useState(false)
const [form] = Form.useForm()
const formUpdateValues = Form.useWatch([], form)
const [messageApi, contextHolder] = message.useMessage()
@ -141,26 +143,32 @@ const ObjectForm = forwardRef(
// Validate form on change (debounced to avoid heavy work on every keystroke)
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentFormValues = form.getFieldsValue()
const mergedObjectData = {
...serverObjectData.current,
...currentFormValues
}
form
.validateFields({ validateOnly: true })
.then(() => {
setFormValid(true)
onStateChange({
onStateChangeRef.current({
formValid: true,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
objectData: mergedObjectData
})
})
.catch(() => {
setFormValid(false)
onStateChange({
onStateChangeRef.current({
formValid: false,
objectData: { ...serverObjectData, ...form.getFieldsValue() }
objectData: mergedObjectData
})
})
}, 150)
return () => clearTimeout(timeoutId)
}, [form, formUpdateValues, onStateChange, serverObjectData])
}, [form, formUpdateValues])
// Cleanup on unmount
useEffect(() => {
@ -185,13 +193,13 @@ const ObjectForm = forwardRef(
const handleFetchObject = useCallback(async () => {
try {
setFetchLoading(true)
onStateChange({ loading: true })
onStateChangeRef.current({ loading: true })
const data = await fetchObject(id, type)
const lockEvent = await fetchObjectLock(id, type)
setLock(lockEvent)
onStateChange({ lock: lockEvent })
onStateChangeRef.current({ lock: lockEvent })
setObjectData(data)
setServerObjectData(data)
serverObjectData.current = data
// Calculate and set computed values on initial load
const computedValues = calculateComputedValues(data, model)
@ -199,7 +207,7 @@ const ObjectForm = forwardRef(
form.setFieldsValue(initialFormData)
setFetchLoading(false)
onStateChange({ loading: false })
onStateChangeRef.current({ loading: false })
} catch (err) {
console.error(err)
messageApi.error('Failed to fetch object info')
@ -218,7 +226,7 @@ const ObjectForm = forwardRef(
// Update event handler
const updateLockEventHandler = useCallback((value) => {
setLock((prev) => {
onStateChange({ lock: { ...prev, ...value } })
onStateChangeRef.current({ lock: { ...prev, ...value } })
return { ...prev, ...value }
})
}, [])
@ -260,29 +268,31 @@ const ObjectForm = forwardRef(
// Debounce objectData updates sent to parent to limit re-renders
useEffect(() => {
const timeoutId = setTimeout(() => {
onStateChange({ objectData })
onStateChangeRef.current({ objectData })
}, 150)
return () => clearTimeout(timeoutId)
}, [objectData, onStateChange])
}, [objectData])
const startEditing = () => {
setIsEditing(true)
onStateChange({ isEditing: true })
onStateChangeRef.current({ isEditing: true })
lockObject(id, type)
}
const cancelEditing = () => {
if (serverObjectData) {
if (serverObjectData.current) {
// Recalculate computed values when canceling
const computedValues = calculateComputedValues(serverObjectData, model)
const resetFormData = { ...serverObjectData, ...computedValues }
const computedValues = calculateComputedValues(
serverObjectData.current,
model
)
const resetFormData = { ...serverObjectData.current, ...computedValues }
form.setFieldsValue(resetFormData)
setObjectData(resetFormData)
}
setIsEditing(false)
onStateChange({ isEditing: false })
onStateChangeRef.current({ isEditing: false })
unlockObject(id, type)
}
@ -290,11 +300,11 @@ const ObjectForm = forwardRef(
try {
const value = await form.validateFields()
setEditLoading(true)
onStateChange({ editLoading: true })
onStateChangeRef.current({ editLoading: true })
await updateObject(id, type, value)
setObjectData({ ...objectData, ...value })
setIsEditing(false)
onStateChange({ isEditing: false })
onStateChangeRef.current({ isEditing: false })
messageApi.success('Information updated successfully')
} catch (err) {
console.error(err)
@ -309,7 +319,7 @@ const ObjectForm = forwardRef(
} finally {
handleFetchObject()
setEditLoading(false)
onStateChange({ editLoading: false })
onStateChangeRef.current({ editLoading: false })
}
}

View File

@ -1,8 +1,10 @@
import { Spin, Descriptions, Flex } from 'antd'
import { useState, useEffect } from 'react'
import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty'
import { getModelProperties } from '../../../database/ObjectModels'
import merge from 'lodash/merge'
const ObjectInfo = ({
loading = false,
@ -27,6 +29,12 @@ const ObjectInfo = ({
}) => {
const allItems = getModelProperties(type)
const [combinedObjectData, setCombinedObjectData] = useState(objectData)
useEffect(() => {
setCombinedObjectData((prev) => merge({}, prev, objectData))
}, [objectData])
// If properties array is empty, show all properties
// Otherwise, filter and order by the properties array
let items
@ -82,7 +90,8 @@ const ObjectInfo = ({
{...item}
{...objectPropertyProps}
isEditing={isEditing}
objectData={objectData}
objectData={combinedObjectData}
showSince={true}
/>
),
span: item?.span || undefined

View File

@ -37,6 +37,7 @@ import ObjectDisplay from './ObjectDisplay'
import ObjectTypeSelect from './ObjectTypeSelect'
import ObjectTypeDisplay from './ObjectTypeDisplay'
import CodeBlockEditor from './CodeBlockEditor'
import CustomSelect from './CustomSelect'
import StateDisplay from './StateDisplay'
import AlertsDisplay from './AlertsDisplay'
import FileUpload from './FileUpload'
@ -84,6 +85,7 @@ const ObjectProperty = ({
options = [],
roundNumber = false,
showHyperlink,
showSince,
...rest
}) => {
if (value && typeof value == 'function' && objectData) {
@ -117,6 +119,9 @@ const ObjectProperty = ({
if (masterFilter && typeof masterFilter == 'function' && objectData) {
masterFilter = masterFilter(objectData)
}
if (options && typeof options == 'function' && objectData) {
options = options(objectData)
}
if (!value) {
value = getPropertyValue(objectData, name)
@ -169,8 +174,9 @@ const ObjectProperty = ({
)
}
case 'select': {
const selectValue = options.find((option) => option.value === value)
if (selectValue) {
if (options && Array.isArray(options)) {
const selectValue =
options.find((option) => option.value === value) || 'n/a'
return <Text {...textParams}>{selectValue.label}</Text>
} else {
return (
@ -206,7 +212,9 @@ const ObjectProperty = ({
}
case 'dateTime': {
if (value != null) {
return <TimeDisplay dateTime={value} {...rest} />
return (
<TimeDisplay dateTime={value} showSince={showSince} {...rest} />
)
} else {
return (
<Text type='secondary' {...textParams}>
@ -249,7 +257,7 @@ const ObjectProperty = ({
)
} else {
var roundedValue = value
if (roundNumber != false) {
if (roundNumber != false && typeof value === 'number') {
roundedValue = value.toFixed(roundNumber)
}
@ -508,7 +516,10 @@ const ObjectProperty = ({
// Editable mode: wrap in Form.Item
// Merge required rule if needed
let mergedFormItemProps = { ...formItemProps, style: { flexGrow: 1 } }
let mergedFormItemProps = {
...formItemProps,
style: { flexGrow: 1, width: '100%' }
}
if (required && disabled == false) {
let rules
if (mergedFormItemProps.rules) {
@ -575,11 +586,10 @@ const ObjectProperty = ({
case 'select':
return (
<Form.Item name={formItemName} {...mergedFormItemProps}>
<Select
defaultValue={value}
<CustomSelect
placeholder={'Select a ' + label.toLowerCase() + '...'}
disabled={disabled}
options={options}
options={Array.isArray(options) ? options : []}
/>
</Form.Item>
)
@ -804,7 +814,8 @@ ObjectProperty.propTypes = {
previewOpen: PropTypes.bool,
showPreview: PropTypes.bool,
showHyperlink: PropTypes.bool,
options: PropTypes.array
options: PropTypes.array,
showSince: PropTypes.bool
}
export default ObjectProperty

View File

@ -1,9 +1,18 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { Progress, Flex, Space } from 'antd'
import { Progress, Flex, Space, Modal, Button, Typography } from 'antd'
import StateTag from './StateTag'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { useState } from 'react'
const StateDisplay = ({ state, showProgress = true, showState = true }) => {
const { Text } = Typography
const StateDisplay = ({
state,
showProgress = true,
showState = true,
showMessage = true
}) => {
const loadingProgressTypes = [
'loading',
'processing',
@ -15,33 +24,54 @@ const StateDisplay = ({ state, showProgress = true, showState = true }) => {
type: 'unknown',
progress: 0
}
const [showMessageModal, setShowMessageModal] = useState(false)
return (
<Flex gap='small' align={'center'}>
{showState && (
<Space>
<StateTag state={currentState.type} />
</Space>
)}
{showProgress &&
loadingProgressTypes.includes(currentState.type) &&
currentState?.progress &&
currentState?.progress > 0 ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status={currentState.type === 'used' ? '' : 'active'}
strokeColor={currentState.type === 'used' ? 'orange' : ''}
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
</Flex>
<>
<Flex gap='small' align={'center'}>
{showState && (
<Space>
<StateTag state={currentState.type} />
</Space>
)}
{showProgress &&
loadingProgressTypes.includes(currentState.type) &&
currentState?.progress &&
currentState?.progress > 0 ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status={currentState.type === 'used' ? '' : 'active'}
strokeColor={currentState.type === 'used' ? 'orange' : ''}
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
{showMessage && currentState?.message && (
<Button
type='text'
size='small'
style={{ padding: '4px' }}
onClick={() => setShowMessageModal(true)}
>
<InfoCircleIcon />
</Button>
)}
</Flex>
<Modal
open={showMessageModal}
onCancel={() => setShowMessageModal(false)}
footer={null}
>
<Text>{currentState.message}</Text>
</Modal>
</>
)
}
StateDisplay.propTypes = {
state: PropTypes.object,
showProgress: PropTypes.bool,
showState: PropTypes.bool
showState: PropTypes.bool,
showMessage: PropTypes.bool
}
export default StateDisplay

View File

@ -32,6 +32,14 @@ const StateTag = ({ state, showBadge = true, style = {} }) => {
status = 'warning'
text = 'Initializing'
break
case 'connecting':
status = 'warning'
text = 'Connecting'
break
case 'deploying':
status = 'warning'
text = 'Deploying'
break
case 'printing':
status = 'processing'
text = 'Printing'

View File

@ -337,8 +337,6 @@ const ApiServerProvider = ({ children }) => {
const callbacks = subscribedCallbacksRef.current
.get(objectType)
.filter((cb) => cb !== callback)
console.log('API: CALLBACKS', callbacks)
if (callbacks.length === 0) {
subscribedCallbacksRef.current.delete(objectType)
socketRef.current.emit('unsubscribeObjectTypeUpdate', {

View File

@ -613,7 +613,7 @@ const AuthProvider = ({ children }) => {
<Button
key='submit'
onClick={() => {
showAuthErrorModal(false)
setShowAuthErrorModal(false)
loginWithSSO()
}}
>

View File

@ -14,7 +14,8 @@ import {
useEffect,
useState,
useRef,
createElement
createElement,
useContext
} from 'react'
import axios from 'axios'
import { LoadingOutlined } from '@ant-design/icons'
@ -29,6 +30,8 @@ import {
getModelByPrefix
} from '../../../database/ObjectModels'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext'
const SpotlightContext = createContext()
@ -41,6 +44,8 @@ const SpotlightProvider = ({ children }) => {
const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null })
const { fetchSpotlightData } = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
// Refs for throttling/debouncing
const lastFetchTime = useRef(0)
@ -116,7 +121,7 @@ const SpotlightProvider = ({ children }) => {
setLoading(true)
setListData([])
let response
let data
// Check if we have a prefix with ? mode (filter mode)
if (inputPrefix && inputPrefix.mode === '?') {
@ -128,37 +133,34 @@ const SpotlightProvider = ({ children }) => {
// Parse the query parameters
const params = new URLSearchParams(queryParams)
response = await axios.get(`${config.backendUrl}/spotlight/${prefix}`, {
params: params,
headers: {
Accept: 'application/json'
},
withCredentials: true
})
} else {
// For other modes (:, ^), use the original behavior
response = await axios.get(
`${config.backendUrl}/spotlight/${encodeURIComponent(searchQuery.trim())}`,
// For ? mode, use axios directly with query params
const response = await axios.get(
`${config.backendUrl}/spotlight/${prefix}`,
{
params: params,
headers: {
Accept: 'application/json'
},
withCredentials: true
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
)
data = response.data
} else {
// For other modes (:, ^), use fetchSpotlightData from ApiServerContext
data = await fetchSpotlightData(searchQuery.trim())
}
setLoading(false)
// If the query contains a prefix mode character, and the response is an object, wrap it in an array
if (
/[:?^]/.test(searchQuery) &&
response.data &&
!Array.isArray(response.data) &&
typeof response.data === 'object'
data &&
!Array.isArray(data) &&
typeof data === 'object'
) {
setListData([response.data])
setListData([data])
} else {
setListData(response.data)
setListData(data)
}
// Check if there's a pending query after this fetch completes
@ -464,11 +466,22 @@ const SpotlightProvider = ({ children }) => {
align='center'
justify='space-between'
>
<Flex gap={'small'} align='center'>
<Flex
gap={'small'}
align='center'
style={{ flexGrow: 1 }}
>
{Icon ? <Icon style={{ fontSize: '20px' }} /> : null}
{item.name ? (
<Text ellipsis style={{ maxWidth: 170 }}>
<Text
ellipsis
style={{
maxWidth: 170,
flexGrow: 1,
width: '100%'
}}
>
{item.name}
</Text>
) : null}

View File

@ -90,7 +90,7 @@ export const ThemeProvider = ({ children }) => {
colorWarning: '#FF9F0A',
colorInfo: '#0A84FF',
colorLink: '#5AC8F5',
borderRadius: '10px'
borderRadius: '12px'
},
components: {
Layout: {

View File

@ -36,9 +36,9 @@ export const DocumentJob = {
`/dashboard/management/documentjobs/info?documentJobId=${_id}&action=edit`
}
],
columns: ['name', '_id', 'width', 'height', 'createdAt', 'updatedAt'],
filters: ['name', '_id', 'width', 'height'],
sorters: ['name', 'width', 'height', 'createdAt', 'updatedAt'],
columns: ['name', '_id', 'state', 'createdAt', 'updatedAt'],
filters: ['name', '_id', 'state'],
sorters: ['name', 'state', 'createdAt', 'updatedAt'],
properties: [
{
name: '_id',

View File

@ -38,13 +38,15 @@ export const DocumentPrinter = {
columns: [
'name',
'_id',
'documentSize',
'documentSize._id',
'createdAt',
'state',
'host',
'host._id',
'tags',
'connectedAt',
'updatedAt'
],
filters: ['name', '_id'],
sorters: ['name', 'documentSize', 'createdAt', 'updatedAt'],
sorters: ['name', 'documentSize', 'connectedAt', 'updatedAt'],
properties: [
{
name: '_id',
@ -82,10 +84,10 @@ export const DocumentPrinter = {
readOnly: true
},
{
name: 'active',
label: 'Active',
type: 'bool',
required: true
name: 'connectedAt',
label: 'Connected At',
type: 'dateTime',
readOnly: true
},
{
name: 'online',
@ -93,6 +95,12 @@ export const DocumentPrinter = {
type: 'bool',
readOnly: true
},
{
name: 'active',
label: 'Active',
type: 'bool',
required: true
},
{
name: 'host',
label: 'Host',
@ -109,16 +117,6 @@ export const DocumentPrinter = {
showCopy: true,
showHyperlink: true
},
{
name: 'connection.mode',
label: 'Mode',
type: 'select',
options: [
{ label: 'Network', value: 'network' },
{ label: 'Serial', value: 'serial' }
],
required: true
},
{
name: 'connection.interface',
label: 'Interface',
@ -130,12 +128,43 @@ export const DocumentPrinter = {
],
required: true
},
{
name: 'connection.protocol',
label: 'Protocol',
type: 'select',
options: (objectData) => {
if (objectData?.connection?.interface == 'cups') {
return [
{ label: 'IPP', value: 'ipp' },
{ label: 'HTTP', value: 'http' }
]
}
return [
{ label: 'TCP', value: 'tcp' },
{ label: 'Serial', value: 'serial' },
{ label: 'System', value: 'system' }
]
},
required: true
},
{
name: 'connection.host',
label: 'Connection String',
label: 'Host Name',
type: 'text',
required: true
},
{
name: 'connection.port',
label: 'Port',
type: 'number',
required: false,
disabled: (objectData) => {
return (
objectData?.connection?.protocol == 'system' ||
objectData?.connection?.protocol == 'serial'
)
}
},
{
name: 'currentDocumentSize',
label: 'Current Document Size',

View File

@ -35,9 +35,24 @@ export const DocumentSize = {
`/dashboard/management/documentsizes/info?documentSizeId=${_id}&action=edit`
}
],
columns: ['name', '_id', 'width', 'height', 'createdAt', 'updatedAt'],
filters: ['name', '_id', 'width', 'height'],
sorters: ['name', 'width', 'height', 'createdAt', 'updatedAt'],
columns: [
'name',
'_id',
'width',
'height',
'infiniteHeight',
'createdAt',
'updatedAt'
],
filters: ['name', '_id', 'width', 'height', 'infiniteHeight'],
sorters: [
'name',
'width',
'height',
'infiniteHeight',
'createdAt',
'updatedAt'
],
properties: [
{
name: '_id',
@ -80,7 +95,17 @@ export const DocumentSize = {
required: true,
columnWidth: 150,
type: 'number',
suffix: 'mm'
suffix: 'mm',
disabled: (objectData) => {
return objectData.infiniteHeight
}
},
{
name: 'infiniteHeight',
label: 'Infinite Height',
required: true,
columnWidth: 150,
type: 'bool'
}
]
}

View File

@ -96,6 +96,7 @@ export const File = {
label: 'Size',
type: 'number',
readOnly: true,
roundNumber: 2,
required: true,
suffix: (objectData) => {
const size = objectData?.size || 0

View File

@ -167,7 +167,16 @@ export const Printer = {
]
}
],
columns: ['name', '_id', 'state', 'tags', 'connectedAt'],
columns: [
'name',
'_id',
'state',
'host',
'host._id',
'tags',
'connectedAt',
'updatedAt'
],
filters: ['name', '_id', 'state', 'tags'],
sorters: ['name', 'state', 'connectedAt'],
group: ['tags'],
@ -180,8 +189,8 @@ export const Printer = {
showCopy: true
},
{
name: 'connectedAt',
label: 'Connected At',
name: 'createdAt',
label: 'Created At',
type: 'dateTime',
readOnly: true
},
@ -193,6 +202,12 @@ export const Printer = {
columnWidth: 200,
columnFixed: 'left'
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{
name: 'state',
label: 'Status',
@ -202,10 +217,10 @@ export const Printer = {
readOnly: true
},
{
name: 'active',
label: 'Active',
type: 'bool',
required: true
name: 'connectedAt',
label: 'Connected At',
type: 'dateTime',
readOnly: true
},
{
name: 'online',
@ -213,6 +228,12 @@ export const Printer = {
type: 'bool',
readOnly: true
},
{
name: 'active',
label: 'Active',
type: 'bool',
required: true
},
{
name: 'vendor',
label: 'Vendor',