diff --git a/CHANGELOG.md b/CHANGELOG.md index cb864d9a2..07f1020b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [4.18.0](https://github.com/ydb-platform/ydb-embedded-ui/compare/v4.17.0...v4.18.0) (2023-09-25) + + +### Features + +* **ProgressViewer:** add custom threasholds to ProgressViewer ([#540](https://github.com/ydb-platform/ydb-embedded-ui/issues/540)) ([3553065](https://github.com/ydb-platform/ydb-embedded-ui/commit/35530655581357f4a79c277a5bf9846b3befb784)) + + +### Bug Fixes + +* **Authentication:** enable page redirect ([#539](https://github.com/ydb-platform/ydb-embedded-ui/issues/539)) ([721883c](https://github.com/ydb-platform/ydb-embedded-ui/commit/721883cc7f4ca60e64d4a5f77b939dbb8e960855)) +* **Healthcheck:** add merge_records request param ([#538](https://github.com/ydb-platform/ydb-embedded-ui/issues/538)) ([6a47481](https://github.com/ydb-platform/ydb-embedded-ui/commit/6a474814f71c3318715a8ce638fd522a770d8038)) +* **Nodes:** use nodes endpoint by default ([#535](https://github.com/ydb-platform/ydb-embedded-ui/issues/535)) ([12d4fef](https://github.com/ydb-platform/ydb-embedded-ui/commit/12d4fefde7a6663bb1a11f46b4e94fb24b23e966)) +* rename flag for display metrics cards for database diagnostics ([#536](https://github.com/ydb-platform/ydb-embedded-ui/issues/536)) ([957e1fa](https://github.com/ydb-platform/ydb-embedded-ui/commit/957e1fafbbc43928498ae9e8d0bc119bcda5288d)) + ## [4.17.0](https://github.com/ydb-platform/ydb-embedded-ui/compare/v4.16.2...v4.17.0) (2023-09-18) diff --git a/package-lock.json b/package-lock.json index e224ca008..746d51ae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ydb-embedded-ui", - "version": "4.17.0", + "version": "4.18.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9def865a9..08789d43d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ydb-embedded-ui", - "version": "4.17.0", + "version": "4.18.0", "files": [ "dist" ], diff --git a/src/components/FullNodeViewer/FullNodeViewer.tsx b/src/components/FullNodeViewer/FullNodeViewer.tsx index e631b58b2..1477baf24 100644 --- a/src/components/FullNodeViewer/FullNodeViewer.tsx +++ b/src/components/FullNodeViewer/FullNodeViewer.tsx @@ -3,10 +3,10 @@ import cn from 'bem-cn-lite'; import type {TSystemStateInfo} from '../../types/api/nodes'; import {LOAD_AVERAGE_TIME_INTERVALS} from '../../utils/constants'; -import {calcUptime} from '../../utils'; +import {calcUptime} from '../../utils/dataFormatters/dataFormatters'; import InfoViewer from '../InfoViewer/InfoViewer'; -import ProgressViewer from '../ProgressViewer/ProgressViewer'; +import {ProgressViewer} from '../ProgressViewer/ProgressViewer'; import {PoolUsage} from '../PoolUsage/PoolUsage'; import './FullNodeViewer.scss'; diff --git a/src/components/InfoViewer/formatters/common.ts b/src/components/InfoViewer/formatters/common.ts index 10526b315..3f3e0dd3e 100644 --- a/src/components/InfoViewer/formatters/common.ts +++ b/src/components/InfoViewer/formatters/common.ts @@ -1,5 +1,5 @@ import type {TDirEntry} from '../../../types/api/schema'; -import {formatDateTime} from '../../../utils'; +import {formatDateTime} from '../../../utils/dataFormatters/dataFormatters'; import {createInfoFormatter} from '../utils'; diff --git a/src/components/InfoViewer/formatters/pqGroup.ts b/src/components/InfoViewer/formatters/pqGroup.ts index bbb80b79c..bac524ad8 100644 --- a/src/components/InfoViewer/formatters/pqGroup.ts +++ b/src/components/InfoViewer/formatters/pqGroup.ts @@ -4,7 +4,7 @@ import { TPQPartitionConfig, TPQTabletConfig, } from '../../../types/api/schema'; -import {formatBps, formatBytes, formatNumber} from '../../../utils'; +import {formatBps, formatBytes, formatNumber} from '../../../utils/dataFormatters/dataFormatters'; import {HOUR_IN_SECONDS} from '../../../utils/constants'; import {createInfoFormatter} from '../utils'; diff --git a/src/components/InfoViewer/formatters/table.ts b/src/components/InfoViewer/formatters/table.ts index e11c33302..919dbc356 100644 --- a/src/components/InfoViewer/formatters/table.ts +++ b/src/components/InfoViewer/formatters/table.ts @@ -1,6 +1,11 @@ import type {TFollowerGroup, TPartitionConfig, TTableStats} from '../../../types/api/schema'; import type {TMetrics} from '../../../types/api/tenant'; -import {formatCPU, formatNumber, formatBps, formatDateTime} from '../../../utils'; +import { + formatBps, + formatCPU, + formatDateTime, + formatNumber, +} from '../../../utils/dataFormatters/dataFormatters'; import {toFormattedSize} from '../../FormattedBytes/utils'; import {createInfoFormatter} from '../utils'; diff --git a/src/components/ProgressViewer/ProgressViewer.js b/src/components/ProgressViewer/ProgressViewer.js deleted file mode 100644 index c3c2c507c..000000000 --- a/src/components/ProgressViewer/ProgressViewer.js +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import cn from 'bem-cn-lite'; -import './ProgressViewer.scss'; -import PropTypes from 'prop-types'; -const b = cn('progress-viewer'); -/* - -Описание props: -1) value - величина прогресса -2) capacity - предельно возможный прогресс -3) formatValues - функция форматирования value и capacity -4) percents - отображать ли заполненость в виде процентов -5) className - кастомный класс -6) colorizeProgress - менять ли цвет полосы прогресса в зависимости от его величины -7) inverseColorize - инвертировать ли цвета при разукрашивании прогресса -*/ -export class ProgressViewer extends React.Component { - static propTypes = { - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - capacity: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - formatValues: PropTypes.func, - percents: PropTypes.bool, - className: PropTypes.string, - size: PropTypes.oneOf(['xs', 's', 'ns', 'm', 'n', 'l', 'head']), - colorizeProgress: PropTypes.bool, - inverseColorize: PropTypes.bool, - }; - - static defaultProps = { - size: 'xs', - colorizeProgress: false, - capacity: 100, - inverseColorize: false, - }; - - render() { - const { - value, - capacity, - formatValues, - percents, - size, - className, - colorizeProgress, - inverseColorize, - } = this.props; - - let fillWidth = Math.round((parseFloat(value) / parseFloat(capacity)) * 100); - fillWidth = fillWidth > 100 ? 100 : fillWidth; - - let valueText = Math.round(value), - capacityText = capacity, - divider = '/'; - if (formatValues) { - [valueText, capacityText] = formatValues(value, capacity); - } else if (percents) { - valueText = fillWidth + '%'; - capacityText = ''; - divider = ''; - } - - let bg = inverseColorize ? 'scarlet' : 'apple'; - if (colorizeProgress) { - if (fillWidth > 60 && fillWidth <= 80) { - bg = 'saffron'; - } else if (fillWidth > 80) { - bg = inverseColorize ? 'apple' : 'scarlet'; - } - } - - const lineStyle = { - width: fillWidth + '%', - }; - - const text = fillWidth > 60 ? 'contrast0' : 'contrast70'; - - if (!isNaN(fillWidth)) { - return ( -
-
- - {`${valueText} ${divider} ${capacityText}`} - -
- ); - } - - return
no data
; - } -} - -export default ProgressViewer; diff --git a/src/components/ProgressViewer/ProgressViewer.tsx b/src/components/ProgressViewer/ProgressViewer.tsx new file mode 100644 index 000000000..597d31546 --- /dev/null +++ b/src/components/ProgressViewer/ProgressViewer.tsx @@ -0,0 +1,99 @@ +import cn from 'bem-cn-lite'; + +import type {ValueOf} from '../../types/common'; + +import './ProgressViewer.scss'; + +const b = cn('progress-viewer'); + +export const PROGRESS_VIEWER_SIZE_IDS = { + xs: 'xs', + s: 's', + ns: 'ns', + m: 'm', + n: 'n', + l: 'l', + head: 'head', +} as const; + +type ProgressViewerSize = ValueOf; + +/* + +Props description: +1) value - the amount of progress +2) capacity - maximum possible progress value +3) formatValues - function for formatting the value and capacity +4) percents - display progress in percents +5) colorizeProgress - change the color of the progress bar depending on its value +6) inverseColorize - invert the colors of the progress bar +7) warningThreshold - the percentage of fullness at which the color of the progress bar changes to yellow +8) dangerThreshold - the percentage of fullness at which the color of the progress bar changes to red +*/ + +interface ProgressViewerProps { + value?: number | string; + capacity?: number | string; + formatValues?: (value?: number, capacity?: number) => (string | undefined)[]; + percents?: boolean; + className?: string; + size?: ProgressViewerSize; + colorizeProgress?: boolean; + inverseColorize?: boolean; + warningThreshold?: number; + dangerThreshold?: number; +} + +export function ProgressViewer({ + value, + capacity = 100, + formatValues, + percents, + className, + size = PROGRESS_VIEWER_SIZE_IDS.xs, + colorizeProgress, + inverseColorize, + warningThreshold = 60, + dangerThreshold = 80, +}: ProgressViewerProps) { + let fillWidth = Math.round((parseFloat(String(value)) / parseFloat(String(capacity))) * 100); + fillWidth = fillWidth > 100 ? 100 : fillWidth; + let valueText: number | string | undefined = Math.round(Number(value)), + capacityText: number | string | undefined = capacity, + divider = '/'; + if (formatValues) { + [valueText, capacityText] = formatValues(Number(value), Number(capacity)); + } else if (percents) { + valueText = fillWidth + '%'; + capacityText = ''; + divider = ''; + } + + let bg = inverseColorize ? 'scarlet' : 'apple'; + if (colorizeProgress) { + if (fillWidth > warningThreshold && fillWidth <= dangerThreshold) { + bg = 'saffron'; + } else if (fillWidth > dangerThreshold) { + bg = inverseColorize ? 'apple' : 'scarlet'; + } + } + + const lineStyle = { + width: fillWidth + '%', + }; + + const text = fillWidth > 60 ? 'contrast0' : 'contrast70'; + + if (!isNaN(fillWidth)) { + return ( +
+
+ {`${valueText} ${divider} ${capacityText}`} +
+ ); + } + + return
no data
; +} diff --git a/src/components/TooltipsContent/TabletTooltipContent/TabletTooltipContent.tsx b/src/components/TooltipsContent/TabletTooltipContent/TabletTooltipContent.tsx index 176daf0d7..57ce5e89e 100644 --- a/src/components/TooltipsContent/TabletTooltipContent/TabletTooltipContent.tsx +++ b/src/components/TooltipsContent/TabletTooltipContent/TabletTooltipContent.tsx @@ -1,6 +1,6 @@ import type {TTabletStateInfo} from '../../../types/api/tablet'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; import {InfoViewer, createInfoFormatter, formatObject} from '../../InfoViewer'; const formatTablet = createInfoFormatter({ diff --git a/src/containers/AsideNavigation/AsideNavigation.tsx b/src/containers/AsideNavigation/AsideNavigation.tsx index 2061d4250..c14e90a0d 100644 --- a/src/containers/AsideNavigation/AsideNavigation.tsx +++ b/src/containers/AsideNavigation/AsideNavigation.tsx @@ -50,7 +50,9 @@ function YbdInternalUser({ydbUser, logout}: YbdInternalUserProps) { const history = useHistory(); const handleLoginClick = () => { - history.push(createHref(routes.auth, undefined, {returnUrl: encodeURI(location.href)})); + history.push( + createHref(routes.auth, undefined, {returnUrl: encodeURIComponent(location.href)}), + ); }; return ( diff --git a/src/containers/Authentication/Authentication.tsx b/src/containers/Authentication/Authentication.tsx index f70a4cea8..8ddd18d1a 100644 --- a/src/containers/Authentication/Authentication.tsx +++ b/src/containers/Authentication/Authentication.tsx @@ -1,12 +1,13 @@ import {KeyboardEvent, useEffect, useState} from 'react'; import {useDispatch} from 'react-redux'; -import {useHistory} from 'react-router'; +import {useHistory, useLocation} from 'react-router'; import cn from 'bem-cn-lite'; import {Button, TextInput, Icon, Link as ExternalLink} from '@gravity-ui/uikit'; import {authenticate} from '../../store/reducers/authentication/authentication'; import {useTypedSelector} from '../../utils/hooks'; +import {parseQuery} from '../../routes'; import ydbLogoIcon from '../../assets/icons/ydb.svg'; import showIcon from '../../assets/icons/show.svg'; @@ -18,13 +19,15 @@ import './Authentication.scss'; const b = cn('authentication'); interface AuthenticationProps { - returnUrl?: string; closable?: boolean; } -function Authentication({returnUrl, closable = false}: AuthenticationProps) { +function Authentication({closable = false}: AuthenticationProps) { const dispatch = useDispatch(); const history = useHistory(); + const location = useLocation(); + + const {returnUrl} = parseQuery(location); const {error} = useTypedSelector((state) => state.authentication); @@ -58,7 +61,14 @@ function Authentication({returnUrl, closable = false}: AuthenticationProps) { // typed dispatch required, remove error expectation after adding it dispatch(authenticate(login, pass)).then(() => { if (returnUrl) { - history.replace(decodeURI(returnUrl)); + const decodedUrl = decodeURIComponent(returnUrl.toString()); + + // to prevent page reload we use router history + // history navigates relative to origin + // so we remove origin to make it work properly + const url = new URL(decodedUrl); + const path = url.pathname + url.search; + history.replace(path); } }); }; diff --git a/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx b/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx index 252e33b6e..5624d023e 100644 --- a/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx +++ b/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx @@ -3,7 +3,7 @@ import block from 'bem-cn-lite'; import {Skeleton} from '@gravity-ui/uikit'; import EntityStatus from '../../../components/EntityStatus/EntityStatus'; -import ProgressViewer from '../../../components/ProgressViewer/ProgressViewer'; +import {ProgressViewer} from '../../../components/ProgressViewer/ProgressViewer'; import InfoViewer, {InfoViewerItem} from '../../../components/InfoViewer/InfoViewer'; import {Tags} from '../../../components/Tags'; import {Tablet} from '../../../components/Tablet'; @@ -16,7 +16,7 @@ import type {AdditionalClusterProps, ClusterLink} from '../../../types/additiona import type {VersionValue} from '../../../types/versions'; import type {TClusterInfo} from '../../../types/api/cluster'; import {backend, customBackend} from '../../../store'; -import {formatStorageValues} from '../../../utils'; +import {formatStorageValues} from '../../../utils/dataFormatters/dataFormatters'; import {useSetting, useTypedSelector} from '../../../utils/hooks'; import { CLUSTER_DEFAULT_TITLE, diff --git a/src/containers/Heatmap/Heatmap.tsx b/src/containers/Heatmap/Heatmap.tsx index 71ec1f96a..c75a41f93 100644 --- a/src/containers/Heatmap/Heatmap.tsx +++ b/src/containers/Heatmap/Heatmap.tsx @@ -7,7 +7,7 @@ import {Checkbox, Select} from '@gravity-ui/uikit'; import type {IHeatmapMetricValue} from '../../types/store/heatmap'; import {getTabletsInfo, setHeatmapOptions} from '../../store/reducers/heatmap'; import {showTooltip, hideTooltip} from '../../store/reducers/tooltip'; -import {formatNumber} from '../../utils'; +import {formatNumber} from '../../utils/dataFormatters/dataFormatters'; import {useAutofetcher, useTypedSelector} from '../../utils/hooks'; import {Loader} from '../../components/Loader'; diff --git a/src/containers/Heatmap/Histogram/Histogram.js b/src/containers/Heatmap/Histogram/Histogram.js index 843179bb1..54b2ec440 100644 --- a/src/containers/Heatmap/Histogram/Histogram.js +++ b/src/containers/Heatmap/Histogram/Histogram.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import cn from 'bem-cn-lite'; import {getColorRange, getCurrentMetricLimits} from '../util'; -import {formatNumber} from '../../../utils'; +import {formatNumber} from '../../../utils/dataFormatters/dataFormatters'; import './Histogram.scss'; diff --git a/src/containers/Node/NodeStructure/Pdisk.tsx b/src/containers/Node/NodeStructure/Pdisk.tsx index 1b86cc789..73cc33091 100644 --- a/src/containers/Node/NodeStructure/Pdisk.tsx +++ b/src/containers/Node/NodeStructure/Pdisk.tsx @@ -8,12 +8,12 @@ import DataTable, {Column, Settings} from '@gravity-ui/react-data-table'; import EntityStatus from '../../../components/EntityStatus/EntityStatus'; import InfoViewer from '../../../components/InfoViewer/InfoViewer'; -import ProgressViewer from '../../../components/ProgressViewer/ProgressViewer'; +import {ProgressViewer} from '../../../components/ProgressViewer/ProgressViewer'; import {Icon} from '../../../components/Icon'; import {Vdisk} from './Vdisk'; import {bytesToGB, pad9} from '../../../utils/utils'; -import {formatStorageValuesToGb} from '../../../utils'; +import {formatStorageValuesToGb} from '../../../utils/dataFormatters/dataFormatters'; import {getPDiskType} from '../../../utils/pdisk'; import {DEFAULT_TABLE_SETTINGS} from '../../../utils/constants'; diff --git a/src/containers/Node/NodeStructure/Vdisk.tsx b/src/containers/Node/NodeStructure/Vdisk.tsx index 54f2b4818..30b733da5 100644 --- a/src/containers/Node/NodeStructure/Vdisk.tsx +++ b/src/containers/Node/NodeStructure/Vdisk.tsx @@ -1,8 +1,11 @@ import React from 'react'; import cn from 'bem-cn-lite'; -import ProgressViewer from '../../../components/ProgressViewer/ProgressViewer'; -import {formatStorageValuesToGb, stringifyVdiskId} from '../../../utils'; +import {ProgressViewer} from '../../../components/ProgressViewer/ProgressViewer'; +import { + formatStorageValuesToGb, + stringifyVdiskId, +} from '../../../utils/dataFormatters/dataFormatters'; import {bytesToGB, bytesToSpeed} from '../../../utils/utils'; import EntityStatus from '../../../components/EntityStatus/EntityStatus'; import {valueIsDefined} from './NodeStructure'; diff --git a/src/containers/Nodes/getNodesColumns.tsx b/src/containers/Nodes/getNodesColumns.tsx index 5ac4ee696..023dc7ca0 100644 --- a/src/containers/Nodes/getNodesColumns.tsx +++ b/src/containers/Nodes/getNodesColumns.tsx @@ -2,12 +2,12 @@ import DataTable, {Column} from '@gravity-ui/react-data-table'; import {Popover} from '@gravity-ui/uikit'; import {PoolsGraph} from '../../components/PoolsGraph/PoolsGraph'; -import ProgressViewer from '../../components/ProgressViewer/ProgressViewer'; +import {ProgressViewer} from '../../components/ProgressViewer/ProgressViewer'; import {TabletsStatistic} from '../../components/TabletsStatistic'; import {NodeHostWrapper} from '../../components/NodeHostWrapper/NodeHostWrapper'; import {isSortableNodesProperty} from '../../utils/nodes'; -import {formatBytesToGigabyte} from '../../utils/index'; +import {formatBytesToGigabyte} from '../../utils/dataFormatters/dataFormatters'; import type {NodesPreparedEntity} from '../../store/reducers/nodes/types'; diff --git a/src/containers/Storage/PDisk/PDisk.tsx b/src/containers/Storage/PDisk/PDisk.tsx index 3df8ec675..fe022de1f 100644 --- a/src/containers/Storage/PDisk/PDisk.tsx +++ b/src/containers/Storage/PDisk/PDisk.tsx @@ -7,7 +7,7 @@ import {Stack} from '../../../components/Stack/Stack'; import routes, {createHref} from '../../../routes'; import {selectVDisksForPDisk} from '../../../store/reducers/storage/selectors'; import {TPDiskStateInfo, TPDiskState} from '../../../types/api/pdisk'; -import {stringifyVdiskId} from '../../../utils'; +import {stringifyVdiskId} from '../../../utils/dataFormatters/dataFormatters'; import {useTypedSelector} from '../../../utils/hooks'; import {getPDiskType} from '../../../utils/pdisk'; import {isFullVDiskData} from '../../../utils/storage'; diff --git a/src/containers/Storage/PDiskPopup/PDiskPopup.tsx b/src/containers/Storage/PDiskPopup/PDiskPopup.tsx index 5ed6323c9..bfe2d8e6e 100644 --- a/src/containers/Storage/PDiskPopup/PDiskPopup.tsx +++ b/src/containers/Storage/PDiskPopup/PDiskPopup.tsx @@ -9,7 +9,7 @@ import {InfoViewer, InfoViewerItem} from '../../../components/InfoViewer'; import {EFlag} from '../../../types/api/enums'; import {TPDiskStateInfo} from '../../../types/api/pdisk'; -import {getPDiskId} from '../../../utils'; +import {getPDiskId} from '../../../utils/dataFormatters/dataFormatters'; import {getPDiskType} from '../../../utils/pdisk'; import {bytesToGB} from '../../../utils/utils'; diff --git a/src/containers/Storage/StorageGroups/StorageGroups.tsx b/src/containers/Storage/StorageGroups/StorageGroups.tsx index 909d33cd2..bb308fcb7 100644 --- a/src/containers/Storage/StorageGroups/StorageGroups.tsx +++ b/src/containers/Storage/StorageGroups/StorageGroups.tsx @@ -10,7 +10,7 @@ import type {HandleSort} from '../../../utils/hooks/useTableSort'; import {VISIBLE_ENTITIES} from '../../../store/reducers/storage/constants'; import {bytesToGB, bytesToSpeed} from '../../../utils/utils'; -import {stringifyVdiskId} from '../../../utils'; +import {stringifyVdiskId} from '../../../utils/dataFormatters/dataFormatters'; import {getUsage, isFullVDiskData, isSortableStorageProperty} from '../../../utils/storage'; import shieldIcon from '../../../assets/icons/shield.svg'; diff --git a/src/containers/Storage/VDisk/VDisk.tsx b/src/containers/Storage/VDisk/VDisk.tsx index 5bf6fb4c4..270762382 100644 --- a/src/containers/Storage/VDisk/VDisk.tsx +++ b/src/containers/Storage/VDisk/VDisk.tsx @@ -8,7 +8,7 @@ import {InternalLink} from '../../../components/InternalLink'; import routes, {createHref} from '../../../routes'; import {EFlag} from '../../../types/api/enums'; import {EVDiskState, TVDiskStateInfo} from '../../../types/api/vdisk'; -import {stringifyVdiskId} from '../../../utils'; +import {stringifyVdiskId} from '../../../utils/dataFormatters/dataFormatters'; import {isFullVDiskData} from '../../../utils/storage'; import {STRUCTURE} from '../../Node/NodePages'; diff --git a/src/containers/Storage/VDiskPopup/VDiskPopup.tsx b/src/containers/Storage/VDiskPopup/VDiskPopup.tsx index 2e0f62384..2802a1272 100644 --- a/src/containers/Storage/VDiskPopup/VDiskPopup.tsx +++ b/src/containers/Storage/VDiskPopup/VDiskPopup.tsx @@ -9,7 +9,7 @@ import {InfoViewer, InfoViewerItem} from '../../../components/InfoViewer'; import {EFlag} from '../../../types/api/enums'; import type {TVDiskStateInfo} from '../../../types/api/vdisk'; -import {stringifyVdiskId} from '../../../utils'; +import {stringifyVdiskId} from '../../../utils/dataFormatters/dataFormatters'; import {bytesToGB, bytesToSpeed} from '../../../utils/utils'; import {isFullVDiskData} from '../../../utils/storage'; diff --git a/src/containers/Tablet/TabletInfo/TabletInfo.tsx b/src/containers/Tablet/TabletInfo/TabletInfo.tsx index ad581677d..8fd2f264e 100644 --- a/src/containers/Tablet/TabletInfo/TabletInfo.tsx +++ b/src/containers/Tablet/TabletInfo/TabletInfo.tsx @@ -5,7 +5,7 @@ import {Link as UIKitLink} from '@gravity-ui/uikit'; import {ETabletState, TTabletStateInfo} from '../../../types/api/tablet'; import {InfoViewer, InfoViewerItem} from '../../../components/InfoViewer'; import routes, {createHref} from '../../../routes'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; import {getDefaultNodePath} from '../../Node/NodePages'; import {b} from '../Tablet'; diff --git a/src/containers/Tablet/TabletTable/TabletTable.tsx b/src/containers/Tablet/TabletTable/TabletTable.tsx index 678b96c2b..fc027b967 100644 --- a/src/containers/Tablet/TabletTable/TabletTable.tsx +++ b/src/containers/Tablet/TabletTable/TabletTable.tsx @@ -4,7 +4,7 @@ import EntityStatus from '../../../components/EntityStatus/EntityStatus'; import {InternalLink} from '../../../components/InternalLink/InternalLink'; import type {ITabletPreparedHistoryItem} from '../../../types/store/tablet'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; import {getDefaultNodePath} from '../../Node/NodePages'; import {b} from '../Tablet'; diff --git a/src/containers/Tenant/Diagnostics/Consumers/TopicStats/ConsumersTopicStats.tsx b/src/containers/Tenant/Diagnostics/Consumers/TopicStats/ConsumersTopicStats.tsx index 0d3ab4923..d7a755a48 100644 --- a/src/containers/Tenant/Diagnostics/Consumers/TopicStats/ConsumersTopicStats.tsx +++ b/src/containers/Tenant/Diagnostics/Consumers/TopicStats/ConsumersTopicStats.tsx @@ -1,7 +1,7 @@ import block from 'bem-cn-lite'; import type {IPreparedTopicStats} from '../../../../../types/store/topic'; -import {formatMsToUptime} from '../../../../../utils'; +import {formatMsToUptime} from '../../../../../utils/dataFormatters/dataFormatters'; import {SpeedMultiMeter} from '../../../../../components/SpeedMultiMeter'; import './ConsumersTopicStats.scss'; diff --git a/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx b/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx index 03f33fe44..4ee394454 100644 --- a/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx +++ b/src/containers/Tenant/Diagnostics/Consumers/columns/columns.tsx @@ -6,7 +6,7 @@ import type {IPreparedConsumerData} from '../../../../../types/store/topic'; import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; import {SpeedMultiMeter} from '../../../../../components/SpeedMultiMeter'; import {InternalLink} from '../../../../../components/InternalLink'; -import {formatMsToUptime} from '../../../../../utils'; +import {formatMsToUptime} from '../../../../../utils/dataFormatters/dataFormatters'; import routes, {createHref} from '../../../../../routes'; import {TenantTabsGroups} from '../../../TenantPages'; diff --git a/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.tsx b/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.tsx index 1c0305860..588ccd504 100644 --- a/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.tsx +++ b/src/containers/Tenant/Diagnostics/DetailedOverview/DetailedOverview.tsx @@ -8,7 +8,7 @@ import type {EPathType} from '../../../../types/api/schema'; import type {AdditionalTenantsProps} from '../../../../types/additionalProps'; import {Icon} from '../../../../components/Icon'; import {useSetting} from '../../../../utils/hooks'; -import {ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN} from '../../../../utils/constants'; +import {DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS} from '../../../../utils/constants'; import Overview from '../Overview/Overview'; import {Healthcheck} from '../OldHealthcheck'; import {TenantOverview} from '../TenantOverview/TenantOverview'; @@ -32,7 +32,7 @@ function DetailedOverview(props: DetailedOverviewProps) { const {currentSchemaPath} = useSelector((state: any) => state.schema); - const [newTenantDiagnostics] = useSetting(ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN); + const [displayMetricsCards] = useSetting(DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS); const openModalHandler = () => { setIsModalVisible(true); @@ -59,7 +59,7 @@ function DetailedOverview(props: DetailedOverviewProps) { }; const renderTenantOverview = () => { - if (newTenantDiagnostics) { + if (displayMetricsCards) { return (
( - this.getPath('/viewer/json/healthcheck'), + this.getPath('/viewer/json/healthcheck?merge_records=true'), {tenant: database}, {concurrentId}, ); diff --git a/src/store/reducers/clusterNodes/clusterNodes.tsx b/src/store/reducers/clusterNodes/clusterNodes.tsx index 15eed0cce..5b81afb08 100644 --- a/src/store/reducers/clusterNodes/clusterNodes.tsx +++ b/src/store/reducers/clusterNodes/clusterNodes.tsx @@ -5,7 +5,7 @@ import {createRequestActionTypes, createApiRequest} from '../../utils'; import type {ClusterNodesAction, ClusterNodesState, PreparedClusterNode} from './types'; import '../../../services/api'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; export const FETCH_CLUSTER_NODES = createRequestActionTypes('cluster', 'FETCH_CLUSTER_NODES'); diff --git a/src/store/reducers/node/selectors.ts b/src/store/reducers/node/selectors.ts index efc4e486d..3b9b4a61c 100644 --- a/src/store/reducers/node/selectors.ts +++ b/src/store/reducers/node/selectors.ts @@ -1,7 +1,7 @@ import type {Selector} from 'reselect'; import {createSelector} from 'reselect'; -import {stringifyVdiskId} from '../../../utils'; +import {stringifyVdiskId} from '../../../utils/dataFormatters/dataFormatters'; import type { NodeStateSlice, diff --git a/src/store/reducers/nodes/selectors.ts b/src/store/reducers/nodes/selectors.ts index ea9ef03ac..b2f79e56f 100644 --- a/src/store/reducers/nodes/selectors.ts +++ b/src/store/reducers/nodes/selectors.ts @@ -1,7 +1,7 @@ import {Selector, createSelector} from 'reselect'; import {EFlag} from '../../../types/api/enums'; -import {calcUptimeInSeconds} from '../../../utils'; +import {calcUptimeInSeconds} from '../../../utils/dataFormatters/dataFormatters'; import {HOUR_IN_SECONDS} from '../../../utils/constants'; import {NodesUptimeFilterValues} from '../../../utils/nodes'; import {prepareSearchValue} from '../../../utils/filters'; diff --git a/src/store/reducers/nodes/types.ts b/src/store/reducers/nodes/types.ts index a90282243..13e16ee29 100644 --- a/src/store/reducers/nodes/types.ts +++ b/src/store/reducers/nodes/types.ts @@ -64,7 +64,7 @@ export interface NodesGeneralRequestParams extends NodesSortParams { uptime?: number; // return nodes with less uptime in seconds problems_only?: boolean; // return nodes with SystemState !== EFlag.Green - offser?: number; + offset?: number; limit?: number; } diff --git a/src/store/reducers/nodes/utils.ts b/src/store/reducers/nodes/utils.ts index 40c89229e..9838fe6a6 100644 --- a/src/store/reducers/nodes/utils.ts +++ b/src/store/reducers/nodes/utils.ts @@ -1,6 +1,6 @@ import type {TComputeInfo, TComputeNodeInfo} from '../../../types/api/compute'; import type {TNodesInfo} from '../../../types/api/nodes'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; import type {NodesHandledResponse, NodesPreparedEntity} from './types'; diff --git a/src/store/reducers/settings/settings.ts b/src/store/reducers/settings/settings.ts index cb76eb049..56f587263 100644 --- a/src/store/reducers/settings/settings.ts +++ b/src/store/reducers/settings/settings.ts @@ -15,7 +15,7 @@ import { LAST_USED_QUERY_ACTION_KEY, USE_BACKEND_PARAMS_FOR_TABLES_KEY, LANGUAGE_KEY, - ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN, + DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS, } from '../../../utils/constants'; import '../../../services/api'; import {parseJson} from '../../../utils/utils'; @@ -50,14 +50,14 @@ export const initialState = { [INVERTED_DISKS_KEY]: readSavedSettingsValue(INVERTED_DISKS_KEY, 'false'), [USE_NODES_ENDPOINT_IN_DIAGNOSTICS_KEY]: readSavedSettingsValue( USE_NODES_ENDPOINT_IN_DIAGNOSTICS_KEY, - 'false', + 'true', ), [ENABLE_ADDITIONAL_QUERY_MODES]: readSavedSettingsValue( ENABLE_ADDITIONAL_QUERY_MODES, 'false', ), - [ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN]: readSavedSettingsValue( - ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN, + [DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS]: readSavedSettingsValue( + DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS, 'false', ), [SAVED_QUERIES_KEY]: readSavedSettingsValue(SAVED_QUERIES_KEY, '[]'), diff --git a/src/store/reducers/storage/types.ts b/src/store/reducers/storage/types.ts index 7693b6432..fbf3f3f9f 100644 --- a/src/store/reducers/storage/types.ts +++ b/src/store/reducers/storage/types.ts @@ -63,7 +63,7 @@ export interface StorageSortParams { export interface StorageSortAndFilterParams extends StorageSortParams { filter?: string; // PoolName or GroupId - offser?: number; + offset?: number; limit?: number; } diff --git a/src/store/reducers/storage/utils.ts b/src/store/reducers/storage/utils.ts index 4dce0b429..620c752fc 100644 --- a/src/store/reducers/storage/utils.ts +++ b/src/store/reducers/storage/utils.ts @@ -9,7 +9,7 @@ import {TPDiskState} from '../../../types/api/pdisk'; import {EFlag} from '../../../types/api/enums'; import {getPDiskType} from '../../../utils/pdisk'; import {getUsage} from '../../../utils/storage'; -import {calcUptime} from '../../../utils'; +import {calcUptime} from '../../../utils/dataFormatters/dataFormatters'; import type {PreparedStorageGroup, PreparedStorageNode, PreparedStorageResponse} from './types'; diff --git a/src/store/reducers/tenants/utils.ts b/src/store/reducers/tenants/utils.ts index 9d6fc9930..68532fe08 100644 --- a/src/store/reducers/tenants/utils.ts +++ b/src/store/reducers/tenants/utils.ts @@ -1,6 +1,6 @@ import type {TTenant} from '../../../types/api/tenant'; import {formatBytes} from '../../../utils/bytesParsers'; -import {formatCPU} from '../../../utils/formatCPU/formatCPU'; +import {formatCPUWithLabel} from '../../../utils/dataFormatters/dataFormatters'; import {isNumeric} from '../../../utils/utils'; import {METRIC_STATUS} from './contants'; @@ -158,7 +158,7 @@ export const formatTenantMetrics = ({ storage?: number; memory?: number; }) => ({ - cpu: formatCPU(cpu), + cpu: formatCPUWithLabel(cpu), storage: formatBytes({value: storage, significantDigits: 2}) || undefined, memory: formatBytes({value: memory, significantDigits: 2}) || undefined, }); diff --git a/src/types/api/vdisk.ts b/src/types/api/vdisk.ts index 5344f6be3..516ea2280 100644 --- a/src/types/api/vdisk.ts +++ b/src/types/api/vdisk.ts @@ -99,7 +99,7 @@ interface TVDiskSatisfactionRank { LevelRank?: TRank; } -interface TVDiskID { +export interface TVDiskID { GroupID?: number; GroupGeneration?: number; Ring?: number; diff --git a/src/utils/bytesParsers/formatBytes.ts b/src/utils/bytesParsers/formatBytes.ts index 92f3f22e3..c3d74eceb 100644 --- a/src/utils/bytesParsers/formatBytes.ts +++ b/src/utils/bytesParsers/formatBytes.ts @@ -1,4 +1,4 @@ -import {formatNumber} from '..'; +import {formatNumber} from '../dataFormatters/dataFormatters'; import {GIGABYTE, KILOBYTE, MEGABYTE, TERABYTE} from '../constants'; import {isNumeric} from '../utils'; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f5d4a6043..58686bd81 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -88,7 +88,8 @@ export const LANGUAGE_KEY = 'language'; export const INVERTED_DISKS_KEY = 'invertedDisks'; export const USE_NODES_ENDPOINT_IN_DIAGNOSTICS_KEY = 'useNodesEndpointInDiagnostics'; export const ENABLE_ADDITIONAL_QUERY_MODES = 'enableAdditionalQueryModes'; -export const ENABLE_NEW_TENANT_DIAGNOSTICS_DESIGN = 'enableNewTenantDiagnosticsDesign'; +// Remain key name "enableNewTenantDiagnosticsDesign" for backward compatibility +export const DISPLAY_METRICS_CARDS_FOR_TENANT_DIAGNOSTICS = 'enableNewTenantDiagnosticsDesign'; export const SAVED_QUERIES_KEY = 'saved_queries'; export const ASIDE_HEADER_COMPACT_KEY = 'asideHeaderCompact'; export const QUERIES_HISTORY_KEY = 'queries_history'; diff --git a/src/utils/dataFormatters/dataFormatters.ts b/src/utils/dataFormatters/dataFormatters.ts new file mode 100644 index 000000000..b51f797b7 --- /dev/null +++ b/src/utils/dataFormatters/dataFormatters.ts @@ -0,0 +1,128 @@ +import {dateTimeParse} from '@gravity-ui/date-utils'; + +import type {TVDiskID, TVSlotId} from '../../types/api/vdisk'; +import {DAY_IN_SECONDS, GIGABYTE, TERABYTE} from '../constants'; +import {configuredNumeral} from '../numeral'; +import {isNumeric} from '../utils'; + +import i18n from './i18n'; + +// Here you can't control displayed size and precision +// If you need more custom format, use formatBytesCustom instead +export const formatBytes = (bytes?: string | number) => { + if (!isNumeric(bytes)) { + return ''; + } + + // by agreement, display byte values in decimal scale + return configuredNumeral(bytes).format('0 b'); +}; + +export const formatBps = (bytes?: string | number) => { + const formattedBytes = formatBytes(bytes); + + if (!formattedBytes) { + return ''; + } + + return formattedBytes + '/s'; +}; + +export const formatBytesToGigabyte = (bytes: number | string) => { + return `${Math.floor(Number(bytes) / GIGABYTE)} GB`; +}; + +export const stringifyVdiskId = (id?: TVDiskID | TVSlotId) => { + return id ? Object.values(id).join('-') : ''; +}; + +export const getPDiskId = (info: {NodeId?: number; PDiskId?: number}) => { + return info.NodeId && info.PDiskId ? `${info.NodeId}-${info.PDiskId}` : undefined; +}; + +export const formatUptime = (seconds: number) => { + const days = Math.floor(seconds / DAY_IN_SECONDS); + const remain = seconds % DAY_IN_SECONDS; + + const uptime = [days && `${days}d`, configuredNumeral(remain).format('00:00:00')] + .filter(Boolean) + .join(' '); + + return uptime; +}; + +export const formatMsToUptime = (ms?: number) => { + return ms && formatUptime(ms / 1000); +}; + +export const formatStorageValues = (value?: number, total?: number) => { + return [ + value ? String(Math.floor(value / TERABYTE)) : undefined, + total ? `${Math.floor(total / TERABYTE)} TB` : undefined, + ]; +}; +export const formatStorageValuesToGb = (value?: number, total?: number): (string | undefined)[] => { + return [ + value ? String(Math.floor(value / 1000000000)) : undefined, + total ? `${Math.floor(total / 1000000000)} GB` : undefined, + ]; +}; + +export const formatNumber = (number?: unknown) => { + if (!isNumeric(number)) { + return ''; + } + + return configuredNumeral(number).format(); +}; + +const normalizeCPU = (value: number | string) => { + const rawCores = Number(value) / 1000000; + let cores = rawCores.toPrecision(3); + if (rawCores >= 1000) { + cores = rawCores.toFixed(); + } + if (rawCores < 0.001) { + cores = '0'; + } + + return Number(cores); +}; + +export const formatCPU = (value?: number | string) => { + if (value === undefined) { + return undefined; + } + + return configuredNumeral(normalizeCPU(value)).format('0.[000]'); +}; + +export const formatCPUWithLabel = (value?: number) => { + if (value === undefined) { + return undefined; + } + const cores = normalizeCPU(value); + const localizedCores = configuredNumeral(cores).format('0.[000]'); + + return `${localizedCores} ${i18n('format-cpu.cores', {count: cores})}`; +}; + +export const formatDateTime = (value?: number | string) => { + if (!isNumeric(value)) { + return ''; + } + + const formattedData = dateTimeParse(Number(value))?.format('YYYY-MM-DD HH:mm'); + + return Number(value) > 0 && formattedData ? formattedData : 'N/A'; +}; + +export const calcUptimeInSeconds = (milliseconds: number | string) => { + const currentDate = new Date(); + const diff = currentDate.getTime() - Number(milliseconds); + return diff <= 0 ? 0 : diff / 1000; +}; + +export const calcUptime = (milliseconds?: number | string) => { + return formatUptime(calcUptimeInSeconds(Number(milliseconds))); +}; diff --git a/src/utils/dataFormatters/i18n/en.json b/src/utils/dataFormatters/i18n/en.json new file mode 100644 index 000000000..fffa73d78 --- /dev/null +++ b/src/utils/dataFormatters/i18n/en.json @@ -0,0 +1,3 @@ +{ + "format-cpu.cores": ["core", "cores", "cores", "cores"] +} diff --git a/src/utils/formatCPU/i18n/index.ts b/src/utils/dataFormatters/i18n/index.ts similarity index 100% rename from src/utils/formatCPU/i18n/index.ts rename to src/utils/dataFormatters/i18n/index.ts diff --git a/src/utils/dataFormatters/i18n/ru.json b/src/utils/dataFormatters/i18n/ru.json new file mode 100644 index 000000000..d0e8aa33a --- /dev/null +++ b/src/utils/dataFormatters/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "format-cpu.cores": ["ядро", "ядра", "ядер", "ядер"] +} diff --git a/src/utils/formatCPU/formatCPU.ts b/src/utils/formatCPU/formatCPU.ts deleted file mode 100644 index da6250066..000000000 --- a/src/utils/formatCPU/formatCPU.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {configuredNumeral} from '../numeral'; -import i18n from './i18n'; - -export const formatCPU = (value?: number) => { - if (value === undefined) { - return undefined; - } - - const rawCores = value / 1000000; - let cores = rawCores.toPrecision(3); - if (rawCores >= 1000) { - cores = rawCores.toFixed(); - } - if (rawCores < 0.001) { - cores = '0'; - } - const localizedCores = configuredNumeral(Number(cores)).format('0.[000]'); - - return `${localizedCores} ${i18n('cores', {count: cores})}`; -}; diff --git a/src/utils/formatCPU/i18n/en.json b/src/utils/formatCPU/i18n/en.json deleted file mode 100644 index c6e60e464..000000000 --- a/src/utils/formatCPU/i18n/en.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "cores": ["core", "cores", "cores", "cores"] -} diff --git a/src/utils/formatCPU/i18n/ru.json b/src/utils/formatCPU/i18n/ru.json deleted file mode 100644 index 7f5cb0d75..000000000 --- a/src/utils/formatCPU/i18n/ru.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "cores": ["ядро", "ядра", "ядер", "ядер"] -} diff --git a/src/utils/index.js b/src/utils/index.js index 6bde6c2de..2ec28dc49 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,105 +1,3 @@ -import {dateTimeParse} from '@gravity-ui/date-utils'; - -import {MEGABYTE, TERABYTE, GIGABYTE, DAY_IN_SECONDS} from './constants'; -import {isNumeric} from './utils'; -import {configuredNumeral} from './numeral'; - -// Here you can't control displayed size and precision -// If you need more custom format, use formatBytesCustom instead -export const formatBytes = (bytes) => { - if (!isNumeric(bytes)) { - return ''; - } - - // by agreement, display byte values in decimal scale - return configuredNumeral(bytes).format('0 b'); -}; - -export const formatBps = (bytes) => { - const formattedBytes = formatBytes(bytes); - - if (!formattedBytes) { - return ''; - } - - return formattedBytes + '/s'; -}; - -export const formatBytesToGigabyte = (bytes) => { - return `${Math.floor(bytes / GIGABYTE)} GB`; -}; - -export const stringifyVdiskId = (id) => { - return Object.values(id).join('-'); -}; -export const getPDiskId = (info) => { - return `${info.NodeId}-${info.PDiskId}`; -}; - -export const formatUptime = (seconds) => { - const days = Math.floor(seconds / DAY_IN_SECONDS); - const remain = seconds % DAY_IN_SECONDS; - - const uptime = [days && `${days}d`, configuredNumeral(remain).format('00:00:00')] - .filter(Boolean) - .join(' '); - - return uptime; -}; - -export const formatMsToUptime = (ms) => { - return formatUptime(ms / 1000); -}; - -export const formatIOPS = (value, capacity) => { - return [Math.floor(value), Math.floor(capacity) + ' IOPS']; -}; - -export const formatStorageValues = (value, total) => { - return [Math.floor(value / TERABYTE), `${Math.floor(total / TERABYTE)} TB`]; -}; -export const formatStorageValuesToGb = (value, total) => { - return [Math.floor(value / 1000000000), `${Math.floor(total / 1000000000)} GB`]; -}; - -export const formatThroughput = (value, total) => { - return [(value / MEGABYTE).toFixed(2), (total / MEGABYTE).toFixed(1) + ' MB/s']; -}; - -export const formatNumber = (number) => { - if (!isNumeric(number)) { - return ''; - } - - return configuredNumeral(number).format(); -}; - -export const formatCPU = (value) => { - if (!isNumeric(value)) { - return ''; - } - - return configuredNumeral(value / 1000000).format('0.00'); -}; - -export const formatDateTime = (value) => { - if (!isNumeric(value)) { - return ''; - } - - return value > 0 ? dateTimeParse(Number(value)).format('YYYY-MM-DD HH:mm') : 'N/A'; -}; - -export const calcUptimeInSeconds = (milliseconds) => { - const currentDate = new Date(); - const diff = currentDate - Number(milliseconds); - return diff <= 0 ? 0 : diff / 1000; -}; - -export const calcUptime = (milliseconds) => { - return formatUptime(calcUptimeInSeconds(milliseconds)); -}; - // determine how many nodes have status Connected "true" export const getConnectedNodesCount = (nodeStateInfo) => { return nodeStateInfo?.reduce((acc, item) => (item.Connected ? acc + 1 : acc), 0);