diff --git a/src/components/DiagnosticCard/DiagnosticCard.scss b/src/components/DiagnosticCard/DiagnosticCard.scss
index bf5bf8197d..b78262c024 100644
--- a/src/components/DiagnosticCard/DiagnosticCard.scss
+++ b/src/components/DiagnosticCard/DiagnosticCard.scss
@@ -1,7 +1,6 @@
.ydb-diagnostic-card {
flex-shrink: 0;
- width: 206px;
padding: 16px;
padding-bottom: 28px;
@@ -13,10 +12,24 @@
border-color: var(--g-color-base-info-medium);
background-color: var(--g-color-base-selection);
}
+ &_interactive {
+ &:hover {
+ cursor: pointer;
- &:hover {
- cursor: pointer;
+ box-shadow: 0px 1px 5px var(--g-color-sfx-shadow);
+ }
+ }
- box-shadow: 0px 1px 5px var(--g-color-sfx-shadow);
+ &_size_m {
+ width: 206px;
+ min-width: 206px;
+ }
+ &_size_l {
+ width: 289px;
+ min-width: 289px;
+ }
+ &_size_s {
+ width: 134px;
+ min-width: 134px;
}
}
diff --git a/src/components/DiagnosticCard/DiagnosticCard.tsx b/src/components/DiagnosticCard/DiagnosticCard.tsx
index 19a6681969..834c96f8f9 100644
--- a/src/components/DiagnosticCard/DiagnosticCard.tsx
+++ b/src/components/DiagnosticCard/DiagnosticCard.tsx
@@ -4,12 +4,20 @@ import './DiagnosticCard.scss';
const b = cn('ydb-diagnostic-card');
-interface DiagnosticCardProps {
+export interface DiagnosticCardProps {
children?: React.ReactNode;
className?: string;
active?: boolean;
+ size?: 'm' | 'l' | 's';
+ interactive?: boolean;
}
-export function DiagnosticCard({children, className, active}: DiagnosticCardProps) {
- return
{children}
;
+export function DiagnosticCard({
+ children,
+ className,
+ active,
+ size = 'm',
+ interactive = true,
+}: DiagnosticCardProps) {
+ return {children}
;
}
diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.scss b/src/components/DoughnutMetrics/DoughnutMetrics.scss
new file mode 100644
index 0000000000..9087310f7a
--- /dev/null
+++ b/src/components/DoughnutMetrics/DoughnutMetrics.scss
@@ -0,0 +1,58 @@
+.ydb-doughnut-metrics {
+ --doughnut-border: 11px;
+ --doughnut-color: var(--ydb-color-status-green);
+ &__doughnut {
+ position: relative;
+
+ width: 172px;
+ aspect-ratio: 1;
+
+ border-radius: 50%;
+ background-color: var(--doughnut-color);
+ &::before {
+ display: block;
+
+ height: calc(100% - calc(var(--doughnut-border) * 2));
+
+ content: '';
+
+ border-radius: 50%;
+ background-color: var(--g-color-base-background);
+
+ transform: translate(var(--doughnut-border), var(--doughnut-border));
+ aspect-ratio: 1;
+ }
+ }
+ &__doughnut_status_warning {
+ --doughnut-color: var(--ydb-color-status-yellow);
+ }
+ &__doughnut_status_danger {
+ --doughnut-color: var(--ydb-color-status-red);
+ }
+ &__text-wrapper {
+ --wrapper-indent: calc(var(--doughnut-border) + 5px);
+
+ position: absolute;
+ top: var(--wrapper-indent);
+ right: var(--wrapper-indent);
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ width: calc(100% - calc(var(--wrapper-indent) * 2));
+
+ text-align: center;
+ aspect-ratio: 1;
+ }
+ &__value {
+ position: absolute;
+ bottom: 20px;
+ }
+ &__legend {
+ height: 50%;
+
+ white-space: pre-wrap;
+ }
+}
diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.tsx b/src/components/DoughnutMetrics/DoughnutMetrics.tsx
new file mode 100644
index 0000000000..b596d8936a
--- /dev/null
+++ b/src/components/DoughnutMetrics/DoughnutMetrics.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+
+import type {TextProps} from '@gravity-ui/uikit';
+import {Text} from '@gravity-ui/uikit';
+
+import {cn} from '../../utils/cn';
+import type {ProgressStatus} from '../../utils/progress';
+
+import './DoughnutMetrics.scss';
+
+const b = cn('ydb-doughnut-metrics');
+
+interface LegendProps {
+ children?: React.ReactNode;
+ variant?: TextProps['variant'];
+}
+
+function Legend({children, variant = 'subheader-3'}: LegendProps) {
+ return (
+
+ {children}
+
+ );
+}
+function Value({children, variant = 'subheader-2'}: LegendProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface DoughnutProps {
+ status: ProgressStatus;
+ fillWidth: number;
+ children?: React.ReactNode;
+ className?: string;
+}
+
+export function DoughnutMetrics({status, fillWidth, children, className}: DoughnutProps) {
+ let gradientFill = 'var(--g-color-line-generic-solid)';
+ let filledDegrees = fillWidth * 3.6 - 90;
+
+ if (fillWidth > 50) {
+ gradientFill = 'var(--doughnut-color)';
+ filledDegrees = fillWidth * 3.6 + 90;
+ }
+ const gradientDegrees = filledDegrees;
+ return (
+
+ );
+}
+
+DoughnutMetrics.Legend = Legend;
+DoughnutMetrics.Value = Value;
diff --git a/src/components/ProgressViewer/ProgressViewer.scss b/src/components/ProgressViewer/ProgressViewer.scss
index 4ca04704a4..02f9d600cd 100644
--- a/src/components/ProgressViewer/ProgressViewer.scss
+++ b/src/components/ProgressViewer/ProgressViewer.scss
@@ -11,7 +11,7 @@
justify-content: center;
align-items: center;
- min-width: 120px;
+ min-width: 150px;
height: 23px;
padding: 0 4px;
diff --git a/src/components/ProgressViewer/ProgressViewer.tsx b/src/components/ProgressViewer/ProgressViewer.tsx
index 81505cc378..6f33d6492e 100644
--- a/src/components/ProgressViewer/ProgressViewer.tsx
+++ b/src/components/ProgressViewer/ProgressViewer.tsx
@@ -2,6 +2,7 @@ import {useTheme} from '@gravity-ui/uikit';
import {cn} from '../../utils/cn';
import {formatNumber, roundToPrecision} from '../../utils/dataFormatters/dataFormatters';
+import {calculateProgressStatus} from '../../utils/progress';
import {isNumeric} from '../../utils/utils';
import './ProgressViewer.scss';
@@ -10,8 +11,6 @@ const b = cn('progress-viewer');
type ProgressViewerSize = 'xs' | 's' | 'ns' | 'm' | 'n' | 'l' | 'head';
-type ProgressViewerStatus = 'good' | 'warning' | 'danger';
-
type FormatProgressViewerValues = (
value?: number,
capacity?: number,
@@ -79,16 +78,15 @@ export function ProgressViewer({
[valueText, capacityText] = formatValues(Number(value), Number(capacity));
}
- let status: ProgressViewerStatus = inverseColorize ? 'danger' : 'good';
- if (colorizeProgress) {
- if (fillWidth > warningThreshold && fillWidth <= dangerThreshold) {
- status = 'warning';
- } else if (fillWidth > dangerThreshold) {
- status = inverseColorize ? 'good' : 'danger';
- }
- if (!isNumeric(capacity)) {
- fillWidth = 100;
- }
+ const status = calculateProgressStatus({
+ fillWidth,
+ warningThreshold,
+ dangerThreshold,
+ colorizeProgress,
+ inverseColorize,
+ });
+ if (colorizeProgress && !isNumeric(capacity)) {
+ fillWidth = 100;
}
const lineStyle = {
diff --git a/src/components/Tag/Tag.scss b/src/components/Tag/Tag.scss
index bc3747eaa6..04d131492d 100644
--- a/src/components/Tag/Tag.scss
+++ b/src/components/Tag/Tag.scss
@@ -1,9 +1,8 @@
.tag {
- margin-right: 5px;
padding: 2px 5px;
font-size: 12px;
- text-transform: uppercase;
+ white-space: nowrap;
color: var(--g-color-text-primary);
border-radius: 3px;
diff --git a/src/components/Tags/Tags.tsx b/src/components/Tags/Tags.tsx
index 9a0c61d2b3..3030b45f37 100644
--- a/src/components/Tags/Tags.tsx
+++ b/src/components/Tags/Tags.tsx
@@ -1,24 +1,23 @@
import React from 'react';
-import {cn} from '../../utils/cn';
+import type {FlexProps} from '@gravity-ui/uikit';
+import {Flex} from '@gravity-ui/uikit';
+
import type {TagType} from '../Tag';
import {Tag} from '../Tag';
-import './Tags.scss';
-
-const b = cn('tags');
-
interface TagsProps {
tags: React.ReactNode[];
tagsType?: TagType;
className?: string;
+ gap?: FlexProps['gap'];
}
-export const Tags = ({tags, tagsType, className = ''}: TagsProps) => {
+export const Tags = ({tags, tagsType, className = '', gap = 1}: TagsProps) => {
return (
-
+
{tags &&
tags.map((tag, tagIndex) => )}
-
+
);
};
diff --git a/src/containers/Cluster/Cluster.scss b/src/containers/Cluster/Cluster.scss
index b575284482..29c31f5176 100644
--- a/src/containers/Cluster/Cluster.scss
+++ b/src/containers/Cluster/Cluster.scss
@@ -27,27 +27,50 @@
height: var(--g-text-header-1-line-height);
}
- &__tabs {
- position: sticky;
- left: 0;
+ &__tabs-sticky-wrapper {
+ z-index: 3;
+ margin-top: 20px;
+ margin-right: -20px;
+ padding-right: 20px;
+ @include sticky-top();
+ }
+ &__tabs {
display: flex;
- justify-content: space-between;
- align-items: center;
@include tabs-wrapper-styles();
}
&__sticky-wrapper {
position: sticky;
z-index: 4;
- top: 56px;
+ top: 66px;
left: 0;
}
&__auto-refresh-control {
float: right;
- margin-top: -40px;
+ margin-top: -46px;
+
+ background-color: var(--g-color-base-background);
+ }
+ .ydb-table-with-controls-layout__controls-wrapper {
+ top: 40px;
+ }
+
+ &__tablets {
+ .data-table__sticky_moving {
+ // Place table head right after controls
+ top: 60px !important;
+ }
+ }
+
+ &__fake-block {
+ position: sticky;
+ z-index: 3;
+ top: 40px;
+
+ height: 20px;
background-color: var(--g-color-base-background);
}
diff --git a/src/containers/Cluster/Cluster.tsx b/src/containers/Cluster/Cluster.tsx
index 5a31ae98a7..0b1c353fa4 100644
--- a/src/containers/Cluster/Cluster.tsx
+++ b/src/containers/Cluster/Cluster.tsx
@@ -31,6 +31,7 @@ import {TabletsTable} from '../Tablets/TabletsTable';
import {Tenants} from '../Tenants/Tenants';
import {Versions} from '../Versions/Versions';
+import {ClusterDashboard} from './ClusterDashboard/ClusterDashboard';
import {ClusterInfo} from './ClusterInfo/ClusterInfo';
import type {ClusterTab} from './utils';
import {clusterTabs, clusterTabsIds, getClusterPath, isClusterTab} from './utils';
@@ -119,7 +120,11 @@ export function Cluster({
{activeTab ? {activeTab.title} : null}
{getClusterTitle()}
-
+
+
+
-
+ {formatNumber(value)}
+
+ );
+}
+
+interface ClusterDashboardProps {
+ cluster: TClusterInfo;
+ groupStats?: ClusterGroupsStats;
+ loading?: boolean;
+}
+
+export function ClusterDashboard(props: ClusterDashboardProps) {
+ return (
+
+ );
+}
+
+function ClusterDoughnuts({cluster, loading}: ClusterDashboardProps) {
+ if (loading) {
+ return ;
+ }
+ const metricsCards = [];
+ if (isClusterInfoV2(cluster)) {
+ const {CoresUsed, NumberOfCpus} = cluster;
+ if (valueIsDefined(CoresUsed) && valueIsDefined(NumberOfCpus)) {
+ metricsCards.push(
+ ,
+ );
+ }
+ }
+ const {StorageTotal, StorageUsed} = cluster;
+ if (valueIsDefined(StorageTotal) && valueIsDefined(StorageUsed)) {
+ metricsCards.push(
+ ,
+ );
+ }
+ const {MemoryTotal, MemoryUsed} = cluster;
+ if (valueIsDefined(MemoryTotal) && valueIsDefined(MemoryUsed)) {
+ metricsCards.push(
+ ,
+ );
+ }
+ return metricsCards;
+}
+
+function ClusterDashboardCards({cluster, groupStats = {}, loading}: ClusterDashboardProps) {
+ if (loading) {
+ return null;
+ }
+ const cards = [];
+
+ const nodesRoles = getNodesRolesInfo(cluster);
+ cards.push(
+
+
+
+
+ {nodesRoles?.length ? : null}
+
+ ,
+ );
+
+ if (Object.keys(groupStats).length) {
+ const tags = getStorageGroupStats(groupStats);
+ const total = getTotalStorageGroupsUsed(groupStats);
+ cards.push(
+
+
+
+
+
+ ,
+ );
+ }
+
+ const dataCenters = getDCInfo(cluster);
+ if (dataCenters?.length) {
+ cards.push(
+
+
+
+
+
+ ,
+ );
+ }
+
+ if (cluster.Tenants) {
+ cards.push(
+
+
+ ,
+ );
+ }
+ return cards;
+}
diff --git a/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCard.tsx b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCard.tsx
new file mode 100644
index 0000000000..60fe1bd6b1
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCard.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+
+import {Text} from '@gravity-ui/uikit';
+
+import type {DiagnosticCardProps} from '../../../../components/DiagnosticCard/DiagnosticCard';
+import {DiagnosticCard} from '../../../../components/DiagnosticCard/DiagnosticCard';
+import {DoughnutMetrics} from '../../../../components/DoughnutMetrics/DoughnutMetrics';
+import {Skeleton} from '../../../../components/Skeleton/Skeleton';
+import type {ProgressStatus} from '../../../../utils/progress';
+import {b} from '../shared';
+
+interface ClusterMetricsDougnutCardProps extends ClusterMetricsCommonCardProps {
+ status: ProgressStatus;
+ fillWidth: number;
+}
+
+interface ClusterMetricsCommonCardProps {
+ children?: React.ReactNode;
+ title?: string;
+ size?: DiagnosticCardProps['size'];
+ className?: string;
+}
+
+export function ClusterMetricsCard({
+ children,
+ title,
+ size,
+ className,
+}: ClusterMetricsCommonCardProps) {
+ return (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+export function ClusterMetricsCardDoughnut({
+ title,
+ children,
+ size,
+ ...rest
+}: ClusterMetricsDougnutCardProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function ClusterMetricsCardSkeleton() {
+ return (
+
+
+
+ );
+}
+
+export function ClusterDashboardSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCores.tsx b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCores.tsx
new file mode 100644
index 0000000000..5f80ba2617
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsCores.tsx
@@ -0,0 +1,28 @@
+import {DoughnutMetrics} from '../../../../components/DoughnutMetrics/DoughnutMetrics';
+import {formatNumberCustom} from '../../../../utils/dataFormatters/dataFormatters';
+import i18n from '../../i18n';
+import type {ClusterMetricsCommonProps} from '../shared';
+import {useDiagramValues} from '../utils';
+
+import {ClusterMetricsCardDoughnut} from './ClusterMetricsCard';
+
+interface ClusterMetricsCoresProps extends ClusterMetricsCommonProps {}
+
+function formatCoresLegend({value, capacity}: {value: number; capacity: number}) {
+ return `${formatNumberCustom(value)} / ${formatNumberCustom(capacity)}\n${i18n('context_cores')}`;
+}
+
+export function ClusterMetricsCores({value, capacity, ...rest}: ClusterMetricsCoresProps) {
+ const {status, percents, legend, fill} = useDiagramValues({
+ value,
+ capacity,
+ legendFormatter: formatCoresLegend,
+ ...rest,
+ });
+ return (
+
+ {legend}
+ {percents}
+
+ );
+}
diff --git a/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsMemory.tsx b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsMemory.tsx
new file mode 100644
index 0000000000..7959395bdb
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsMemory.tsx
@@ -0,0 +1,30 @@
+import {DoughnutMetrics} from '../../../../components/DoughnutMetrics/DoughnutMetrics';
+import {formatStorageValues} from '../../../../utils/dataFormatters/dataFormatters';
+import i18n from '../../i18n';
+import type {ClusterMetricsCommonProps} from '../shared';
+import {useDiagramValues} from '../utils';
+
+import {ClusterMetricsCardDoughnut} from './ClusterMetricsCard';
+
+interface ClusterMetricsMemoryProps extends ClusterMetricsCommonProps {}
+
+function formatStorageLegend({value, capacity}: {value: number; capacity: number}) {
+ const formatted = formatStorageValues(value, capacity, undefined, '\n');
+ return `${formatted[0]} / ${formatted[1]}`;
+}
+
+export function ClusterMetricsMemory({value, capacity, ...rest}: ClusterMetricsMemoryProps) {
+ const {status, percents, legend, fill} = useDiagramValues({
+ value,
+ capacity,
+ legendFormatter: formatStorageLegend,
+ ...rest,
+ });
+
+ return (
+
+ {legend}
+ {percents}
+
+ );
+}
diff --git a/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsStorage.tsx b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsStorage.tsx
new file mode 100644
index 0000000000..07cb6e5393
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/components/ClusterMetricsStorage.tsx
@@ -0,0 +1,30 @@
+import {DoughnutMetrics} from '../../../../components/DoughnutMetrics/DoughnutMetrics';
+import {formatStorageValues} from '../../../../utils/dataFormatters/dataFormatters';
+import i18n from '../../i18n';
+import type {ClusterMetricsCommonProps} from '../shared';
+import {useDiagramValues} from '../utils';
+
+import {ClusterMetricsCardDoughnut} from './ClusterMetricsCard';
+
+interface ClusterMetricsStorageProps extends ClusterMetricsCommonProps {}
+
+function formatStorageLegend({value, capacity}: {value: number; capacity: number}) {
+ const formatted = formatStorageValues(value, capacity, undefined, '\n');
+ return `${formatted[0]} / ${formatted[1]}`;
+}
+
+export function ClusterMetricsStorage({value, capacity, ...rest}: ClusterMetricsStorageProps) {
+ const {status, percents, legend, fill} = useDiagramValues({
+ value,
+ capacity,
+ legendFormatter: formatStorageLegend,
+ ...rest,
+ });
+
+ return (
+
+ {legend}
+ {percents}
+
+ );
+}
diff --git a/src/containers/Cluster/ClusterDashboard/shared.ts b/src/containers/Cluster/ClusterDashboard/shared.ts
new file mode 100644
index 0000000000..23591c8fdc
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/shared.ts
@@ -0,0 +1,11 @@
+import {cn} from '../../../utils/cn';
+export const b = cn('ydb-cluster-dashboard');
+
+export interface ClusterMetricsCommonProps {
+ value: number | string;
+ capacity: number | string;
+ colorizeProgress?: boolean;
+ inverseColorize?: boolean;
+ warningThreshold?: number;
+ dangerThreshold?: number;
+}
diff --git a/src/containers/Cluster/ClusterDashboard/utils.tsx b/src/containers/Cluster/ClusterDashboard/utils.tsx
new file mode 100644
index 0000000000..823e04287b
--- /dev/null
+++ b/src/containers/Cluster/ClusterDashboard/utils.tsx
@@ -0,0 +1,97 @@
+import React from 'react';
+
+import type {ClusterGroupsStats} from '../../../store/reducers/cluster/types';
+import {isClusterInfoV2} from '../../../types/api/cluster';
+import type {TClusterInfo} from '../../../types/api/cluster';
+import {formatNumber, formatPercent} from '../../../utils/dataFormatters/dataFormatters';
+import {calculateProgressStatus} from '../../../utils/progress';
+import {DiskGroupsErasureStats} from '../ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars';
+
+import type {ClusterMetricsCommonProps} from './shared';
+
+export function useDiagramValues({
+ value,
+ capacity,
+ colorizeProgress = true,
+ warningThreshold = 60,
+ dangerThreshold = 80,
+ inverseColorize = false,
+ legendFormatter,
+}: ClusterMetricsCommonProps & {
+ legendFormatter: (params: {value: number; capacity: number}) => string;
+}) {
+ const parsedValue = parseFloat(String(value));
+ const parsedCapacity = parseFloat(String(capacity));
+ let fillWidth = (parsedValue / parsedCapacity) * 100 || 0;
+ fillWidth = fillWidth > 100 ? 100 : fillWidth;
+ const normalizedFillWidth = fillWidth < 1 ? 0.5 : fillWidth;
+ const status = calculateProgressStatus({
+ fillWidth,
+ warningThreshold,
+ dangerThreshold,
+ colorizeProgress,
+ inverseColorize,
+ });
+
+ const percents = formatPercent(fillWidth / 100);
+ const legend = legendFormatter({
+ value: parsedValue,
+ capacity: parsedCapacity,
+ });
+
+ return {status, percents, legend, fill: normalizedFillWidth};
+}
+
+export function getDCInfo(cluster: TClusterInfo) {
+ if (isClusterInfoV2(cluster) && cluster.MapDataCenters) {
+ return Object.keys(cluster.MapDataCenters);
+ }
+ return cluster.DataCenters?.filter(Boolean);
+}
+
+const rolesToShow = ['storage', 'tenant'];
+
+export function getNodesRolesInfo(cluster: TClusterInfo) {
+ const nodesRoles: React.ReactNode[] = [];
+ if (isClusterInfoV2(cluster) && cluster.MapNodeRoles) {
+ for (const [role, count] of Object.entries(cluster.MapNodeRoles)) {
+ if (rolesToShow.includes(role.toLowerCase())) {
+ nodesRoles.push(
+
+ {role}: {formatNumber(count)}
+ ,
+ );
+ }
+ }
+ }
+ return nodesRoles;
+}
+
+export function getStorageGroupStats(groupStats: ClusterGroupsStats) {
+ const result: React.ReactNode[] = [];
+
+ Object.entries(groupStats).forEach(([storageType, stats]) => {
+ Object.values(stats).forEach((erasureStats) => {
+ result.push(
+
+ {storageType}: {formatNumber(erasureStats.createdGroups)} /{' '}
+ {formatNumber(erasureStats.totalGroups)}
+ ,
+ );
+ });
+ });
+ return result;
+}
+
+export const getTotalStorageGroupsUsed = (groupStats: ClusterGroupsStats) => {
+ return Object.values(groupStats).reduce((acc, data) => {
+ Object.values(data).forEach((erasureStats) => {
+ acc += erasureStats.createdGroups;
+ });
+
+ return acc;
+ }, 0);
+};
diff --git a/src/containers/Cluster/ClusterInfo/ClusterInfo.scss b/src/containers/Cluster/ClusterInfo/ClusterInfo.scss
index e8e3d29449..8db9a954b1 100644
--- a/src/containers/Cluster/ClusterInfo/ClusterInfo.scss
+++ b/src/containers/Cluster/ClusterInfo/ClusterInfo.scss
@@ -1,7 +1,7 @@
@import '../../../styles/mixins';
.cluster-info {
- padding-top: 20px;
+ padding: 20px 0;
&__skeleton {
margin-top: 5px;
@@ -11,16 +11,6 @@
@include body-2-typography();
}
- &__system-tablets {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
-
- & .tablet {
- margin-top: 2px;
- }
- }
-
&__metrics {
margin: 0 -15px;
padding: 0 15px !important;
@@ -53,11 +43,4 @@
margin-left: 5px;
}
- &__dc-count {
- text-transform: lowercase;
- }
- &__nodes-states {
- display: flex;
- gap: var(--g-spacing-half);
- }
}
diff --git a/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx b/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx
index c777365e12..d640abfd25 100644
--- a/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx
+++ b/src/containers/Cluster/ClusterInfo/ClusterInfo.tsx
@@ -1,24 +1,19 @@
import {ResponseError} from '../../../components/Errors/ResponseError';
import {InfoViewer} from '../../../components/InfoViewer/InfoViewer';
import {InfoViewerSkeleton} from '../../../components/InfoViewerSkeleton/InfoViewerSkeleton';
-import {backend, customBackend} from '../../../store';
-import type {ClusterGroupsStats} from '../../../store/reducers/cluster/types';
import type {AdditionalClusterProps} from '../../../types/additionalProps';
import type {TClusterInfo} from '../../../types/api/cluster';
import type {IResponseError} from '../../../types/api/error';
import type {VersionToColorMap} from '../../../types/versions';
-import {DEVELOPER_UI_TITLE} from '../../../utils/constants';
-import {useTypedSelector} from '../../../utils/hooks';
import {b} from './shared';
-import {getInfo, useGetVersionValues} from './utils';
+import {getInfo} from './utils';
import './ClusterInfo.scss';
interface ClusterInfoProps {
cluster?: TClusterInfo;
versionToColor?: VersionToColorMap;
- groupsStats?: ClusterGroupsStats;
loading?: boolean;
error?: IResponseError;
additionalClusterProps?: AdditionalClusterProps;
@@ -26,28 +21,13 @@ interface ClusterInfoProps {
export const ClusterInfo = ({
cluster,
- versionToColor,
- groupsStats = {},
loading,
error,
additionalClusterProps = {},
}: ClusterInfoProps) => {
- const singleClusterMode = useTypedSelector((state) => state.singleClusterMode);
-
- const versionsValues = useGetVersionValues(cluster, versionToColor);
-
- let internalLink = backend + '/internal';
-
- if (singleClusterMode && !customBackend) {
- internalLink = `/internal`;
- }
-
const {info = [], links = []} = additionalClusterProps;
- const clusterInfo = getInfo(cluster ?? {}, versionsValues, groupsStats, info, [
- {title: DEVELOPER_UI_TITLE, url: internalLink},
- ...links,
- ]);
+ const clusterInfo = getInfo(cluster ?? {}, info, links);
const getContent = () => {
if (loading) {
diff --git a/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.scss b/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.scss
index 1e7378a206..92647ff851 100644
--- a/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.scss
+++ b/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.scss
@@ -1,10 +1,5 @@
.ydb-disk-groups-stats {
- display: flex;
- flex-direction: column;
- gap: var(--g-spacing-3);
- &__bar {
- cursor: pointer;
- }
+ cursor: pointer;
&__popup-content {
padding: var(--g-spacing-3);
}
diff --git a/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.tsx b/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.tsx
index d9013dcede..f21d995287 100644
--- a/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.tsx
+++ b/src/containers/Cluster/ClusterInfo/components/DiskGroupsStatsBars/DiskGroupsStatsBars.tsx
@@ -1,10 +1,7 @@
+import {DefinitionList} from '@gravity-ui/components';
+
import {ContentWithPopup} from '../../../../../components/ContentWithPopup/ContentWithPopup';
-import {InfoViewer} from '../../../../../components/InfoViewer';
-import {ProgressViewer} from '../../../../../components/ProgressViewer/ProgressViewer';
-import type {
- DiskErasureGroupsStats,
- DiskGroupsStats,
-} from '../../../../../store/reducers/cluster/types';
+import type {DiskErasureGroupsStats} from '../../../../../store/reducers/cluster/types';
import {formatBytes, getSizeWithSignificantDigits} from '../../../../../utils/bytesParsers';
import {cn} from '../../../../../utils/cn';
import i18n from '../../../i18n';
@@ -14,25 +11,20 @@ import './DiskGroupsStatsBars.scss';
const b = cn('ydb-disk-groups-stats');
interface DiskGroupsStatsProps {
- stats: DiskGroupsStats;
+ stats: DiskErasureGroupsStats;
+ children: React.ReactNode;
}
-export const DiskGroupsStatsBars = ({stats}: DiskGroupsStatsProps) => {
+export const DiskGroupsErasureStats = ({stats, children}: DiskGroupsStatsProps) => {
return (
- {Object.values(stats).map((erasureStats) => (
-
}
- >
-
-
- ))}
+
}
+ >
+ {children}
+
);
};
@@ -53,26 +45,26 @@ function GroupsStatsPopupContent({stats}: GroupsStatsPopupContentProps) {
const info = [
{
- label: i18n('disk-type'),
- value: diskType,
+ name: i18n('disk-type'),
+ content: diskType,
},
{
- label: i18n('erasure'),
- value: erasure,
+ name: i18n('erasure'),
+ content: erasure,
},
{
- label: i18n('allocated'),
- value: convertedAllocatedSize,
+ name: i18n('allocated'),
+ content: convertedAllocatedSize,
},
{
- label: i18n('available'),
- value: convertedAvailableSize,
+ name: i18n('available'),
+ content: convertedAvailableSize,
},
{
- label: i18n('usage'),
- value: usage + '%',
+ name: i18n('usage'),
+ content: usage + '%',
},
];
- return ;
+ return ;
}
diff --git a/src/containers/Cluster/ClusterInfo/components/NodesState/NodesState.scss b/src/containers/Cluster/ClusterInfo/components/NodesState/NodesState.scss
index e32f77726b..be7cee3e10 100644
--- a/src/containers/Cluster/ClusterInfo/components/NodesState/NodesState.scss
+++ b/src/containers/Cluster/ClusterInfo/components/NodesState/NodesState.scss
@@ -5,11 +5,12 @@
justify-content: center;
align-items: center;
- min-width: 30px;
+ width: max-content;
+ min-width: 26px;
+ height: 20px;
padding: 0 var(--g-spacing-1);
color: var(--entity-state-font-color);
- border: 1px solid var(--entity-state-border-color);
border-radius: var(--g-spacing-1);
background-color: var(--entity-state-background-color);
@include entity-state-colors();
diff --git a/src/containers/Cluster/ClusterInfo/utils.tsx b/src/containers/Cluster/ClusterInfo/utils.tsx
index f3f56352cc..85fe01d5ec 100644
--- a/src/containers/Cluster/ClusterInfo/utils.tsx
+++ b/src/containers/Cluster/ClusterInfo/utils.tsx
@@ -1,30 +1,22 @@
import React from 'react';
-import {skipToken} from '@reduxjs/toolkit/query';
+import {Flex} from '@gravity-ui/uikit';
import type {InfoViewerItem} from '../../../components/InfoViewer/InfoViewer';
import {LinkWithIcon} from '../../../components/LinkWithIcon/LinkWithIcon';
import {ProgressViewer} from '../../../components/ProgressViewer/ProgressViewer';
-import {Tablet} from '../../../components/Tablet';
import {Tags} from '../../../components/Tags';
-import type {ClusterGroupsStats} from '../../../store/reducers/cluster/types';
-import {nodesApi} from '../../../store/reducers/nodes/nodes';
import type {ClusterLink} from '../../../types/additionalProps';
import {isClusterInfoV2} from '../../../types/api/cluster';
import type {TClusterInfo} from '../../../types/api/cluster';
import type {EFlag} from '../../../types/api/enums';
import type {TTabletStateInfo} from '../../../types/api/tablet';
import {EType} from '../../../types/api/tablet';
-import type {VersionToColorMap, VersionValue} from '../../../types/versions';
-import {formatStorageValues} from '../../../utils/dataFormatters/dataFormatters';
-import {parseNodeGroupsToVersionsValues, parseNodesToVersionsValues} from '../../../utils/versions';
-import {VersionsBar} from '../VersionsBar/VersionsBar';
+import {formatNumber} from '../../../utils/dataFormatters/dataFormatters';
import i18n from '../i18n';
-import {DiskGroupsStatsBars} from './components/DiskGroupsStatsBars/DiskGroupsStatsBars';
import {NodesState} from './components/NodesState/NodesState';
import {b} from './shared';
-
const COLORS_PRIORITY: Record = {
Green: 5,
Blue: 4,
@@ -46,92 +38,24 @@ export const compareTablets = (tablet1: TTabletStateInfo, tablet2: TTabletStateI
return 0;
};
-const getGroupsStatsFields = (groupsStats: ClusterGroupsStats) => {
- return Object.keys(groupsStats).map((diskType) => {
- return {
- label: i18n('storage-groups', {diskType}),
- value: ,
- };
- });
-};
-
const getDCInfo = (cluster: TClusterInfo) => {
if (isClusterInfoV2(cluster) && cluster.MapDataCenters) {
return Object.entries(cluster.MapDataCenters).map(([dc, count]) => (
- {dc}: {i18n('quantity', {count})}
+ {dc}: {formatNumber(count)}
));
}
- return cluster.DataCenters?.filter(Boolean);
-};
-
-const getStorageStats = (cluster: TClusterInfo) => {
- if (isClusterInfoV2(cluster) && cluster.MapDataCenters) {
- const {MapStorageTotal, MapStorageUsed} = cluster;
- const storageTypesSet = new Set(
- Object.keys(MapStorageTotal ?? []).concat(Object.keys(MapStorageUsed ?? [])),
- );
- if (storageTypesSet.size > 0) {
- return Array.from(storageTypesSet).reduce(
- (acc, storageType) => {
- acc[storageType] = {
- used: MapStorageUsed?.[storageType],
- total: MapStorageTotal?.[storageType],
- };
- return acc;
- },
- {} as Record,
- );
- }
- }
- return {_default: {used: cluster?.StorageUsed, total: cluster?.StorageTotal}};
+ return undefined;
};
export const getInfo = (
cluster: TClusterInfo,
- versionsValues: VersionValue[],
- groupsStats: ClusterGroupsStats,
additionalInfo: InfoViewerItem[],
links: ClusterLink[],
) => {
const info: InfoViewerItem[] = [];
- const dataCenters = getDCInfo(cluster);
-
- if (dataCenters?.length) {
- info.push({
- label: i18n('dc'),
- value: ,
- });
- }
-
- if (cluster.SystemTablets) {
- const tablets = cluster.SystemTablets.slice(0).sort(compareTablets);
- info.push({
- label: i18n('tablets'),
- value: (
-
- {tablets.map((tablet, tabletIndex) => (
-
- ))}
-
- ),
- });
- }
-
- if (cluster.Tenants) {
- info.push({
- label: i18n('databases'),
- value: cluster.Tenants,
- });
- }
-
- info.push({
- label: i18n('nodes'),
- value: ,
- });
-
if (isClusterInfoV2(cluster) && cluster.MapNodeStates) {
const arrayNodesStates = Object.entries(cluster.MapNodeStates) as [EFlag, number][];
// sort stack to achieve order "green, orange, yellow, red, blue, grey"
@@ -144,42 +68,28 @@ export const getInfo = (
);
});
info.push({
- label: i18n('nodes-state'),
- value: {nodesStates}
,
+ label: i18n('label_nodes-state'),
+ value: {nodesStates},
+ });
+ }
+
+ const dataCenters = getDCInfo(cluster);
+ if (dataCenters?.length) {
+ info.push({
+ label: i18n('label_dc'),
+ value: ,
});
}
info.push({
- label: i18n('load'),
+ label: i18n('label_load'),
value: ,
});
- const storageStats = getStorageStats(cluster);
+ info.push(...additionalInfo);
- Object.entries(storageStats).forEach(([type, stats]) => {
- let label = i18n('storage-size');
- if (type !== '_default') {
- label += `, ${type}`;
- }
+ if (links.length) {
info.push({
- label: label,
- value: (
-
- ),
- });
- });
-
- if (Object.keys(groupsStats).length) {
- info.push(...getGroupsStatsFields(groupsStats));
- }
-
- info.push(
- ...additionalInfo,
- {
label: i18n('links'),
value: (
@@ -188,51 +98,8 @@ export const getInfo = (
))}
),
- },
- {
- label: i18n('versions'),
- value: (
- el.title !== 'unknown')}
- />
- ),
- },
- );
+ });
+ }
return info;
};
-
-export const useGetVersionValues = (cluster?: TClusterInfo, versionToColor?: VersionToColorMap) => {
- const {currentData} = nodesApi.useGetNodesQuery(
- isClusterInfoV2(cluster)
- ? skipToken
- : {
- tablets: false,
- fieldsRequired: ['SystemState'],
- group: 'Version',
- },
- );
-
- const versionsValues = React.useMemo(() => {
- if (isClusterInfoV2(cluster) && cluster.MapVersions) {
- const groups = Object.entries(cluster.MapVersions).map(([version, count]) => ({
- name: version,
- count,
- }));
- return parseNodeGroupsToVersionsValues(groups, versionToColor, cluster.NodesTotal);
- }
- if (!currentData) {
- return [];
- }
- if (Array.isArray(currentData.NodeGroups)) {
- return parseNodeGroupsToVersionsValues(
- currentData.NodeGroups,
- versionToColor,
- cluster?.NodesTotal,
- );
- }
- return parseNodesToVersionsValues(currentData.Nodes, versionToColor);
- }, [currentData, versionToColor, cluster]);
-
- return versionsValues;
-};
diff --git a/src/containers/Cluster/i18n/en.json b/src/containers/Cluster/i18n/en.json
index cf0eba22d4..8ad4b9446f 100644
--- a/src/containers/Cluster/i18n/en.json
+++ b/src/containers/Cluster/i18n/en.json
@@ -4,19 +4,18 @@
"allocated": "Allocated",
"available": "Available",
"usage": "Usage",
- "dc": "DC",
- "tablets": "Tablets",
- "databases": "Databases",
- "nodes": "Nodes",
- "nodes-state": "Nodes state",
- "load": "Load",
+ "label_nodes-state": "Nodes state",
+ "label_dc": "Nodes data centers",
"storage-size": "Storage size",
"storage-groups": "Storage groups, {{diskType}}",
"links": "Links",
- "versions": "Versions",
- "quantity": {
- "one": "{{count}} node",
- "other": "{{count}} nodes",
- "zero": "no nodes"
- }
+ "context_cores": "cores",
+ "title_cpu": "CPU",
+ "title_storage": "Storage",
+ "title_memory": "Memory",
+ "label_nodes": "Nodes",
+ "label_hosts": "Hosts",
+ "label_storage-groups": "Storage groups",
+ "label_databases": "Databases",
+ "label_load": "Load"
}
diff --git a/src/types/api/cluster.ts b/src/types/api/cluster.ts
index 6a4c3e0edf..c30a25efe8 100644
--- a/src/types/api/cluster.ts
+++ b/src/types/api/cluster.ts
@@ -57,6 +57,9 @@ export interface TClusterInfoV2 extends TClusterInfoV1 {
MapDataCenters?: {
[key: string]: number;
};
+ MapNodeRoles?: {
+ [key: string]: number;
+ };
MapNodeStates?: Partial>;
/** value is uint64 */
MapStorageTotal?: {
diff --git a/src/utils/bytesParsers/__test__/formatBytes.test.ts b/src/utils/bytesParsers/__test__/formatBytes.test.ts
index e5cf024fb9..1a64ad3f0a 100644
--- a/src/utils/bytesParsers/__test__/formatBytes.test.ts
+++ b/src/utils/bytesParsers/__test__/formatBytes.test.ts
@@ -1,32 +1,51 @@
+import {UNBREAKABLE_GAP} from '../../utils';
import {formatBytes} from '../formatBytes';
describe('formatBytes', () => {
it('should work with only value', () => {
- expect(formatBytes({value: 100})).toBe('100 B');
- expect(formatBytes({value: 100_000})).toBe('100 KB');
- expect(formatBytes({value: 100_000_000})).toBe('100 MB');
- expect(formatBytes({value: 100_000_000_000})).toBe('100 GB');
- expect(formatBytes({value: 100_000_000_000_000})).toBe('100 TB');
+ expect(formatBytes({value: 100})).toBe(`100${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 100_000})).toBe(`100${UNBREAKABLE_GAP}KB`);
+ expect(formatBytes({value: 100_000_000})).toBe(`100${UNBREAKABLE_GAP}MB`);
+ expect(formatBytes({value: 100_000_000_000})).toBe(`100${UNBREAKABLE_GAP}GB`);
+ expect(formatBytes({value: 100_000_000_000_000})).toBe(`100${UNBREAKABLE_GAP}TB`);
});
it('should convert to size', () => {
- expect(formatBytes({value: 100_000, size: 'b'})).toBe('100 000 B');
- expect(formatBytes({value: 100_000_000_000_000, size: 'gb'})).toBe('100 000 GB');
+ expect(formatBytes({value: 100_000, size: 'b'})).toBe(
+ `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}B`,
+ );
+ expect(formatBytes({value: 100_000_000_000_000, size: 'gb'})).toBe(
+ `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}GB`,
+ );
});
it('should convert without labels', () => {
- expect(formatBytes({value: 100_000, size: 'b', withSizeLabel: false})).toBe('100 000');
+ expect(formatBytes({value: 100_000, size: 'b', withSizeLabel: false})).toBe(
+ `100${UNBREAKABLE_GAP}000`,
+ );
expect(formatBytes({value: 100_000_000_000_000, size: 'gb', withSizeLabel: false})).toBe(
- '100 000',
+ `100${UNBREAKABLE_GAP}000`,
);
});
it('should convert to speed', () => {
- expect(formatBytes({value: 100_000, withSpeedLabel: true})).toBe('100 KB/s');
- expect(formatBytes({value: 100_000, size: 'b', withSpeedLabel: true})).toBe('100 000 B/s');
+ expect(formatBytes({value: 100_000, withSpeedLabel: true})).toBe(
+ `100${UNBREAKABLE_GAP}KB/s`,
+ );
+ expect(formatBytes({value: 100_000, size: 'b', withSpeedLabel: true})).toBe(
+ `100${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}B/s`,
+ );
});
it('should return fixed amount of significant digits', () => {
- expect(formatBytes({value: 99_000, significantDigits: 2})).toEqual('99 000 B');
- expect(formatBytes({value: 100_000, significantDigits: 2})).toEqual('100 KB');
- expect(formatBytes({value: 99_000_000_000_000, significantDigits: 2})).toEqual('99 000 GB');
- expect(formatBytes({value: 100_000_000_000_000, significantDigits: 2})).toEqual('100 TB');
+ expect(formatBytes({value: 99_000, significantDigits: 2})).toEqual(
+ `99${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}B`,
+ );
+ expect(formatBytes({value: 100_000, significantDigits: 2})).toEqual(
+ `100${UNBREAKABLE_GAP}KB`,
+ );
+ expect(formatBytes({value: 99_000_000_000_000, significantDigits: 2})).toEqual(
+ `99${UNBREAKABLE_GAP}000${UNBREAKABLE_GAP}GB`,
+ );
+ expect(formatBytes({value: 100_000_000_000_000, significantDigits: 2})).toEqual(
+ `100${UNBREAKABLE_GAP}TB`,
+ );
});
it('should return empty string on invalid data', () => {
expect(formatBytes({value: undefined})).toEqual('');
@@ -36,12 +55,12 @@ describe('formatBytes', () => {
expect(formatBytes({value: '123qwe'})).toEqual('');
});
it('should work with precision', () => {
- expect(formatBytes({value: 123.123, precision: 2})).toBe('123 B');
- expect(formatBytes({value: 12.123, precision: 2})).toBe('12 B');
- expect(formatBytes({value: 1.123, precision: 2})).toBe('1.1 B');
- expect(formatBytes({value: 0.123, precision: 2})).toBe('0.12 B');
- expect(formatBytes({value: 0.012, precision: 2})).toBe('0.01 B');
- expect(formatBytes({value: 0.001, precision: 2})).toBe('0 B');
- expect(formatBytes({value: 0, precision: 2})).toBe('0 B');
+ expect(formatBytes({value: 123.123, precision: 2})).toBe(`123${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 12.123, precision: 2})).toBe(`12${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 1.123, precision: 2})).toBe(`1.1${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 0.123, precision: 2})).toBe(`0.12${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 0.012, precision: 2})).toBe(`0.01${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 0.001, precision: 2})).toBe(`0${UNBREAKABLE_GAP}B`);
+ expect(formatBytes({value: 0, precision: 2})).toBe(`0${UNBREAKABLE_GAP}B`);
});
});
diff --git a/src/utils/bytesParsers/formatBytes.ts b/src/utils/bytesParsers/formatBytes.ts
index c5d4745986..f6f9eca154 100644
--- a/src/utils/bytesParsers/formatBytes.ts
+++ b/src/utils/bytesParsers/formatBytes.ts
@@ -1,6 +1,6 @@
import {GIGABYTE, KILOBYTE, MEGABYTE, TERABYTE} from '../constants';
import {formatNumber, roundToPrecision} from '../dataFormatters/dataFormatters';
-import {isNumeric} from '../utils';
+import {UNBREAKABLE_GAP, isNumeric} from '../utils';
import i18n from './i18n';
@@ -84,8 +84,8 @@ const formatToSize = ({value, size = 'mb', precision = 0}: FormatToSizeArgs) =>
return formatNumber(result);
};
-const addSizeLabel = (result: string, size: BytesSizes) => {
- return result + ` ${sizes[size].label}`;
+const addSizeLabel = (result: string, size: BytesSizes, delimiter = UNBREAKABLE_GAP) => {
+ return result + delimiter + sizes[size].label;
};
const addSpeedLabel = (result: string, size: BytesSizes) => {
@@ -97,6 +97,7 @@ export type FormatBytesArgs = Omit & {
withSpeedLabel?: boolean;
withSizeLabel?: boolean;
significantDigits?: number;
+ delimiter?: string;
};
/**
@@ -108,6 +109,7 @@ export const formatBytes = ({
withSpeedLabel = false,
withSizeLabel = true,
significantDigits = 0,
+ delimiter,
...params
}: FormatBytesArgs) => {
if (!isNumeric(value)) {
@@ -125,7 +127,7 @@ export const formatBytes = ({
}
if (withSizeLabel) {
- return addSizeLabel(result, sizeToConvert);
+ return addSizeLabel(result, sizeToConvert, delimiter);
}
return result;
diff --git a/src/utils/dataFormatters/__test__/formatStorageValues.test.ts b/src/utils/dataFormatters/__test__/formatStorageValues.test.ts
new file mode 100644
index 0000000000..6d5a6ebf47
--- /dev/null
+++ b/src/utils/dataFormatters/__test__/formatStorageValues.test.ts
@@ -0,0 +1,44 @@
+import {UNBREAKABLE_GAP} from '../../utils';
+import {formatStorageValues} from '../dataFormatters';
+
+describe('formatStorageValues', () => {
+ it('should return ["", ""] when both value and total are undefined', () => {
+ const result = formatStorageValues();
+ expect(result).toEqual(['', '']);
+ });
+
+ it('should format value correctly when total is undefined', () => {
+ const result = formatStorageValues(1024);
+ expect(result).toEqual([`1${UNBREAKABLE_GAP}KB`, '']);
+ });
+
+ it('should format total correctly when value is undefined', () => {
+ const result = formatStorageValues(undefined, 2048);
+ expect(result).toEqual(['', `2${UNBREAKABLE_GAP}KB`]);
+ });
+
+ it('should format both value and total correctly', () => {
+ const result = formatStorageValues(1024, 2048);
+ expect(result).toEqual(['1', `2${UNBREAKABLE_GAP}KB`]);
+ });
+
+ it('should handle small value compared to total and increase precision', () => {
+ const result = formatStorageValues(1, 1024);
+ expect(result).toEqual(['0.001', `1${UNBREAKABLE_GAP}KB`]);
+ });
+
+ it('should return ["0", formattedTotal] when value is 0', () => {
+ const result = formatStorageValues(0, 2048);
+ expect(result).toEqual(['0', `2${UNBREAKABLE_GAP}KB`]);
+ });
+
+ it('should use provided size and delimiter', () => {
+ const result = formatStorageValues(5120, 10240, 'mb', '/');
+ expect(result).toEqual(['0.01', '0/MB']);
+ });
+
+ it('should handle non-numeric total gracefully', () => {
+ const result = formatStorageValues(2048, 'Not a number' as any);
+ expect(result).toEqual([`2${UNBREAKABLE_GAP}KB`, '']);
+ });
+});
diff --git a/src/utils/dataFormatters/dataFormatters.ts b/src/utils/dataFormatters/dataFormatters.ts
index cea9b2cc46..ab567744d0 100644
--- a/src/utils/dataFormatters/dataFormatters.ts
+++ b/src/utils/dataFormatters/dataFormatters.ts
@@ -52,7 +52,12 @@ export const formatMsToUptime = (ms?: number) => {
return ms && formatUptime(ms / 1000);
};
-export const formatStorageValues = (value?: number, total?: number, size?: BytesSizes) => {
+export const formatStorageValues = (
+ value?: number,
+ total?: number,
+ size?: BytesSizes,
+ delimiter?: string,
+) => {
let calculatedSize = getSizeWithSignificantDigits(Number(value), 0);
let valueWithSizeLabel = true;
let valuePrecision = 0;
@@ -63,13 +68,28 @@ export const formatStorageValues = (value?: number, total?: number, size?: Bytes
valuePrecision = 1;
}
- const formattedValue = formatBytesCustom({
+ let formattedValue = formatBytesCustom({
value,
withSizeLabel: valueWithSizeLabel,
size: size || calculatedSize,
precision: valuePrecision,
});
- const formattedTotal = formatBytesCustom({value: total, size: size || calculatedSize});
+ if (value && value > 0) {
+ while (formattedValue === '0') {
+ valuePrecision += 1;
+ formattedValue = formatBytesCustom({
+ value,
+ withSizeLabel: valueWithSizeLabel,
+ size: size || calculatedSize,
+ precision: valuePrecision,
+ });
+ }
+ }
+ const formattedTotal = formatBytesCustom({
+ value: total,
+ size: size || calculatedSize,
+ delimiter,
+ });
return [formattedValue, formattedTotal];
};
@@ -90,6 +110,21 @@ export const formatNumber = (number?: unknown) => {
// "," in format is delimiter sign, not delimiter itself
return configuredNumeral(number).format('0,0.[00000]');
};
+export const formatNumberCustom = (number?: number) => {
+ return configuredNumeral(number).format('0.[0]a');
+};
+export const formatPercent = (number?: unknown) => {
+ if (!isNumeric(number)) {
+ return '';
+ }
+ const configuredNumber = configuredNumeral(number);
+ const numberValue = configuredNumber.value();
+ let format = '0.[0]%';
+ if (numberValue && numberValue < 0.001) {
+ format = '0.[00]%';
+ }
+ return configuredNumber.format(format);
+};
export const formatSecondsToHours = (seconds: number) => {
const hours = (seconds / HOUR_IN_SECONDS).toFixed(2);
diff --git a/src/utils/numeral.ts b/src/utils/numeral.ts
index 2c2b74669c..1ae6acc13a 100644
--- a/src/utils/numeral.ts
+++ b/src/utils/numeral.ts
@@ -2,11 +2,12 @@ import numeral from 'numeral';
import 'numeral/locales'; // Without this numeral will throw an error when using not 'en' locale
import {Lang, i18n} from './i18n';
+import {UNBREAKABLE_GAP} from './utils';
// Set space delimiter for all locales possible in project
Object.values(Lang).forEach((value) => {
if (numeral.locales[value]) {
- numeral.locales[value].delimiters.thousands = ' ';
+ numeral.locales[value].delimiters.thousands = UNBREAKABLE_GAP;
}
});
diff --git a/src/utils/progress.ts b/src/utils/progress.ts
new file mode 100644
index 0000000000..9c2dfa45a0
--- /dev/null
+++ b/src/utils/progress.ts
@@ -0,0 +1,27 @@
+export type ProgressStatus = 'good' | 'warning' | 'danger';
+
+interface CalculateProgressStatusProps {
+ inverseColorize?: boolean;
+ dangerThreshold?: number;
+ warningThreshold?: number;
+ colorizeProgress?: boolean;
+ fillWidth: number;
+}
+
+export function calculateProgressStatus({
+ inverseColorize,
+ warningThreshold = 60,
+ dangerThreshold = 80,
+ colorizeProgress,
+ fillWidth,
+}: CalculateProgressStatusProps) {
+ let status: ProgressStatus = inverseColorize ? 'danger' : 'good';
+ if (colorizeProgress) {
+ if (fillWidth > warningThreshold && fillWidth <= dangerThreshold) {
+ status = 'warning';
+ } else if (fillWidth > dangerThreshold) {
+ status = inverseColorize ? 'good' : 'danger';
+ }
+ }
+ return status;
+}
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index f9d038b1e3..9c37ebca5d 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -104,3 +104,5 @@ export function isNumeric(value?: unknown): value is number | string {
export function toExponential(value: number, precision?: number) {
return Number(value).toExponential(precision);
}
+
+export const UNBREAKABLE_GAP = '\xa0';