Tom Butcher a7cd374375 Refactor StatsDisplay component to use Tag and Badge for visual indicators
- Replaced Alert with Tag for displaying stat colors.
- Updated color mapping function to return Tag colors instead of Alert types.
- Enhanced layout and styling for better visual representation of stats.
- Introduced Badge component for status indication alongside stat labels.
2025-12-27 13:47:19 +00:00

348 lines
8.9 KiB
JavaScript

import { useEffect, useState, useContext, useRef } from 'react'
import { Flex, Tag, Card, Typography, Skeleton, Badge } 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 Tag colors for visual indication
*/
const getTagColor = (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 'processing'
}
// Default states
return 'default'
}
/*i*
* 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 tagColor = statDef.color || getTagColor(statDef.name)
const label = statDef.label || statDef.name
if (statDef?.roundNumber) {
statValue = round(statValue, statDef?.roundNumber)
}
const statusColors = [
'success',
'warning',
'error',
'processing',
'default'
]
var badgeProps = {
status: tagColor
}
if (!statusColors.includes(tagColor)) {
badgeProps = { color: tagColor }
}
const content = (
<Flex vertical gap='3px'>
<Flex gap={'12px'} align='center'>
<Badge {...badgeProps} />
<Text>{label}</Text>
</Flex>
<Flex gap={'12px'} align='center'>
{Icon && (
<Text>
<Icon style={{ fontSize: 26 }} />
</Text>
)}
{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 (tagColor === 'default') {
return (
<Card
key={statDef.name}
style={{ minWidth: statDef?.cardWidth || 175 }}
styles={{ body: { padding: '16px 24px' } }}
>
{content}
</Card>
)
}
return (
<Tag
key={statDef.name}
color={tagColor}
style={{
minWidth: statDef?.cardWidth || 175,
padding: '16px 24px',
margin: 0
}}
>
{content}
</Tag>
)
})}
</Flex>
)
}
StatsDisplay.propTypes = {
objectType: PropTypes.string.isRequired,
stats: PropTypes.object // Optional - if not provided, will fetch automatically
}
export default StatsDisplay