Compare commits

..

No commits in common. "34d919d88ebb729da598b7135e9f1172cc467cf6" and "7a4dec3f5422eb2815a9e66137a53f40e93e87bd" have entirely different histories.

14 changed files with 63 additions and 265 deletions

View File

@ -28,12 +28,7 @@
.ant-select, .ant-select,
.ant-progress, .ant-progress,
.ant-collapse, .ant-collapse,
.ant-picker-dropdown,
.ant-radio-group, .ant-radio-group,
.g2-tooltip-title,
.g2-tooltip-list-item,
.ant-picker-input,
.ant-picker-header-view button,
[class*=' ant-radio'] { [class*=' ant-radio'] {
font-family: 'DM Sans'; font-family: 'DM Sans';
} }
@ -396,7 +391,3 @@ body {
.object-info-descriptions table { .object-info-descriptions table {
table-layout: fixed !important; table-layout: fixed !important;
} }
.ant-btn-variant-outlined.ant-btn.ant-btn-icon-only {
min-width: 32px;
}

View File

@ -9,7 +9,7 @@
"private": true, "private": true,
"homepage": "./", "homepage": "./",
"dependencies": { "dependencies": {
"@ant-design/charts": "^2.6.5", "@ant-design/charts": "^2.6.2",
"@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",

View File

@ -85,7 +85,6 @@ const Jobs = () => {
open={newJobOpen} open={newJobOpen}
footer={null} footer={null}
width={700} width={700}
destroyOnHidden={true}
onCancel={() => { onCancel={() => {
setNewJobOpen(false) setNewJobOpen(false)
}} }}

View File

@ -24,7 +24,6 @@ const NewJob = ({ onOk, defaultValues }) => {
bordered={false} bordered={false}
isEditing={true} isEditing={true}
required={true} required={true}
labelWidth='100px'
objectData={objectData} objectData={objectData}
/> />
) )
@ -37,14 +36,11 @@ const NewJob = ({ onOk, defaultValues }) => {
type='job' type='job'
column={1} column={1}
bordered={false} bordered={false}
labelWidth='100px'
visibleProperties={{ visibleProperties={{
_id: false, _id: false,
createdAt: false, createdAt: false,
updatedAt: false, updatedAt: false,
startedAt: false, startedAt: false
finishedAt: false,
totalTime: false
}} }}
isEditing={false} isEditing={false}
objectData={objectData} objectData={objectData}

View File

@ -25,7 +25,6 @@ const NewPrinter = ({ onOk, defaultValues }) => {
<ObjectInfo <ObjectInfo
type='printer' type='printer'
column={1} column={1}
labelWidth='100px'
bordered={false} bordered={false}
isEditing={true} isEditing={true}
required={true} required={true}
@ -43,7 +42,6 @@ const NewPrinter = ({ onOk, defaultValues }) => {
bordered={false} bordered={false}
isEditing={true} isEditing={true}
required={false} required={false}
labelWidth='100px'
objectData={objectData} objectData={objectData}
visibleProperties={{ visibleProperties={{
firmware: false, firmware: false,

View File

@ -155,7 +155,6 @@ const PrinterInfo = () => {
isEditing={isEditing} isEditing={isEditing}
type='printer' type='printer'
objectData={objectData} objectData={objectData}
labelWidth='175px'
visibleProperties={{ visibleProperties={{
currentFilamentStock: false, currentFilamentStock: false,
'currentFilamentStock._id': false, 'currentFilamentStock._id': false,

View File

@ -7,7 +7,6 @@ import InfoCollapse from '../common/InfoCollapse'
import ScrollBox from '../common/ScrollBox' import ScrollBox from '../common/ScrollBox'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import ObjectTable from '../common/ObjectTable'
const ProductionOverview = () => { const ProductionOverview = () => {
const { connected } = useContext(ApiServerContext) const { connected } = useContext(ApiServerContext)
@ -17,7 +16,6 @@ const ProductionOverview = () => {
{ {
printerStats: true, printerStats: true,
printerHistory: true, printerHistory: true,
onlinePrinters: true,
jobStats: true, jobStats: true,
productionStats: true, productionStats: true,
jobStatsDetails: true jobStatsDetails: true
@ -42,7 +40,6 @@ const ProductionOverview = () => {
<InfoCollapse <InfoCollapse
title='Printer Statistics' title='Printer Statistics'
icon={null} icon={null}
canCollapse={false}
active={collapseState.printerStats} active={collapseState.printerStats}
onToggle={(isActive) => onToggle={(isActive) =>
updateCollapseState('printerStats', isActive) updateCollapseState('printerStats', isActive)
@ -63,7 +60,6 @@ const ProductionOverview = () => {
<InfoCollapse <InfoCollapse
title='Job Statistics' title='Job Statistics'
icon={null} icon={null}
canCollapse={false}
active={collapseState.jobStats} active={collapseState.jobStats}
onToggle={(isActive) => updateCollapseState('jobStats', isActive)} onToggle={(isActive) => updateCollapseState('jobStats', isActive)}
className='no-t-padding-collapse' className='no-t-padding-collapse'
@ -80,7 +76,7 @@ const ProductionOverview = () => {
</InfoCollapse> </InfoCollapse>
<Flex gap='large' wrap='wrap'> <Flex gap='large' wrap='wrap'>
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'> <Flex flex={1} vertical style={{ minWidth: '300px' }}>
<InfoCollapse <InfoCollapse
title='Printer History' title='Printer History'
icon={null} icon={null}
@ -93,20 +89,8 @@ const ProductionOverview = () => {
> >
<HistoryDisplay objectType='printer' /> <HistoryDisplay objectType='printer' />
</InfoCollapse> </InfoCollapse>
<InfoCollapse
title='Online Printers'
icon={null}
active={collapseState.onlinePrinters}
onToggle={(isActive) =>
updateCollapseState('onlinePrinters', isActive)
}
collapseKey='onlinePrinters'
canCollapse={false}
>
<ObjectTable type='printer' masterFilter={{ online: true }} />
</InfoCollapse>
</Flex> </Flex>
<Flex flex={1} vertical style={{ minWidth: '300px' }} gap='large'> <Flex flex={1} vertical style={{ minWidth: '300px' }}>
<InfoCollapse <InfoCollapse
title='Job History' title='Job History'
icon={null} icon={null}
@ -119,21 +103,6 @@ const ProductionOverview = () => {
> >
<HistoryDisplay objectType='job' /> <HistoryDisplay objectType='job' />
</InfoCollapse> </InfoCollapse>
<InfoCollapse
title='Queued Jobs'
icon={null}
active={collapseState.queuedJobs}
onToggle={(isActive) =>
updateCollapseState('queuedJobs', isActive)
}
canCollapse={false}
collapseKey='queuedJobs'
>
<ObjectTable
type='job'
masterFilter={{ 'state.type': 'queued' }}
/>
</InfoCollapse>
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useContext, useMemo } from 'react' import { useEffect, useState, useContext, useMemo } from 'react'
import { Card, Segmented, Flex, Popover, DatePicker, Button, Space } from 'antd' import { Card, Spin, Segmented, Flex } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { Column } from '@ant-design/charts' import { Column } from '@ant-design/charts'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
@ -7,25 +8,14 @@ import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import LoadingPlaceholder from './LoadingPlaceholder'
import MissingPlaceholder from './MissingPlaceholder'
import CheckIcon from '../../Icons/CheckIcon'
const HistoryDisplay = ({ const HistoryDisplay = ({ objectType, startDate, endDate, styles }) => {
objectType,
startDate,
endDate,
styles,
height = 400
}) => {
const { getModelHistory, connected } = useContext(ApiServerContext) const { getModelHistory, connected } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const [historyData, setHistoryData] = useState([]) const [historyData, setHistoryData] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [timeRange, setTimeRange] = useState('4hrs') const [timeRange, setTimeRange] = useState('4hrs')
const [startCustomDate, setStartCustomDate] = useState(null)
const [endCustomDate, setEndCustomDate] = useState(null)
const { isDarkMode, getColors } = useThemeContext() const { isDarkMode, getColors } = useThemeContext()
// Calculate dates based on selected time range or provided props // Calculate dates based on selected time range or provided props
const { defaultStartDate, defaultEndDate } = useMemo(() => { const { defaultStartDate, defaultEndDate } = useMemo(() => {
@ -40,14 +30,6 @@ const HistoryDisplay = ({
} }
} }
// Handle custom date range
if (timeRange === 'custom' && startCustomDate && endCustomDate) {
return {
defaultStartDate: startCustomDate.toDate(),
defaultEndDate: endCustomDate.toDate()
}
}
// Otherwise, calculate based on selected time range // Otherwise, calculate based on selected time range
const hoursMap = { const hoursMap = {
'8hrs': 8, '8hrs': 8,
@ -61,7 +43,7 @@ const HistoryDisplay = ({
defaultStartDate: new Date(now.getTime() - hours * 60 * 60 * 1000), defaultStartDate: new Date(now.getTime() - hours * 60 * 60 * 1000),
defaultEndDate: now defaultEndDate: now
} }
}, [startDate, endDate, timeRange, startCustomDate, endCustomDate]) }, [startDate, endDate, timeRange])
useEffect(() => { useEffect(() => {
if (!objectType || !getModelHistory || !token || !connected) { if (!objectType || !getModelHistory || !token || !connected) {
@ -269,7 +251,6 @@ const HistoryDisplay = ({
const config = { const config = {
data: chartData, data: chartData,
height,
xField: 'dateFormatted', xField: 'dateFormatted',
yField: 'value', yField: 'value',
theme: { theme: {
@ -301,85 +282,31 @@ const HistoryDisplay = ({
} }
} }
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 ( return (
<Card <Spin spinning={loading} indicator={<LoadingOutlined spin />}>
style={{ width: '100%' }} <Card
styles={{ body: { padding: '12px', ...styles } }} style={{ width: '100%' }}
> styles={{ body: { padding: '12px', ...styles } }}
{!startDate && !endDate && ( >
<Flex justify='space-between'> {!startDate && !endDate && (
<Flex align='center' gap='5px'> <Flex justify='flex-end'>
<Popover <Segmented
content={customTimeRangeContent} size='small'
trigger='hover' options={[
arrow={false} { label: '24hr', value: '24hrs' },
placement='bottomLeft' { label: '12hr', value: '12hrs' },
styles={{ body: { borderRadius: '22.5px' } }} { label: '8hr', value: '8hrs' },
> { label: '4hr', value: '4hrs' },
<Segmented { label: '1hr', value: '1hrs' }
size='small' ]}
options={[{ label: 'Custom', value: 'custom' }]} value={timeRange}
value={timeRange} onChange={setTimeRange}
onChange={setTimeRange} />
disabled={loading}
/>
</Popover>
</Flex> </Flex>
)}
<Segmented <Column {...config} />
size='small' </Card>
options={[ </Spin>
{ 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 && <Column {...config} />}
{loading == false && chartData.length == 0 && (
<Flex justify='center' align='center' style={{ height: `${height}px` }}>
<MissingPlaceholder message='No data available.' />
</Flex>
)}
</Card>
) )
} }
@ -390,8 +317,7 @@ HistoryDisplay.propTypes = {
PropTypes.instanceOf(Date) PropTypes.instanceOf(Date)
]), ]),
styles: PropTypes.object, styles: PropTypes.object,
endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), endDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)])
height: PropTypes.string
} }
export default HistoryDisplay export default HistoryDisplay

View File

@ -56,26 +56,6 @@ const ObjectSelect = ({
// Normalize a value to an identity string so we can detect in-place _id updates // Normalize a value to an identity string so we can detect in-place _id updates
const getValueIdentity = useCallback((val) => { const getValueIdentity = useCallback((val) => {
if (val && typeof val === 'object') { if (val && typeof val === 'object') {
// Handle arrays
if (Array.isArray(val)) {
const ids = val
.map((item) => {
if (item && typeof item === 'object') {
if (item._id) return String(item._id)
if (
item.value &&
typeof item.value === 'object' &&
item.value._id
)
return String(item.value._id)
}
return null
})
.filter(Boolean)
.sort()
return ids.length > 0 ? ids.join(',') : JSON.stringify(val)
}
// Handle single objects
if (val._id) return String(val._id) if (val._id) return String(val._id)
if (val.value && typeof val.value === 'object' && val.value._id) if (val.value && typeof val.value === 'object' && val.value._id)
return String(val.value._id) return String(val.value._id)

View File

@ -969,16 +969,10 @@ const ApiServerProvider = ({ children }) => {
const timeRangeMs = endDate.getTime() - startDate.getTime() const timeRangeMs = endDate.getTime() - startDate.getTime()
const oneHourMs = 60 * 60 * 1000 const oneHourMs = 60 * 60 * 1000
const twelveHoursMs = 12 * 60 * 60 * 1000 const twelveHoursMs = 12 * 60 * 60 * 1000
const oneDayMs = 24 * 60 * 60 * 1000
const threeDaysMs = 3 * 24 * 60 * 60 * 1000
// Determine interval based on time range // Determine interval based on time range
let intervalMinutes = 1 // Default: 1 minute let intervalMinutes = 1 // Default: 1 minute
if (timeRangeMs > threeDaysMs) { if (timeRangeMs > twelveHoursMs) {
intervalMinutes = 60 // Over 1 day: 60 minutes
} else if (timeRangeMs > oneDayMs) {
intervalMinutes = 30 // Over 2 days: 30 minutes
} else if (timeRangeMs > twelveHoursMs) {
intervalMinutes = 10 // Over 12 hours: 10 minutes intervalMinutes = 10 // Over 12 hours: 10 minutes
} else if (timeRangeMs > oneHourMs) { } else if (timeRangeMs > oneHourMs) {
intervalMinutes = 5 // Over 1 hour: 5 minutes intervalMinutes = 5 // Over 1 hour: 5 minutes

View File

@ -2,7 +2,6 @@ import JobIcon from '../../components/Icons/JobIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon' import ReloadIcon from '../../components/Icons/ReloadIcon'
import CheckIcon from '../../components/Icons/CheckIcon' import CheckIcon from '../../components/Icons/CheckIcon'
import dayjs from 'dayjs'
export const Job = { export const Job = {
name: 'job', name: 'job',
@ -38,7 +37,7 @@ export const Job = {
url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload` url: (_id) => `/dashboard/production/jobs/info?jobId=${_id}&action=reload`
} }
], ],
columns: ['_id', 'quantity', 'state', 'gcodeFile', 'createdAt'], columns: ['_id', 'gcodeFile', 'quantity', 'state', 'createdAt'],
filters: ['state', '_id', 'gcodeFile', 'quantity'], filters: ['state', '_id', 'gcodeFile', 'quantity'],
sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'], sorters: ['createdAt', 'state', 'quantity', 'gcodeFile'],
properties: [ properties: [
@ -78,7 +77,6 @@ export const Job = {
name: 'quantity', name: 'quantity',
label: 'Quantity', label: 'Quantity',
type: 'number', type: 'number',
columnFixed: 'left',
columnWidth: 125, columnWidth: 125,
required: true required: true
}, },
@ -105,37 +103,10 @@ export const Job = {
name: 'gcodeFile', name: 'gcodeFile',
label: 'GCode File', label: 'GCode File',
type: 'object', type: 'object',
columnFixed: 'left',
objectType: 'gcodeFile', objectType: 'gcodeFile',
required: true, required: true,
showHyperlink: true showHyperlink: true
},
{
name: 'totalTime',
label: 'Total Time',
type: 'text',
readOnly: true,
value: (objectData) => {
if (!objectData?.startedAt || !objectData?.finishedAt) {
return '-'
}
const totalSeconds = dayjs(objectData?.finishedAt).diff(
dayjs(objectData?.startedAt),
'seconds'
)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`)
return parts.join(' ')
}
} }
], ],
stats: [ stats: [

View File

@ -49,21 +49,7 @@ export const OrderItem = {
type: 'dateTime', type: 'dateTime',
readOnly: true readOnly: true
}, },
{
name: '_reference',
label: 'Reference',
type: 'reference',
objectType: 'orderItem',
showCopy: true,
readOnly: true
},
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{ name: 'state', label: 'State', type: 'state', readOnly: true },
{ {
name: 'orderType', name: 'orderType',
label: 'Order Type', label: 'Order Type',
@ -71,7 +57,12 @@ export const OrderItem = {
masterFilter: ['purchaseOrder', 'salesOrder'], masterFilter: ['purchaseOrder', 'salesOrder'],
required: true required: true
}, },
{
name: 'updatedAt',
label: 'Updated At',
type: 'dateTime',
readOnly: true
},
{ {
name: 'order', name: 'order',
label: 'Order', label: 'Order',

View File

@ -1,7 +1,6 @@
import SubJobIcon from '../../components/Icons/SubJobIcon' import SubJobIcon from '../../components/Icons/SubJobIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import XMarkIcon from '../../components/Icons/XMarkIcon' import XMarkIcon from '../../components/Icons/XMarkIcon'
import dayjs from 'dayjs'
export const SubJob = { export const SubJob = {
name: 'subJob', name: 'subJob',
@ -69,6 +68,7 @@ export const SubJob = {
readOnly: true, readOnly: true,
columnWidth: 175 columnWidth: 175
}, },
{ {
name: 'moonrakerJobId', name: 'moonrakerJobId',
label: 'Moonraker Job ID', label: 'Moonraker Job ID',
@ -76,12 +76,28 @@ export const SubJob = {
columnWidth: 140, columnWidth: 140,
showCopy: true showCopy: true
}, },
{ {
name: 'startedAt', name: 'startedAt',
label: 'Started At', label: 'Started At',
type: 'dateTime', type: 'dateTime',
readOnly: true readOnly: true
}, },
{
name: 'createdPartStock',
label: 'Created Part Stock',
type: 'bool',
readOnly: true
},
{
name: 'finishedAt',
label: 'Finished At',
type: 'dateTime',
readOnly: true
},
{ {
name: 'job', name: 'job',
label: 'Job', label: 'Job',
@ -89,12 +105,7 @@ export const SubJob = {
objectType: 'job', objectType: 'job',
showHyperlink: true showHyperlink: true
}, },
{
name: 'finishedAt',
label: 'Finished At',
type: 'dateTime',
readOnly: true
},
{ {
name: 'printer', name: 'printer',
label: 'Printer', label: 'Printer',
@ -102,33 +113,6 @@ export const SubJob = {
columnFixed: 'left', columnFixed: 'left',
objectType: 'printer', objectType: 'printer',
showHyperlink: true showHyperlink: true
},
{
name: 'totalTime',
label: 'Total Time',
type: 'text',
readOnly: true,
value: (objectData) => {
if (!objectData?.startedAt || !objectData?.finishedAt) {
return '-'
}
const totalSeconds = dayjs(objectData?.finishedAt).diff(
dayjs(objectData?.startedAt),
'seconds'
)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`)
return parts.join(' ')
}
} }
] ]
} }

View File

@ -21,7 +21,7 @@
dependencies: dependencies:
lodash "^4.17.21" lodash "^4.17.21"
"@ant-design/charts@^2.6.5": "@ant-design/charts@^2.6.2":
version "2.6.6" version "2.6.6"
resolved "https://registry.yarnpkg.com/@ant-design/charts/-/charts-2.6.6.tgz#236430f56bc4a130ca3119c9cd14873f45d51d1c" resolved "https://registry.yarnpkg.com/@ant-design/charts/-/charts-2.6.6.tgz#236430f56bc4a130ca3119c9cd14873f45d51d1c"
integrity sha512-Mw2XqB9c7JoENyewJmtxU+5TU2sW5VEyct2f6n4HjJ/6hBo4ht3qdu965G3UrNLyiRctd47Qje32u+8DeFZ6Bg== integrity sha512-Mw2XqB9c7JoENyewJmtxU+5TU2sW5VEyct2f6n4HjJ/6hBo4ht3qdu965G3UrNLyiRctd47Qje32u+8DeFZ6Bg==
@ -1041,9 +1041,9 @@
optionalDependencies: optionalDependencies:
global-agent "^3.0.0" global-agent "^3.0.0"
"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2": "@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2":
version "10.2.0-electron.1" version "10.2.0-electron.1"
resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2" resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2"
dependencies: dependencies:
env-paths "^2.2.0" env-paths "^2.2.0"
exponential-backoff "^3.1.1" exponential-backoff "^3.1.1"