From 98ca73791ff0390b5eb3f712e2e26d149a314fb5 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Mon, 8 Dec 2025 23:00:58 +0000 Subject: [PATCH] Overhauled printer overview page (now working, with charts and realtime updates to current state) --- .../Production/ProductionOverview.jsx | 493 +++--------------- .../Dashboard/common/HistoryDisplay.jsx | 323 ++++++++++++ .../Dashboard/common/InfoCollapse.jsx | 14 +- .../Dashboard/common/StatsDisplay.jsx | 319 ++++++++++++ src/database/models/Job.js | 42 +- src/database/models/Printer.js | 27 + 6 files changed, 790 insertions(+), 428 deletions(-) create mode 100644 src/components/Dashboard/common/HistoryDisplay.jsx create mode 100644 src/components/Dashboard/common/StatsDisplay.jsx diff --git a/src/components/Dashboard/Production/ProductionOverview.jsx b/src/components/Dashboard/Production/ProductionOverview.jsx index 7cf6513..1f1e2f1 100644 --- a/src/components/Dashboard/Production/ProductionOverview.jsx +++ b/src/components/Dashboard/Production/ProductionOverview.jsx @@ -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 ( -
- } /> -
- ) - } - - if (error || !stats) { - return ( - -

{error || 'Printer not found'}

- -
- ) + if (!connected) { + return null } return ( -
- {contextHolder} - - updateCollapseState('overview', keys.length > 0)} - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse no-t-padding-collapse' - > - - Status Overview - + + + + + updateCollapseState('printerStats', isActive) } - key='1' + className='no-t-padding-collapse' + collapseKey='printerStats' > { wrap='wrap' align='flex-start' > - - Ready - - - - {(stats.printers.standby || 0) + - (stats.printers.complete || 0)} - - - - } - /> - - Printing - - - - {stats.printers.printing || 0} - - - - } - /> - - Queued - - - - {stats.jobs.queued || 0} - - - - } - /> - - Printing - - - - {stats.jobs.printing || 0} - - - - } - /> - - Failed - - - - {(stats.jobs.failed || 0) + (stats.jobs.cancelled || 0)} - - - - } - /> - - Complete - - - - {stats.jobs.complete || 0} - - - - } - /> + - - + - - - - updateCollapseState('printerStats', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' + updateCollapseState('jobStats', isActive)} + className='no-t-padding-collapse' + collapseKey='jobStats' + > + - - - Production Statistics - - - setTimeRanges((prev) => ({ - ...prev, - printerStats: value - })) - } - size='small' - /> - + + + + + + + + updateCollapseState('printerHistory', isActive) } - key='2' + collapseKey='printerHistory' + canCollapse={false} > - - - - - - - {stats.totalPrinters} - - - {stats.activePrinters} - - - {stats.activePrinters} - - - - - - - - - updateCollapseState('jobStats', keys.length > 0) - } - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' - > - - - Job Statistics - - - setTimeRanges((prev) => ({ ...prev, jobStats: value })) - } - size='small' - /> - + + + + + + updateCollapseState('jobHistory', isActive) } - key='3' + canCollapse={false} + collapseKey='jobHistory' > - - - - - - - {stats.totalJobs} - - - {stats.activeJobs} - - - {stats.completedJobs} - - - - - + + + - -
+ + ) } diff --git a/src/components/Dashboard/common/HistoryDisplay.jsx b/src/components/Dashboard/common/HistoryDisplay.jsx new file mode 100644 index 0000000..b57e1d8 --- /dev/null +++ b/src/components/Dashboard/common/HistoryDisplay.jsx @@ -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 ( + }> + + {!startDate && !endDate && ( + + + + )} + + + + ) +} + +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 diff --git a/src/components/Dashboard/common/InfoCollapse.jsx b/src/components/Dashboard/common/InfoCollapse.jsx index 6606be2..bb5ec40 100644 --- a/src/components/Dashboard/common/InfoCollapse.jsx +++ b/src/components/Dashboard/common/InfoCollapse.jsx @@ -11,17 +11,18 @@ const InfoCollapse = ({ active, onToggle, className = '', - collapseKey = 'default' + collapseKey = 'default', + canCollapse = true }) => { return ( onToggle(keys.length > 0)} - expandIcon={({ isActive }) => ( - - )} + expandIcon={({ isActive }) => + canCollapse ? : 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 diff --git a/src/components/Dashboard/common/StatsDisplay.jsx b/src/components/Dashboard/common/StatsDisplay.jsx new file mode 100644 index 0000000..988d5f6 --- /dev/null +++ b/src/components/Dashboard/common/StatsDisplay.jsx @@ -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 ( + + {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 = ( + + {label} + + {Icon && } + {loading ? ( + + + + ) : ( + + {statDef?.prefix} + {statValue} + {statDef?.suffix} + + )} + + + ) + + if (alertType === 'default') { + return ( + + {content} + + ) + } + + return ( + + ) + })} + + ) +} + +StatsDisplay.propTypes = { + objectType: PropTypes.string.isRequired, + stats: PropTypes.object // Optional - if not provided, will fetch automatically +} + +export default StatsDisplay diff --git a/src/database/models/Job.js b/src/database/models/Job.js index a80670a..e49bb2b 100644 --- a/src/database/models/Job.js +++ b/src/database/models/Job.js @@ -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' + } ] } diff --git a/src/database/models/Printer.js b/src/database/models/Printer.js index 30fc7a5..e845d85 100644 --- a/src/database/models/Printer.js +++ b/src/database/models/Printer.js @@ -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' + } ] }