430 lines
12 KiB
JavaScript
430 lines
12 KiB
JavaScript
import { useEffect, useState, useContext, useMemo, lazy, Suspense } from 'react'
|
|
import {
|
|
Card,
|
|
Segmented,
|
|
Flex,
|
|
Popover,
|
|
DatePicker,
|
|
Button,
|
|
Space,
|
|
Skeleton
|
|
} from 'antd'
|
|
|
|
const Column = lazy(() =>
|
|
import('@ant-design/charts').then((module) => ({ default: module.Column }))
|
|
)
|
|
|
|
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'
|
|
import LoadingPlaceholder from './LoadingPlaceholder'
|
|
import MissingPlaceholder from './MissingPlaceholder'
|
|
import CheckIcon from '../../Icons/CheckIcon'
|
|
|
|
const HistoryDisplay = ({
|
|
objectType,
|
|
startDate,
|
|
endDate,
|
|
styles,
|
|
height = 400
|
|
}) => {
|
|
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 [startCustomDate, setStartCustomDate] = useState(null)
|
|
const [endCustomDate, setEndCustomDate] = useState(null)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Handle custom date range
|
|
if (timeRange === 'custom' && startCustomDate && endCustomDate) {
|
|
return {
|
|
defaultStartDate: startCustomDate.toDate(),
|
|
defaultEndDate: endCustomDate.toDate()
|
|
}
|
|
}
|
|
|
|
// 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, startCustomDate, endCustomDate])
|
|
|
|
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,
|
|
processing: themeColors.colorInfo,
|
|
error: themeColors.colorError,
|
|
warning: themeColors.colorWarning,
|
|
default: '#8c8c8c',
|
|
cyan: themeColors.colorCyan,
|
|
pink: themeColors.colorPink,
|
|
purple: themeColors.colorPurple,
|
|
magenta: themeColors.colorMagenta,
|
|
volcano: themeColors.colorVolcano
|
|
}
|
|
|
|
// 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,
|
|
height,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
const customTimeRangeContent = (
|
|
<Space.Compact>
|
|
<DatePicker.RangePicker
|
|
onChange={(dates) => {
|
|
if (dates) {
|
|
setStartCustomDate(dates[0])
|
|
setEndCustomDate(dates[1])
|
|
} else {
|
|
setStartCustomDate(null)
|
|
setEndCustomDate(null)
|
|
}
|
|
}}
|
|
value={[startCustomDate, endCustomDate]}
|
|
/>
|
|
<Button
|
|
type='primary'
|
|
onClick={() => {
|
|
if (startCustomDate && endCustomDate) {
|
|
setTimeRange('custom')
|
|
}
|
|
}}
|
|
disabled={!startCustomDate || !endCustomDate}
|
|
icon={<CheckIcon />}
|
|
></Button>
|
|
</Space.Compact>
|
|
)
|
|
|
|
return (
|
|
<Card
|
|
style={{ width: '100%' }}
|
|
styles={{ body: { padding: '12px', ...styles } }}
|
|
>
|
|
{!startDate && !endDate && (
|
|
<Flex justify='space-between'>
|
|
<Flex align='center' gap='5px'>
|
|
<Popover
|
|
content={customTimeRangeContent}
|
|
trigger='hover'
|
|
arrow={false}
|
|
placement='bottomLeft'
|
|
styles={{ body: { borderRadius: '22.5px' } }}
|
|
>
|
|
<Segmented
|
|
size='small'
|
|
options={[{ label: 'Custom', value: 'custom' }]}
|
|
value={timeRange}
|
|
onChange={setTimeRange}
|
|
disabled={loading}
|
|
/>
|
|
</Popover>
|
|
</Flex>
|
|
|
|
<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}
|
|
disabled={loading}
|
|
/>
|
|
</Flex>
|
|
)}
|
|
{loading == true && (
|
|
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
|
|
<LoadingPlaceholder message='Loading history data...' />
|
|
</Flex>
|
|
)}
|
|
{chartData.length > 0 && (
|
|
<Suspense
|
|
fallback={
|
|
<Flex
|
|
justify='center'
|
|
align='center'
|
|
style={{ height: `${height}px` }}
|
|
>
|
|
<Skeleton active paragraph={{ rows: 4 }} />
|
|
</Flex>
|
|
}
|
|
>
|
|
<Column {...config} />
|
|
</Suspense>
|
|
)}
|
|
{loading == false && chartData.length == 0 && (
|
|
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
|
|
<MissingPlaceholder message='No data available.' />
|
|
</Flex>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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)]),
|
|
height: PropTypes.string
|
|
}
|
|
|
|
export default HistoryDisplay
|