Overhauled printer overview page (now working, with charts and realtime updates to current state)

This commit is contained in:
Tom Butcher 2025-12-08 23:00:58 +00:00
parent aaeeb4013e
commit 98ca73791f
6 changed files with 790 additions and 428 deletions

View File

@ -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)
}
}
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>
)
if (!connected) {
return null
}
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>
<Flex
gap='large'
vertical='true'
style={{
maxHeight: '100%',
minHeight: 0
}}
>
<ScrollBox>
<Flex vertical gap={'large'}>
<InfoCollapse
title='Printer Statistics'
icon={null}
active={collapseState.printerStats}
onToggle={(isActive) =>
updateCollapseState('printerStats', isActive)
}
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>
</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>
}
/>
<StatsDisplay objectType='printer' />
</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)
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '2px' }}
/>
)}
className='no-h-padding-collapse'
<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'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<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>
<StatsDisplay objectType='job' />
</Flex>
</InfoCollapse>
<Flex gap='large' wrap='wrap'>
<Flex flex={1} vertical style={{ minWidth: '300px' }}>
<InfoCollapse
title='Printer History'
icon={null}
active={collapseState.printerHistory}
onToggle={(isActive) =>
updateCollapseState('printerHistory', isActive)
}
key='2'
collapseKey='printerHistory'
canCollapse={false}
>
<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>
<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)
}
key='3'
canCollapse={false}
collapseKey='jobHistory'
>
<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>
</Flex>
</div>
</ScrollBox>
</Flex>
)
}

View 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

View File

@ -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

View 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

View File

@ -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'
}
]
}

View File

@ -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'
}
]
}