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 {
|
||||
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 { useContext } from 'react'
|
||||
import { Flex } from 'antd'
|
||||
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 { AuthContext } from '../context/AuthContext'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
|
||||
const ProductionOverview = () => {
|
||||
const { token } = useContext(AuthContext)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [error, setError] = useState(null)
|
||||
const [fetchPrinterStatsLoading, setFetchPrinterStatsLoading] = useState(true)
|
||||
const [chartData, setChartData] = useState([])
|
||||
const { connected } = useContext(ApiServerContext)
|
||||
|
||||
const [collapseState, updateCollapseState] = useCollapseState(
|
||||
'ProductionOverview',
|
||||
{
|
||||
overview: 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({
|
||||
totalPrinters: 0,
|
||||
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)
|
||||
}
|
||||
if (!connected) {
|
||||
return null
|
||||
}
|
||||
|
||||
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' }}
|
||||
<Flex
|
||||
gap='large'
|
||||
vertical='true'
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
minHeight: 0
|
||||
}}
|
||||
>
|
||||
<p>{error || 'Printer not found'}</p>
|
||||
<Button icon={<ReloadIcon />} onClick={fetchAllStats}>
|
||||
Retry
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
<ScrollBox>
|
||||
<Flex vertical gap={'large'}>
|
||||
<InfoCollapse
|
||||
title='Printer Statistics'
|
||||
icon={null}
|
||||
active={collapseState.printerStats}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('printerStats', isActive)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
||||
{contextHolder}
|
||||
<Flex gap='large' vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.overview ? ['1'] : []}
|
||||
onChange={(keys) => updateCollapseState('overview', keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse no-t-padding-collapse'
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Status Overview
|
||||
</Title>
|
||||
}
|
||||
key='1'
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='printerStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
@ -181,268 +53,61 @@ const ProductionOverview = () => {
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<Alert
|
||||
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>
|
||||
<StatsDisplay objectType='printer' />
|
||||
</Flex>
|
||||
</InfoCollapse>
|
||||
|
||||
<InfoCollapse
|
||||
title='Job Statistics'
|
||||
icon={null}
|
||||
active={collapseState.jobStats}
|
||||
onToggle={(isActive) => updateCollapseState('jobStats', isActive)}
|
||||
className='no-t-padding-collapse'
|
||||
collapseKey='jobStats'
|
||||
>
|
||||
<Flex
|
||||
justify='flex-start'
|
||||
gap='middle'
|
||||
wrap='wrap'
|
||||
align='flex-start'
|
||||
>
|
||||
<StatsDisplay objectType='job' />
|
||||
</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>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</InfoCollapse>
|
||||
|
||||
<Flex gap='large' wrap='wrap'>
|
||||
<Flex flex={1} vertical>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={collapseState.printerStats ? ['2'] : []}
|
||||
onChange={(keys) =>
|
||||
updateCollapseState('printerStats', keys.length > 0)
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
||||
<InfoCollapse
|
||||
title='Printer History'
|
||||
icon={null}
|
||||
active={collapseState.printerHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('printerHistory', isActive)
|
||||
}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined
|
||||
rotate={isActive ? 90 : 0}
|
||||
style={{ paddingTop: '2px' }}
|
||||
/>
|
||||
)}
|
||||
className='no-h-padding-collapse'
|
||||
collapseKey='printerHistory'
|
||||
canCollapse={false}
|
||||
>
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<Flex
|
||||
align='center'
|
||||
justify='space-between'
|
||||
style={{ width: '100%' }}
|
||||
<HistoryDisplay objectType='printer' />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
|
||||
<InfoCollapse
|
||||
title='Job History'
|
||||
icon={null}
|
||||
active={collapseState.jobHistory}
|
||||
onToggle={(isActive) =>
|
||||
updateCollapseState('jobHistory', isActive)
|
||||
}
|
||||
canCollapse={false}
|
||||
collapseKey='jobHistory'
|
||||
>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
Production Statistics
|
||||
</Title>
|
||||
<Segmented
|
||||
options={['4h', '8h', '12h', '24h']}
|
||||
value={timeRanges.printerStats}
|
||||
onChange={(value) =>
|
||||
setTimeRanges((prev) => ({
|
||||
...prev,
|
||||
printerStats: value
|
||||
}))
|
||||
}
|
||||
size='small'
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
key='2'
|
||||
>
|
||||
<Flex vertical gap={'middle'}>
|
||||
<Card style={{ height: 250, width: '100%' }}>
|
||||
<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.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'
|
||||
>
|
||||
<Flex vertical gap={'middle'}>
|
||||
<Card
|
||||
style={{ height: 250, width: '100%', minWidth: '300px' }}
|
||||
>
|
||||
<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>
|
||||
<HistoryDisplay objectType='job' />
|
||||
</InfoCollapse>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</ScrollBox>
|
||||
</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,
|
||||
onToggle,
|
||||
className = '',
|
||||
collapseKey = 'default'
|
||||
collapseKey = 'default',
|
||||
canCollapse = true
|
||||
}) => {
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
expandIconPosition='end'
|
||||
activeKey={active ? [collapseKey] : []}
|
||||
activeKey={canCollapse ? (active ? [collapseKey] : []) : [collapseKey]}
|
||||
onChange={(keys) => onToggle(keys.length > 0)}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CaretLeftOutlined rotate={isActive ? -90 : 0} />
|
||||
)}
|
||||
expandIcon={({ isActive }) =>
|
||||
canCollapse ? <CaretLeftOutlined rotate={isActive ? -90 : 0} /> : null
|
||||
}
|
||||
className={`no-h-padding-collapse ${className}`}
|
||||
items={[
|
||||
{
|
||||
@ -48,7 +49,8 @@ InfoCollapse.propTypes = {
|
||||
active: PropTypes.bool.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
collapseKey: PropTypes.string
|
||||
collapseKey: PropTypes.string,
|
||||
canCollapse: PropTypes.bool
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
],
|
||||
columns: [
|
||||
'_id',
|
||||
'gcodeFile',
|
||||
'quantity',
|
||||
'state',
|
||||
|
||||
'createdAt'
|
||||
],
|
||||
columns: ['_id', 'gcodeFile', 'quantity', 'state', 'createdAt'],
|
||||
filters: ['state', '_id', 'gcodeFile', 'quantity'],
|
||||
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
|
||||
properties: [
|
||||
@ -115,5 +108,38 @@ export const Job = {
|
||||
required: 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,
|
||||
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