Overhauled printer overview page (now working, with charts and realtime updates to current state)
This commit is contained in:
parent
aaeeb4013e
commit
98ca73791f
@ -1,179 +1,51 @@
|
|||||||
import { useEffect, useState, useCallback, useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
import {
|
import { Flex } from 'antd'
|
||||||
Descriptions,
|
|
||||||
Space,
|
|
||||||
Flex,
|
|
||||||
Alert,
|
|
||||||
Typography,
|
|
||||||
Spin,
|
|
||||||
message,
|
|
||||||
Button,
|
|
||||||
Collapse,
|
|
||||||
Segmented,
|
|
||||||
Card
|
|
||||||
} from 'antd'
|
|
||||||
import { LoadingOutlined, CaretLeftOutlined } from '@ant-design/icons'
|
|
||||||
import { Line } from '@ant-design/charts'
|
|
||||||
import axios from 'axios'
|
|
||||||
import PrinterIcon from '../../Icons/PrinterIcon'
|
|
||||||
import JobIcon from '../../Icons/JobIcon'
|
|
||||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
|
||||||
import useCollapseState from '../hooks/useCollapseState'
|
import useCollapseState from '../hooks/useCollapseState'
|
||||||
|
import StatsDisplay from '../common/StatsDisplay'
|
||||||
|
import HistoryDisplay from '../common/HistoryDisplay'
|
||||||
|
import InfoCollapse from '../common/InfoCollapse'
|
||||||
|
import ScrollBox from '../common/ScrollBox'
|
||||||
|
|
||||||
import config from '../../../config'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
|
||||||
|
|
||||||
const ProductionOverview = () => {
|
const ProductionOverview = () => {
|
||||||
const { token } = useContext(AuthContext)
|
const { connected } = useContext(ApiServerContext)
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
|
|
||||||
const [chartData, setChartData] = useState([])
|
|
||||||
const [collapseState, updateCollapseState] = useCollapseState(
|
const [collapseState, updateCollapseState] = useCollapseState(
|
||||||
'ProductionOverview',
|
'ProductionOverview',
|
||||||
{
|
{
|
||||||
overview: true,
|
|
||||||
printerStats: true,
|
printerStats: true,
|
||||||
jobStats: true
|
printerHistory: true,
|
||||||
|
jobStats: true,
|
||||||
|
productionStats: true,
|
||||||
|
jobStatsDetails: true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const [timeRanges, setTimeRanges] = useState({
|
|
||||||
overview: '24h',
|
|
||||||
printerStats: '24h',
|
|
||||||
jobStats: '24h'
|
|
||||||
})
|
|
||||||
|
|
||||||
const [stats, setStats] = useState({
|
if (!connected) {
|
||||||
totalPrinters: 0,
|
return null
|
||||||
activePrinters: 0,
|
|
||||||
totalJobs: 0,
|
|
||||||
activeJobs: 0,
|
|
||||||
completedJobs: 0,
|
|
||||||
printerStatus: {
|
|
||||||
idle: 0,
|
|
||||||
printing: 0,
|
|
||||||
error: 0,
|
|
||||||
offline: 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchAllStats = useCallback(async () => {
|
|
||||||
await fetchPrinterStats()
|
|
||||||
await fetchJobstats()
|
|
||||||
await fetchChartData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchPrinterStats = async () => {
|
|
||||||
try {
|
|
||||||
setFetchPrinterStatsLoading(true)
|
|
||||||
const response = await axios.get(`${config.backendUrl}/printers/stats`, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const printStats = response.data
|
|
||||||
setStats((prev) => ({ ...prev, printers: printStats }))
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Failed to fetch printer details')
|
|
||||||
messageApi.error('Failed to fetch printer details')
|
|
||||||
} finally {
|
|
||||||
setFetchPrinterStatsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchJobstats = async () => {
|
|
||||||
try {
|
|
||||||
setFetchPrinterStatsLoading(true)
|
|
||||||
const response = await axios.get(`${config.backendUrl}/jobs/stats`, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const jobstats = response.data
|
|
||||||
setStats((prev) => ({ ...prev, jobs: jobstats }))
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setError('Failed to fetch printer details')
|
|
||||||
messageApi.error('Failed to fetch printer details')
|
|
||||||
} finally {
|
|
||||||
setFetchPrinterStatsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchChartData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${config.backendUrl}/stats/history`, {
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setChartData(response.data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
console.error('Failed to fetch chart data:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (token != null) {
|
|
||||||
fetchAllStats()
|
|
||||||
}
|
|
||||||
}, [fetchAllStats, token])
|
|
||||||
|
|
||||||
if (fetchPrinterStatsLoading || fetchPrinterStatsLoading) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
||||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !stats) {
|
|
||||||
return (
|
|
||||||
<Space
|
|
||||||
direction='vertical'
|
|
||||||
style={{ width: '100%', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
<p>{error || 'Printer not found'}</p>
|
|
||||||
<Button icon={<ReloadIcon />} onClick={fetchAllStats}>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
<Flex
|
||||||
{contextHolder}
|
gap='large'
|
||||||
<Flex gap='large' vertical>
|
vertical='true'
|
||||||
<Collapse
|
style={{
|
||||||
ghost
|
maxHeight: '100%',
|
||||||
expandIconPosition='end'
|
minHeight: 0
|
||||||
activeKey={collapseState.overview ? ['1'] : []}
|
}}
|
||||||
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
|
>
|
||||||
expandIcon={({ isActive }) => (
|
<ScrollBox>
|
||||||
<CaretLeftOutlined
|
<Flex vertical gap={'large'}>
|
||||||
rotate={isActive ? 90 : 0}
|
<InfoCollapse
|
||||||
style={{ paddingTop: '2px' }}
|
title='Printer Statistics'
|
||||||
/>
|
icon={null}
|
||||||
)}
|
active={collapseState.printerStats}
|
||||||
className='no-h-padding-collapse no-t-padding-collapse'
|
onToggle={(isActive) =>
|
||||||
>
|
updateCollapseState('printerStats', isActive)
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
|
||||||
Status Overview
|
|
||||||
</Title>
|
|
||||||
}
|
}
|
||||||
key='1'
|
className='no-t-padding-collapse'
|
||||||
|
collapseKey='printerStats'
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
justify='flex-start'
|
justify='flex-start'
|
||||||
@ -181,268 +53,61 @@ const ProductionOverview = () => {
|
|||||||
wrap='wrap'
|
wrap='wrap'
|
||||||
align='flex-start'
|
align='flex-start'
|
||||||
>
|
>
|
||||||
<Alert
|
<StatsDisplay objectType='printer' />
|
||||||
type='success'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Ready</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<PrinterIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{(stats.printers.standby || 0) +
|
|
||||||
(stats.printers.complete || 0)}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
type='info'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Printing</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<PrinterIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{stats.printers.printing || 0}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
type='warning'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Queued</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<JobIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{stats.jobs.queued || 0}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
type='info'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Printing</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<JobIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{stats.jobs.printing || 0}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
type='error'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Failed</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<JobIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{(stats.jobs.failed || 0) + (stats.jobs.cancelled || 0)}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Alert
|
|
||||||
type='success'
|
|
||||||
style={{ minWidth: 150 }}
|
|
||||||
description={
|
|
||||||
<Flex vertical>
|
|
||||||
<Text type='secondary'>Complete</Text>
|
|
||||||
<Flex gap={'small'}>
|
|
||||||
<JobIcon style={{ fontSize: 26 }} />
|
|
||||||
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
|
||||||
{stats.jobs.complete || 0}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Collapse.Panel>
|
</InfoCollapse>
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Flex gap='large' wrap='wrap'>
|
<InfoCollapse
|
||||||
<Flex flex={1} vertical>
|
title='Job Statistics'
|
||||||
<Collapse
|
icon={null}
|
||||||
ghost
|
active={collapseState.jobStats}
|
||||||
expandIconPosition='end'
|
onToggle={(isActive) => updateCollapseState('jobStats', isActive)}
|
||||||
activeKey={collapseState.printerStats ? ['2'] : []}
|
className='no-t-padding-collapse'
|
||||||
onChange={(keys) =>
|
collapseKey='jobStats'
|
||||||
updateCollapseState('printerStats', keys.length > 0)
|
>
|
||||||
}
|
<Flex
|
||||||
expandIcon={({ isActive }) => (
|
justify='flex-start'
|
||||||
<CaretLeftOutlined
|
gap='middle'
|
||||||
rotate={isActive ? 90 : 0}
|
wrap='wrap'
|
||||||
style={{ paddingTop: '2px' }}
|
align='flex-start'
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse'
|
|
||||||
>
|
>
|
||||||
<Collapse.Panel
|
<StatsDisplay objectType='job' />
|
||||||
header={
|
</Flex>
|
||||||
<Flex
|
</InfoCollapse>
|
||||||
align='center'
|
|
||||||
justify='space-between'
|
<Flex gap='large' wrap='wrap'>
|
||||||
style={{ width: '100%' }}
|
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
||||||
>
|
<InfoCollapse
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
title='Printer History'
|
||||||
Production Statistics
|
icon={null}
|
||||||
</Title>
|
active={collapseState.printerHistory}
|
||||||
<Segmented
|
onToggle={(isActive) =>
|
||||||
options={['4h', '8h', '12h', '24h']}
|
updateCollapseState('printerHistory', isActive)
|
||||||
value={timeRanges.printerStats}
|
|
||||||
onChange={(value) =>
|
|
||||||
setTimeRanges((prev) => ({
|
|
||||||
...prev,
|
|
||||||
printerStats: value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
}
|
}
|
||||||
key='2'
|
collapseKey='printerHistory'
|
||||||
|
canCollapse={false}
|
||||||
>
|
>
|
||||||
<Flex vertical gap={'middle'}>
|
<HistoryDisplay objectType='printer' />
|
||||||
<Card style={{ height: 250, width: '100%' }}>
|
</InfoCollapse>
|
||||||
<Line
|
</Flex>
|
||||||
data={chartData}
|
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
||||||
xField='timestamp'
|
<InfoCollapse
|
||||||
yField='value'
|
title='Job History'
|
||||||
seriesField='type'
|
icon={null}
|
||||||
smooth
|
active={collapseState.jobHistory}
|
||||||
animation={{
|
onToggle={(isActive) =>
|
||||||
appear: {
|
updateCollapseState('jobHistory', isActive)
|
||||||
animation: 'wave-in',
|
|
||||||
duration: 1000
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
point={{
|
|
||||||
size: 4,
|
|
||||||
shape: 'circle'
|
|
||||||
}}
|
|
||||||
tooltip={{
|
|
||||||
showMarkers: false
|
|
||||||
}}
|
|
||||||
legend={{
|
|
||||||
position: 'top'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<Descriptions column={1} bordered>
|
|
||||||
<Descriptions.Item label='Completed'>
|
|
||||||
{stats.totalPrinters}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label='Error'>
|
|
||||||
{stats.activePrinters}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label='Paused'>
|
|
||||||
{stats.activePrinters}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Flex>
|
|
||||||
</Collapse.Panel>
|
|
||||||
</Collapse>
|
|
||||||
</Flex>
|
|
||||||
<Flex flex={1} vertical>
|
|
||||||
<Collapse
|
|
||||||
ghost
|
|
||||||
expandIconPosition='end'
|
|
||||||
activeKey={collapseState.jobStats ? ['3'] : []}
|
|
||||||
onChange={(keys) =>
|
|
||||||
updateCollapseState('jobStats', keys.length > 0)
|
|
||||||
}
|
|
||||||
expandIcon={({ isActive }) => (
|
|
||||||
<CaretLeftOutlined
|
|
||||||
rotate={isActive ? 90 : 0}
|
|
||||||
style={{ paddingTop: '2px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
className='no-h-padding-collapse'
|
|
||||||
>
|
|
||||||
<Collapse.Panel
|
|
||||||
header={
|
|
||||||
<Flex
|
|
||||||
align='center'
|
|
||||||
justify='space-between'
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
|
||||||
Job Statistics
|
|
||||||
</Title>
|
|
||||||
<Segmented
|
|
||||||
options={['4h', '8h', '12h', '24h']}
|
|
||||||
value={timeRanges.jobStats}
|
|
||||||
onChange={(value) =>
|
|
||||||
setTimeRanges((prev) => ({ ...prev, jobStats: value }))
|
|
||||||
}
|
|
||||||
size='small'
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
}
|
}
|
||||||
key='3'
|
canCollapse={false}
|
||||||
|
collapseKey='jobHistory'
|
||||||
>
|
>
|
||||||
<Flex vertical gap={'middle'}>
|
<HistoryDisplay objectType='job' />
|
||||||
<Card
|
</InfoCollapse>
|
||||||
style={{ height: 250, width: '100%', minWidth: '300px' }}
|
</Flex>
|
||||||
>
|
|
||||||
<Line
|
|
||||||
data={chartData}
|
|
||||||
xField='timestamp'
|
|
||||||
yField='value'
|
|
||||||
seriesField='type'
|
|
||||||
smooth
|
|
||||||
animation={{
|
|
||||||
appear: {
|
|
||||||
animation: 'wave-in',
|
|
||||||
duration: 1000
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
point={{
|
|
||||||
size: 4,
|
|
||||||
shape: 'circle'
|
|
||||||
}}
|
|
||||||
tooltip={{
|
|
||||||
showMarkers: false
|
|
||||||
}}
|
|
||||||
legend={{
|
|
||||||
position: 'top'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<Descriptions column={1} bordered>
|
|
||||||
<Descriptions.Item label='Completed'>
|
|
||||||
{stats.totalJobs}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label='Failed'>
|
|
||||||
{stats.activeJobs}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label='Queued'>
|
|
||||||
{stats.completedJobs}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Flex>
|
|
||||||
</Collapse.Panel>
|
|
||||||
</Collapse>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</ScrollBox>
|
||||||
</div>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
323
src/components/Dashboard/common/HistoryDisplay.jsx
Normal file
323
src/components/Dashboard/common/HistoryDisplay.jsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
import { useEffect, useState, useContext, useMemo } from 'react'
|
||||||
|
import { Card, Spin, Segmented, Flex } from 'antd'
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons'
|
||||||
|
import { Column } from '@ant-design/charts'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useThemeContext } from '../context/ThemeContext'
|
||||||
|
|
||||||
|
const HistoryDisplay = ({ objectType, startDate, endDate, styles }) => {
|
||||||
|
const { getModelHistory, connected } = useContext(ApiServerContext)
|
||||||
|
const { token } = useContext(AuthContext)
|
||||||
|
const [historyData, setHistoryData] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [timeRange, setTimeRange] = useState('4hrs')
|
||||||
|
const { isDarkMode, getColors } = useThemeContext()
|
||||||
|
// Calculate dates based on selected time range or provided props
|
||||||
|
const { defaultStartDate, defaultEndDate } = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// If startDate/endDate props are provided, use them (ignore time range)
|
||||||
|
if (startDate || endDate) {
|
||||||
|
return {
|
||||||
|
defaultStartDate:
|
||||||
|
startDate || new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
defaultEndDate: endDate || now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate based on selected time range
|
||||||
|
const hoursMap = {
|
||||||
|
'8hrs': 8,
|
||||||
|
'12hrs': 12,
|
||||||
|
'24hrs': 24,
|
||||||
|
'4hrs': 4,
|
||||||
|
'1hrs': 1
|
||||||
|
}
|
||||||
|
const hours = hoursMap[timeRange] || 1
|
||||||
|
return {
|
||||||
|
defaultStartDate: new Date(now.getTime() - hours * 60 * 60 * 1000),
|
||||||
|
defaultEndDate: now
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, timeRange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!objectType || !getModelHistory || !token || !connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const fetchedHistory = await getModelHistory(
|
||||||
|
objectType,
|
||||||
|
defaultStartDate,
|
||||||
|
defaultEndDate
|
||||||
|
)
|
||||||
|
if (fetchedHistory) {
|
||||||
|
setHistoryData(fetchedHistory)
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch history')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching history:', err)
|
||||||
|
setError('Failed to fetch history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchHistory()
|
||||||
|
}, [
|
||||||
|
objectType,
|
||||||
|
getModelHistory,
|
||||||
|
token,
|
||||||
|
connected,
|
||||||
|
defaultStartDate,
|
||||||
|
defaultEndDate
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!objectType) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = getModelByName(objectType)
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelStats = model.stats || []
|
||||||
|
|
||||||
|
if (modelStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !Array.isArray(historyData)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the stat key from history data based on stat definition name
|
||||||
|
* e.g., "states.standby.count" -> "standby", "standbyPrinters" -> "standby"
|
||||||
|
* The history data keys are the rollup names (like "standby", "printing")
|
||||||
|
*/
|
||||||
|
const extractStatKey = (statName) => {
|
||||||
|
// Handle dot notation paths like "states.standby.count"
|
||||||
|
if (statName.includes('.')) {
|
||||||
|
const parts = statName.split('.')
|
||||||
|
// Extract the middle part (e.g., "standby" from "states.standby.count")
|
||||||
|
// Skip first part (usually "states") and last part (usually "count")
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts[parts.length - 2] // Get second-to-last part
|
||||||
|
}
|
||||||
|
return parts[parts.length - 1] // Fallback to last part
|
||||||
|
}
|
||||||
|
// Remove common suffixes
|
||||||
|
return statName
|
||||||
|
.replace(/Printers?$/i, '')
|
||||||
|
.replace(/Jobs?$/i, '')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a nested value from an object using dot notation
|
||||||
|
*/
|
||||||
|
const getNestedValue = (obj, path) => {
|
||||||
|
if (!obj || !path) return undefined
|
||||||
|
const keys = path.split('.')
|
||||||
|
let value = obj
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value === null || value === undefined) return undefined
|
||||||
|
value = value[key]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts numeric value from a rollup object
|
||||||
|
* Handles both old format (number) and new format ({count: 0}, {sum: 100}, {avg: 5.5})
|
||||||
|
*/
|
||||||
|
const extractValue = (value, preferredOperation) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
// If a preferred operation is specified and exists, use it
|
||||||
|
if (preferredOperation && value[preferredOperation] !== undefined) {
|
||||||
|
return value[preferredOperation]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new format: {count: 0}, {sum: 100}, {avg: 5.5}
|
||||||
|
const operationKeys = ['count', 'sum', 'avg']
|
||||||
|
for (const key of operationKeys) {
|
||||||
|
if (value[key] !== undefined) {
|
||||||
|
return value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: return first numeric value found
|
||||||
|
const firstValue = Object.values(value)[0]
|
||||||
|
return typeof firstValue === 'number' ? firstValue : 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the stat value from history data point
|
||||||
|
*/
|
||||||
|
const getStatValueFromPoint = (point, statDef) => {
|
||||||
|
// Handle special combined stats (support both 'combine' and 'sum' properties)
|
||||||
|
const combineList = statDef.combine || statDef.sum
|
||||||
|
if (combineList) {
|
||||||
|
return combineList.reduce((sum, statName) => {
|
||||||
|
const tempStatDef = { name: statName }
|
||||||
|
const value = getStatValueFromPoint(point, tempStatDef)
|
||||||
|
return sum + (value || 0)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statName = statDef.name
|
||||||
|
|
||||||
|
// Determine operation from stat name
|
||||||
|
let operation
|
||||||
|
if (statName.endsWith('.count')) operation = 'count'
|
||||||
|
else if (statName.endsWith('.sum')) operation = 'sum'
|
||||||
|
else if (statName.endsWith('.avg')) operation = 'avg'
|
||||||
|
|
||||||
|
let value
|
||||||
|
|
||||||
|
// Extract the key that matches the history data structure
|
||||||
|
// History data has keys like "standby", "printing" (the rollup names)
|
||||||
|
const statKey = extractStatKey(statName)
|
||||||
|
value = point[statKey]
|
||||||
|
|
||||||
|
// If not found with extracted key, try direct access with full path
|
||||||
|
if (value === undefined && statName.includes('.')) {
|
||||||
|
value = getNestedValue(point, statName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, try with original stat name
|
||||||
|
if (value === undefined) {
|
||||||
|
value = point[statName]
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractValue(value, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform data for the chart using model stats configuration
|
||||||
|
// Each data point has: { date: '2024-01-01', standby: {count: 0}, printing: {count: 0}, ... }
|
||||||
|
// We need to transform to: [{ date: '2024-01-01', category: 'Ready', value: 0 }, ...]
|
||||||
|
const chartData = historyData.flatMap((point) => {
|
||||||
|
return modelStats.map((statDef) => {
|
||||||
|
const statValue = getStatValueFromPoint(point, statDef)
|
||||||
|
const label = statDef.label || statDef.name
|
||||||
|
return {
|
||||||
|
date: point.date,
|
||||||
|
dateFormatted: dayjs(point.date).format('DD/MM HH:mm'),
|
||||||
|
category: label,
|
||||||
|
value: statValue || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
chartData.sort((a, b) => {
|
||||||
|
return new Date(a.date) - new Date(b.date)
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeColors = getColors()
|
||||||
|
|
||||||
|
// Create color mapping from model stats
|
||||||
|
const colors = {
|
||||||
|
success: themeColors.colorSuccess,
|
||||||
|
info: themeColors.colorInfo,
|
||||||
|
error: themeColors.colorError,
|
||||||
|
warning: themeColors.colorWarning,
|
||||||
|
default: '#8c8c8c'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build color range array based on model stats order
|
||||||
|
const colorRange = modelStats.map((stat) => {
|
||||||
|
if (stat.color) {
|
||||||
|
return colors[stat.color] || stat.color
|
||||||
|
}
|
||||||
|
return colors.default
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
data: chartData,
|
||||||
|
xField: 'dateFormatted',
|
||||||
|
yField: 'value',
|
||||||
|
theme: {
|
||||||
|
type: isDarkMode ? 'dark' : 'light',
|
||||||
|
fontFamily: '"DM Sans", -apple-system, BlinkMacSystemFont, sans-serif'
|
||||||
|
},
|
||||||
|
stack: true,
|
||||||
|
colorField: 'category',
|
||||||
|
columnWidthRatio: 1,
|
||||||
|
scale: {
|
||||||
|
color: {
|
||||||
|
range: colorRange
|
||||||
|
}
|
||||||
|
},
|
||||||
|
smooth: true,
|
||||||
|
interaction: {
|
||||||
|
tooltip: {
|
||||||
|
marker: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top'
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
appear: {
|
||||||
|
animation: 'wave-in',
|
||||||
|
duration: 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading} indicator={<LoadingOutlined spin />}>
|
||||||
|
<Card
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
styles={{ body: { padding: '12px', ...styles } }}
|
||||||
|
>
|
||||||
|
{!startDate && !endDate && (
|
||||||
|
<Flex justify='flex-end'>
|
||||||
|
<Segmented
|
||||||
|
size='small'
|
||||||
|
options={[
|
||||||
|
{ label: '24hr', value: '24hrs' },
|
||||||
|
{ label: '12hr', value: '12hrs' },
|
||||||
|
{ label: '8hr', value: '8hrs' },
|
||||||
|
{ label: '4hr', value: '4hrs' },
|
||||||
|
{ label: '1hr', value: '1hrs' }
|
||||||
|
]}
|
||||||
|
value={timeRange}
|
||||||
|
onChange={setTimeRange}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<Column {...config} />
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HistoryDisplay.propTypes = {
|
||||||
|
objectType: PropTypes.string.isRequired,
|
||||||
|
startDate: PropTypes.oneOfType([
|
||||||
|
PropTypes.string,
|
||||||
|
PropTypes.instanceOf(Date)
|
||||||
|
]),
|
||||||
|
styles: PropTypes.object,
|
||||||
|
endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HistoryDisplay
|
||||||
@ -11,17 +11,18 @@ const InfoCollapse = ({
|
|||||||
active,
|
active,
|
||||||
onToggle,
|
onToggle,
|
||||||
className = '',
|
className = '',
|
||||||
collapseKey = 'default'
|
collapseKey = 'default',
|
||||||
|
canCollapse = true
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
expandIconPosition='end'
|
expandIconPosition='end'
|
||||||
activeKey={active ? [collapseKey] : []}
|
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
||||||
onChange={(keys) => onToggle(keys.length > 0)}
|
onChange={(keys) => onToggle(keys.length > 0)}
|
||||||
expandIcon={({ isActive }) => (
|
expandIcon={({ isActive }) =>
|
||||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
canCollapse ? <CaretLeftOutlined rotate={isActive ? -90 : 0} /> : null
|
||||||
)}
|
}
|
||||||
className={`no-h-padding-collapse ${className}`}
|
className={`no-h-padding-collapse ${className}`}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@ -48,7 +49,8 @@ InfoCollapse.propTypes = {
|
|||||||
active: PropTypes.bool.isRequired,
|
active: PropTypes.bool.isRequired,
|
||||||
onToggle: PropTypes.func.isRequired,
|
onToggle: PropTypes.func.isRequired,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
collapseKey: PropTypes.string
|
collapseKey: PropTypes.string,
|
||||||
|
canCollapse: PropTypes.bool
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InfoCollapse
|
export default InfoCollapse
|
||||||
|
|||||||
319
src/components/Dashboard/common/StatsDisplay.jsx
Normal file
319
src/components/Dashboard/common/StatsDisplay.jsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import { useEffect, useState, useContext, useRef } from 'react'
|
||||||
|
import { Flex, Alert, Card, Typography, Skeleton } from 'antd'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { getModelByName } from '../../../database/ObjectModels'
|
||||||
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import { round } from '../utils/Utils'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps stat names to Alert types for visual indication
|
||||||
|
*/
|
||||||
|
const getAlertType = (statName) => {
|
||||||
|
const name = statName.toLowerCase()
|
||||||
|
|
||||||
|
// Success states
|
||||||
|
if (
|
||||||
|
name.includes('complete') ||
|
||||||
|
name.includes('ready') ||
|
||||||
|
name.includes('standby') ||
|
||||||
|
name.includes('success')
|
||||||
|
) {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error states
|
||||||
|
if (
|
||||||
|
name.includes('error') ||
|
||||||
|
name.includes('failed') ||
|
||||||
|
name.includes('cancelled')
|
||||||
|
) {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning states
|
||||||
|
if (
|
||||||
|
name.includes('queued') ||
|
||||||
|
name.includes('pending') ||
|
||||||
|
name.includes('waiting')
|
||||||
|
) {
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes('printing')) {
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default states
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a nested value from an object using dot notation
|
||||||
|
* e.g., getNestedValue(obj, 'states.ready') -> obj.states.ready
|
||||||
|
*/
|
||||||
|
const getNestedValue = (obj, path) => {
|
||||||
|
if (!obj || !path) return undefined
|
||||||
|
const keys = path.split('.')
|
||||||
|
let value = obj
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value === null || value === undefined) return undefined
|
||||||
|
value = value[key]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts numeric value from a rollup object
|
||||||
|
* Handles both old format (number) and new format ({count: 0}, {sum: 100}, {avg: 5.5})
|
||||||
|
*/
|
||||||
|
const extractRollupValue = (value) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
// Handle new format: {count: 0}, {sum: 100}, {avg: 5.5}
|
||||||
|
const operationKeys = ['count', 'sum', 'avg']
|
||||||
|
for (const key of operationKeys) {
|
||||||
|
if (value[key] !== undefined) {
|
||||||
|
return value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: return first numeric value found
|
||||||
|
const firstValue = Object.values(value)[0]
|
||||||
|
return typeof firstValue === 'number' ? firstValue : 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the stat value from the stats data object
|
||||||
|
* Handles both flattened and nested structures, including dot notation paths
|
||||||
|
* Now also handles new format with operation objects ({count: 0}, {sum: 100}, etc.)
|
||||||
|
*/
|
||||||
|
const getStatValue = (stats, statName, statDef) => {
|
||||||
|
if (!stats) return 0
|
||||||
|
|
||||||
|
// Handle special combined stats (support both 'combine' and 'sum' properties)
|
||||||
|
const combineList = statDef.combine || statDef.sum
|
||||||
|
if (combineList) {
|
||||||
|
return combineList.reduce((sum, name) => {
|
||||||
|
const value = getStatValue(stats, name, {})
|
||||||
|
return sum + (value || 0)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dot notation paths (e.g., "states.ready")
|
||||||
|
if (statName.includes('.')) {
|
||||||
|
const value = getNestedValue(stats, statName)
|
||||||
|
return extractRollupValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try direct access first (flattened structure)
|
||||||
|
if (stats[statName] !== undefined) {
|
||||||
|
return extractRollupValue(stats[statName])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try nested structure (stats.states.statName)
|
||||||
|
if (stats.states && stats.states[statName] !== undefined) {
|
||||||
|
return extractRollupValue(stats.states[statName])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with objectType prefix removed (e.g., "standbyPrinters" -> "standby")
|
||||||
|
const simplifiedName = statName.replace(
|
||||||
|
/^(standby|printing|error|offline|queued|complete|failed|cancelled|draft)/i,
|
||||||
|
(match) => {
|
||||||
|
return match.toLowerCase()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (stats[simplifiedName] !== undefined) {
|
||||||
|
return extractRollupValue(stats[simplifiedName])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.states && stats.states[simplifiedName] !== undefined) {
|
||||||
|
return extractRollupValue(stats.states[simplifiedName])
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the base stat name from model stat names
|
||||||
|
* e.g., "standbyPrinters" -> "standby", "printingPrinters" -> "printing"
|
||||||
|
* Preserves dot notation paths (e.g., "states.ready" -> "states.ready")
|
||||||
|
*/
|
||||||
|
const extractBaseStatName = (statName) => {
|
||||||
|
// If it's a dot notation path, return as-is
|
||||||
|
if (statName.includes('.')) {
|
||||||
|
return statName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove common suffixes
|
||||||
|
const baseName = statName
|
||||||
|
.replace(/Printers?$/i, '')
|
||||||
|
.replace(/Jobs?$/i, '')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsDisplay = ({ objectType, stats: statsProp }) => {
|
||||||
|
const { getModelStats, connected, subscribeToModelStats } =
|
||||||
|
useContext(ApiServerContext)
|
||||||
|
const { token } = useContext(AuthContext)
|
||||||
|
const [stats, setStats] = useState(statsProp || null)
|
||||||
|
const [loading, setLoading] = useState(!statsProp)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If stats are provided as prop, use them and don't fetch
|
||||||
|
if (statsProp) {
|
||||||
|
setStats(statsProp)
|
||||||
|
setLoading(false)
|
||||||
|
initializedRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, fetch stats
|
||||||
|
if (
|
||||||
|
!objectType ||
|
||||||
|
!getModelStats ||
|
||||||
|
!token ||
|
||||||
|
!connected ||
|
||||||
|
initializedRef.current == true
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const fetchedStats = await getModelStats(objectType)
|
||||||
|
if (fetchedStats) {
|
||||||
|
setStats(fetchedStats)
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch stats')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stats:', err)
|
||||||
|
setError('Failed to fetch stats')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
}, [objectType, getModelStats, token, connected, statsProp])
|
||||||
|
|
||||||
|
// Subscribe to real-time stats updates
|
||||||
|
useEffect(() => {
|
||||||
|
// Only subscribe if stats are not provided as prop and we have the required dependencies
|
||||||
|
if (!objectType || !connected || !subscribeToModelStats) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsUnsubscribe = subscribeToModelStats(
|
||||||
|
objectType,
|
||||||
|
(updatedStats) => {
|
||||||
|
setStats(updatedStats)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (statsUnsubscribe) statsUnsubscribe()
|
||||||
|
}
|
||||||
|
}, [objectType, connected, subscribeToModelStats, statsProp])
|
||||||
|
|
||||||
|
if (!objectType) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = getModelByName(objectType)
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = model.icon
|
||||||
|
const modelStats = model.stats || []
|
||||||
|
|
||||||
|
if (modelStats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex justify='flex-start' gap='middle' wrap='wrap' align='flex-start'>
|
||||||
|
{modelStats.map((statDef) => {
|
||||||
|
const baseStatName = extractBaseStatName(statDef.name)
|
||||||
|
var statValue = getStatValue(stats, baseStatName, statDef)
|
||||||
|
const alertType = getAlertType(statDef.name)
|
||||||
|
const label = statDef.label || statDef.name
|
||||||
|
|
||||||
|
if (statDef?.roundNumber) {
|
||||||
|
statValue = round(statValue, statDef?.roundNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Flex vertical>
|
||||||
|
<Text type='secondary'>{label}</Text>
|
||||||
|
<Flex gap={'small'}>
|
||||||
|
{Icon && <Icon style={{ fontSize: 26 }} />}
|
||||||
|
{loading ? (
|
||||||
|
<Flex justify='center' align='center' style={{ height: 44 }}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active
|
||||||
|
size='small'
|
||||||
|
style={{ width: 50, height: 30 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Text style={{ fontSize: 28, fontWeight: 600 }}>
|
||||||
|
{statDef?.prefix}
|
||||||
|
{statValue}
|
||||||
|
{statDef?.suffix}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (alertType === 'default') {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={statDef.name}
|
||||||
|
style={{ minWidth: statDef?.cardWidth || 175 }}
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={statDef.name}
|
||||||
|
type={alertType}
|
||||||
|
style={{ minWidth: statDef?.cardWidth || 175 }}
|
||||||
|
description={content}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsDisplay.propTypes = {
|
||||||
|
objectType: PropTypes.string.isRequired,
|
||||||
|
stats: PropTypes.object // Optional - if not provided, will fetch automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatsDisplay
|
||||||
@ -37,14 +37,7 @@ export const Job = {
|
|||||||
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
|
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
columns: [
|
columns: ['_id', 'gcodeFile', 'quantity', 'state', 'createdAt'],
|
||||||
'_id',
|
|
||||||
'gcodeFile',
|
|
||||||
'quantity',
|
|
||||||
'state',
|
|
||||||
|
|
||||||
'createdAt'
|
|
||||||
],
|
|
||||||
filters: ['state', '_id', 'gcodeFile', 'quantity'],
|
filters: ['state', '_id', 'gcodeFile', 'quantity'],
|
||||||
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
|
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
|
||||||
properties: [
|
properties: [
|
||||||
@ -115,5 +108,38 @@ export const Job = {
|
|||||||
required: true,
|
required: true,
|
||||||
showHyperlink: true
|
showHyperlink: true
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
name: 'draft.count',
|
||||||
|
label: 'Draft',
|
||||||
|
type: 'number',
|
||||||
|
color: 'default'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'queued.count',
|
||||||
|
label: 'Queued',
|
||||||
|
type: 'number',
|
||||||
|
color: 'warning'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'printing.count',
|
||||||
|
label: 'Printing',
|
||||||
|
type: 'number',
|
||||||
|
color: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'failed.count',
|
||||||
|
label: 'Failed',
|
||||||
|
type: 'number',
|
||||||
|
combine: ['failed.count', 'cancelled.count'],
|
||||||
|
color: 'error'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'complete.count',
|
||||||
|
label: 'Complete',
|
||||||
|
type: 'number',
|
||||||
|
color: 'success'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -364,5 +364,32 @@ export const Printer = {
|
|||||||
required: false,
|
required: false,
|
||||||
readOnly: true
|
readOnly: true
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
name: 'standby.count',
|
||||||
|
label: 'Ready',
|
||||||
|
type: 'number',
|
||||||
|
sum: ['standby.count', 'complete.count'],
|
||||||
|
color: 'success'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'printing.count',
|
||||||
|
label: 'Printing',
|
||||||
|
type: 'number',
|
||||||
|
color: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'error.count',
|
||||||
|
label: 'Error',
|
||||||
|
type: 'number',
|
||||||
|
color: 'error'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'offline.count',
|
||||||
|
label: 'Offline',
|
||||||
|
type: 'number',
|
||||||
|
color: 'default'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user