- 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.
348 lines
8.9 KiB
JavaScript
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
|