diff --git a/src/containers/Cluster/ClusterOverview/utils.tsx b/src/containers/Cluster/ClusterOverview/utils.tsx index a84cc9d858..455da489cf 100644 --- a/src/containers/Cluster/ClusterOverview/utils.tsx +++ b/src/containers/Cluster/ClusterOverview/utils.tsx @@ -3,6 +3,24 @@ import {calculateProgressStatus} from '../../../utils/progress'; import type {ClusterMetricsBaseProps, ClusterMetricsCommonProps} from './shared'; +function parseDiagramValue(value: number | string) { + if (typeof value === 'string' && value.trim() === '') { + return NaN; + } + + const parsedValue = Number(value); + + return Number.isFinite(parsedValue) ? parsedValue : NaN; +} + +function calculateFillWidth(value: number, capacity: number) { + if (!Number.isFinite(value) || !Number.isFinite(capacity) || capacity <= 0) { + return 0; + } + + return (value / capacity) * 100; +} + export function calculateBaseDiagramValues({ colorizeProgress = true, warningThreshold, @@ -33,9 +51,9 @@ export function getDiagramValues({ }: ClusterMetricsCommonProps & { legendFormatter: (params: {value: number; capacity: number}) => string; }) { - const parsedValue = parseFloat(String(value)); - const parsedCapacity = parseFloat(String(capacity)); - const fillWidth = (parsedValue / parsedCapacity) * 100 || 0; + const parsedValue = parseDiagramValue(value); + const parsedCapacity = parseDiagramValue(capacity); + const fillWidth = calculateFillWidth(parsedValue, parsedCapacity); const legend = legendFormatter({ value: parsedValue, diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/MetricsTabs.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/MetricsTabs.tsx index a11233cdc2..da6939a3ae 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/MetricsTabs.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/MetricsTabs.tsx @@ -29,9 +29,39 @@ import './MetricsTabs.scss'; const b = cn('tenant-metrics-tabs'); +interface SelectStorageStatsForMetricCardParams { + blobStorageStats?: TenantStorageStats[]; + tabletStorageStats?: TenantStorageStats[]; + isServerless: boolean; +} + +export function selectStorageStatsForMetricCard({ + blobStorageStats, + tabletStorageStats, + isServerless, +}: SelectStorageStatsForMetricCardParams) { + if (isServerless) { + return tabletStorageStats || blobStorageStats || []; + } + + const hasLimit = (stats?: TenantStorageStats[]) => + Boolean(stats?.some((item) => Number(item.limit) > 0)); + + if (hasLimit(tabletStorageStats)) { + return tabletStorageStats || []; + } + + if (hasLimit(blobStorageStats)) { + return blobStorageStats || []; + } + + return blobStorageStats || tabletStorageStats || []; +} + interface MetricsTabsProps { poolsCpuStats?: TenantPoolsStats[]; memoryStats?: TenantMetricStats[]; + storageMetricStats?: TenantStorageStats[]; blobStorageStats?: TenantStorageStats[]; tabletStorageStats?: TenantStorageStats[]; networkUtilization?: number; @@ -46,6 +76,7 @@ interface MetricsTabsProps { export function MetricsTabs({ poolsCpuStats, memoryStats, + storageMetricStats, blobStorageStats, tabletStorageStats, networkUtilization, @@ -84,11 +115,18 @@ export function MetricsTabs({ [poolsCpuStats], ); const cpuMetrics = React.useMemo(() => calculateMetricAggregates(cpuPools), [cpuPools]); + const isServerless = databaseType === 'Serverless'; // Calculate storage metrics using utility const storageStats = React.useMemo( - () => tabletStorageStats || blobStorageStats || [], - [tabletStorageStats, blobStorageStats], + () => + storageMetricStats ?? + selectStorageStatsForMetricCard({ + blobStorageStats, + tabletStorageStats, + isServerless, + }), + [blobStorageStats, isServerless, storageMetricStats, tabletStorageStats], ); const storageMetrics = React.useMemo( () => calculateMetricAggregates(storageStats), @@ -106,8 +144,6 @@ export function MetricsTabs({ // card variant is handled within subcomponents - const isServerless = databaseType === 'Serverless'; - const renderNetworkTab = () => { if (!showNetworkUtilization) { return null; diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/__test__/MetricsTabs.test.ts b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/__test__/MetricsTabs.test.ts new file mode 100644 index 0000000000..93c0070976 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/MetricsTabs/__test__/MetricsTabs.test.ts @@ -0,0 +1,85 @@ +import {EType} from '../../../../../../types/api/tenant'; +import {selectStorageStatsForMetricCard} from '../MetricsTabs'; + +describe('selectStorageStatsForMetricCard', () => { + test('keeps prod legacy fallback to database storage when tablet storage has no limit', () => { + const blobStorageStats = [ + { + name: EType.SSD, + used: 492_778_291_200, + limit: 6_399_113_297_920, + usage: 7.7, + }, + ]; + const tabletStorageStats = [ + { + name: EType.SSD, + used: 35_915_303_563, + limit: undefined, + usage: undefined, + }, + ]; + + expect( + selectStorageStatsForMetricCard({ + blobStorageStats, + tabletStorageStats, + isServerless: false, + }), + ).toBe(blobStorageStats); + }); + + test('keeps quota-based tablet storage priority from main', () => { + const blobStorageStats = [ + { + name: EType.SSD, + used: 492_778_291_200, + limit: 6_399_113_297_920, + usage: 7.7, + }, + ]; + const tabletStorageStats = [ + { + name: EType.SSD, + used: 289_166_965_049, + limit: 612_032_839_680, + usage: 47.2, + }, + ]; + + expect( + selectStorageStatsForMetricCard({ + blobStorageStats, + tabletStorageStats, + isServerless: false, + }), + ).toBe(tabletStorageStats); + }); + + test('keeps serverless legacy priority for tablet storage', () => { + const blobStorageStats = [ + { + name: EType.SSD, + used: 500, + limit: 1_000, + usage: 50, + }, + ]; + const tabletStorageStats = [ + { + name: EType.SSD, + used: 100, + limit: undefined, + usage: undefined, + }, + ]; + + expect( + selectStorageStatsForMetricCard({ + blobStorageStats, + tabletStorageStats, + isServerless: true, + }), + ).toBe(tabletStorageStats); + }); +}); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx index 41d4e6aaa0..81a73eab41 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx @@ -19,8 +19,14 @@ import { TENANT_PAGES_IDS, } from '../../../../store/reducers/tenant/constants'; import {setDiagnosticsTab, tenantApi} from '../../../../store/reducers/tenant/tenant'; +import type {TenantMetricsTab} from '../../../../store/reducers/tenant/types'; import {calculateTenantMetrics} from '../../../../store/reducers/tenants/utils'; +import type {TenantStorageStats} from '../../../../store/reducers/tenants/utils'; import type {AdditionalTenantsProps} from '../../../../types/additionalProps'; +import type {EFlag} from '../../../../types/api/enums'; +import type {SelfCheckResult} from '../../../../types/api/healthcheck'; +import type {TMemoryStats} from '../../../../types/api/nodes'; +import type {ETenantType, TTenant} from '../../../../types/api/tenant'; import {getInfoTabLinks} from '../../../../utils/additionalProps'; import {TENANT_DEFAULT_TITLE} from '../../../../utils/constants'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; @@ -36,7 +42,8 @@ import {MetricsTabs} from './MetricsTabs/MetricsTabs'; import {TenantCpu} from './TenantCpu/TenantCpu'; import {TenantMemory} from './TenantMemory/TenantMemory'; import {TenantNetwork} from './TenantNetwork/TenantNetwork'; -import {TenantStorage} from './TenantStorage/TenantStorage'; +import {TenantStorageMode} from './TenantStorage/TenantStorageMode'; +import type {TenantStorageMetrics} from './TenantStorage/types'; import i18n from './i18n'; import {b} from './utils'; @@ -50,6 +57,213 @@ interface TenantOverviewProps { additionalTenantProps?: AdditionalTenantsProps; } +function isTenantLoading(isFetching: boolean, tenant: TTenant | undefined, error: unknown) { + return isFetching && tenant === undefined && !error; +} + +function shouldSkipHealthcheck({ + isServerless, + isV2NavigationEnabled, + tenant, +}: { + isServerless: boolean; + isV2NavigationEnabled: boolean; + tenant?: TTenant; +}) { + return isServerless || isV2NavigationEnabled || tenant === undefined; +} + +function getDatabaseStatus(overall?: EFlag, selfCheckResult?: SelfCheckResult) { + const healthcheckStatus = + selfCheckResult === undefined ? undefined : selfCheckResultToHcStatus[selfCheckResult]; + + if (healthcheckStatus === undefined) { + return overall; + } + + return hcStatusToColorFlag[healthcheckStatus] ?? overall; +} + +function getActiveMetricsTab(isServerless: boolean, metricsTab: TenantMetricsTab) { + if ( + isServerless && + metricsTab !== TENANT_METRICS_TABS_IDS.cpu && + metricsTab !== TENANT_METRICS_TABS_IDS.storage + ) { + return TENANT_METRICS_TABS_IDS.cpu; + } + + return metricsTab; +} + +function getStorageGroupsCount(storageGroups?: string) { + return storageGroups ? Number(storageGroups) : undefined; +} + +function TenantName({ + databaseStatus, + name, + hasTenant, + isServerless, +}: { + databaseStatus?: EFlag; + name?: string; + hasTenant: boolean; + isServerless: boolean; +}) { + return ( + + + {isServerless ? : null} + + ); +} + +function renderTenantError(error: unknown) { + return error ? : null; +} + +function renderHealthcheckPreview({ + database, + isServerless, + isV2NavigationEnabled, +}: { + database: string; + isServerless: boolean; + isV2NavigationEnabled: boolean; +}) { + return !isServerless && !isV2NavigationEnabled ? ( + + ) : null; +} + +function renderOverviewHead({ + databaseStatus, + handleOpenMonitoring, + hasTenant, + isServerless, + isV2NavigationEnabled, + links, + monitoringTabAvailable, + name, + tenantType, +}: { + databaseStatus?: EFlag; + handleOpenMonitoring: () => void; + hasTenant: boolean; + isServerless: boolean; + isV2NavigationEnabled: boolean; + links: ReturnType; + monitoringTabAvailable: boolean; + name?: string; + tenantType?: string; +}) { + if (isV2NavigationEnabled) { + return null; + } + + return ( + + + {tenantType} + {monitoringTabAvailable && ( + + )} + + + + {links.length > 0 && ( + + {links.map(({title, url, icon}) => ( + + ))} + + )} + + + ); +} + +function renderMetricsTabContent({ + activeMetricsTab, + blobStorageStats, + database, + databaseFullPath, + databaseType, + memoryLimit, + memoryStats, + memoryUsed, + storageMetrics, + tabletStorageStats, +}: { + activeMetricsTab: TenantMetricsTab; + blobStorageStats?: TenantStorageStats[]; + database: string; + databaseFullPath: string; + databaseType?: ETenantType; + memoryLimit?: string; + memoryStats?: TMemoryStats; + memoryUsed?: string; + storageMetrics: TenantStorageMetrics; + tabletStorageStats?: TenantStorageStats[]; +}) { + switch (activeMetricsTab) { + case TENANT_METRICS_TABS_IDS.cpu: { + return ( + + ); + } + case TENANT_METRICS_TABS_IDS.storage: { + return ( + + ); + } + case TENANT_METRICS_TABS_IDS.memory: { + return ( + + ); + } + case TENANT_METRICS_TABS_IDS.network: { + return ; + } + default: { + return null; + } + } +} + export function TenantOverview({ database, databaseFullPath, @@ -74,7 +288,7 @@ export function TenantOverview({ {pollingInterval: autoRefreshInterval}, ); - const tenantLoading = isFetching && tenant === undefined && !error; + const tenantLoading = isTenantLoading(isFetching, tenant, error); const {Name, Type, Overall, ControlPlane, CoresTotal} = tenant || {}; const isServerless = Type === 'Serverless'; @@ -86,22 +300,11 @@ export function TenantOverview({ const {currentData: healthcheckData} = healthcheckApi.useGetHealthcheckInfoQuery( {database}, { - skip: isServerless || isV2NavigationEnabled || tenant === undefined, + skip: shouldSkipHealthcheck({isServerless, isV2NavigationEnabled, tenant}), }, ); - const selfCheckResult = healthcheckData?.self_check_result; - const healthcheckStatus = - selfCheckResult === undefined ? undefined : selfCheckResultToHcStatus[selfCheckResult]; - const databaseStatus = - healthcheckStatus === undefined - ? Overall - : (hcStatusToColorFlag[healthcheckStatus] ?? Overall); - const activeMetricsTab = - isServerless && - metricsTab !== TENANT_METRICS_TABS_IDS.cpu && - metricsTab !== TENANT_METRICS_TABS_IDS.storage - ? TENANT_METRICS_TABS_IDS.cpu - : metricsTab; + const databaseStatus = getDatabaseStatus(Overall, healthcheckData?.self_check_result); + const activeMetricsTab = getActiveMetricsTab(isServerless, metricsTab); const controlPlaneNodesCount = ControlPlane?.scale_policy?.fixed_scale?.size; @@ -115,6 +318,7 @@ export function TenantOverview({ poolsStats, memoryStats, + storageMetricStats, blobStorageStats, tabletStorageStats, networkUtilization, @@ -128,60 +332,6 @@ export function TenantOverview({ tabletStorageLimit, }; - const renderName = () => { - return ( - - - {isServerless ? : null} - - ); - }; - - const renderTabContent = () => { - switch (activeMetricsTab) { - case TENANT_METRICS_TABS_IDS.cpu: { - return ( - - ); - } - case TENANT_METRICS_TABS_IDS.storage: { - return ( - - ); - } - case TENANT_METRICS_TABS_IDS.memory: { - return ( - - ); - } - case TENANT_METRICS_TABS_IDS.network: { - return ; - } - default: { - return null; - } - } - }; - const links = getInfoTabLinks(additionalTenantProps, Name, Type); const {monitoring: clusterMonitoring} = useClusterBaseInfo(); const monitoringTabAvailable = canShowTenantMonitoringTab( @@ -194,64 +344,38 @@ export function TenantOverview({ dispatch(setDiagnosticsTab(TENANT_DIAGNOSTICS_TABS_IDS.monitoring)); }; - const renderOverviewHead = () => { - if (isV2NavigationEnabled) { - return null; - } - return ( - - - {tenantType} - {monitoringTabAvailable && ( - - )} - - - {renderName()} - {links.length > 0 && ( - - {links.map(({title, url, icon}) => ( - - ))} - - )} - - - ); - }; - return ( - {error ? : null} + {renderTenantError(error)}
- {renderOverviewHead()} + {renderOverviewHead({ + databaseStatus, + handleOpenMonitoring, + hasTenant: Boolean(tenant), + isServerless, + isV2NavigationEnabled, + links, + monitoringTabAvailable, + name: Name, + tenantType, + })} - {!isServerless && !isV2NavigationEnabled && ( - - )} + {renderHealthcheckPreview({ + database, + isServerless, + isV2NavigationEnabled, + })}
-
{renderTabContent()}
+
+ {renderMetricsTabContent({ + activeMetricsTab, + blobStorageStats, + database, + databaseFullPath, + databaseType: Type, + memoryLimit: tenant?.MemoryLimit, + memoryStats: tenant?.MemoryStats, + memoryUsed: tenant?.MemoryUsed, + storageMetrics, + tabletStorageStats, + })} +
); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorage.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorage.tsx index 69204a394f..ea43c959f4 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorage.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorage.tsx @@ -5,30 +5,19 @@ import {LabelWithPopover} from '../../../../../components/LabelWithPopover'; import {ProgressWrapper} from '../../../../../components/ProgressWrapper'; import {getTenantPath} from '../../../../../routes'; import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../../../store/reducers/tenant/constants'; -import type {ETenantType} from '../../../../../types/api/tenant'; import {formatStorageValues} from '../../../../../utils/dataFormatters/dataFormatters'; import {useSearchQuery} from '../../../../../utils/hooks'; import {TenantTabsGroups} from '../../../TenantPages'; import {StatsWrapper} from '../StatsWrapper/StatsWrapper'; import {TenantDashboard} from '../TenantDashboard/TenantDashboard'; -import i18n from '../i18n'; import {TopGroups} from './TopGroups'; import {TopTables} from './TopTables'; +import i18n from './i18n'; import {storageDashboardConfig} from './storageDashboardConfig'; +import type {TenantStorageProps} from './types'; -export interface TenantStorageMetrics { - blobStorageUsed?: number; - blobStorageLimit?: number; - tabletStorageUsed?: number; - tabletStorageLimit?: number; -} - -interface TenantStorageProps { - database: string; - metrics: TenantStorageMetrics; - databaseType?: ETenantType; -} +export type {TenantStorageMetrics} from './types'; export function TenantStorage({database, metrics, databaseType}: TenantStorageProps) { const {blobStorageUsed, tabletStorageUsed, blobStorageLimit, tabletStorageLimit} = metrics; @@ -38,8 +27,8 @@ export function TenantStorage({database, metrics, databaseType}: TenantStoragePr { label: ( ), value: ( @@ -54,8 +43,8 @@ export function TenantStorage({database, metrics, databaseType}: TenantStoragePr { label: ( ), value: ( diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageMode.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageMode.tsx new file mode 100644 index 0000000000..e409e69417 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageMode.tsx @@ -0,0 +1,27 @@ +import { + useCapabilitiesLoaded, + useNewStorageViewEnabled, + useStorageStatsAvailable, +} from '../../../../../store/reducers/capabilities/hooks'; + +import {TenantStorage} from './TenantStorage'; +import {TenantStorageNew} from './TenantStorageNew'; +import type {TenantStorageProps} from './types'; + +export function TenantStorageMode(props: TenantStorageProps) { + const capabilitiesLoaded = useCapabilitiesLoaded(); + const newStorageViewEnabled = useNewStorageViewEnabled(); + const storageStatsAvailable = useStorageStatsAvailable(); + + const shouldUseLegacy = + props.databaseType === 'Serverless' || + !newStorageViewEnabled || + !capabilitiesLoaded || + !storageStatsAvailable; + + if (shouldUseLegacy) { + return ; + } + + return ; +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.scss new file mode 100644 index 0000000000..4ee233d15b --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.scss @@ -0,0 +1,50 @@ +.ydb-tenant-storage-new { + --ydb-storage-segment-row-tables: #5282ff; + --ydb-storage-segment-column-tables: #ff7112; + --ydb-storage-segment-topics: #ed2a7a; + --ydb-storage-segment-system: var(--g-color-base-generic-medium); + --ydb-storage-segment-unknown: var(--g-color-text-misc); + + &__sections-group { + display: flex; + flex-direction: column; + } + + &__sections-inner { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + } + + &__summary-skeleton-card { + padding: var(--g-spacing-4); + + border-radius: var(--g-border-radius-xs); + background: var(--g-color-base-generic); + } + + &__summary-skeleton-title { + width: 132px; + height: 28px; + } + + &__summary-skeleton-description { + width: 224px; + height: 20px; + } + + &__summary-skeleton-metric { + width: 92px; + height: 42px; + } + + &__summary-skeleton-progress { + width: 100%; + height: 20px; + } + + &__summary-skeleton-percent { + width: 76px; + height: 18px; + } +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.tsx new file mode 100644 index 0000000000..452d3c2cb6 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageNew.tsx @@ -0,0 +1,141 @@ +import React from 'react'; + +import {Flex} from '@gravity-ui/uikit'; + +import {ResponseError} from '../../../../../components/Errors/ResponseError'; +import {Skeleton} from '../../../../../components/Skeleton/Skeleton'; +import {cn} from '../../../../../utils/cn'; + +import { + TenantStorageGroupedMediaSectionsView, + TenantStorageMediaSectionView, +} from './TenantStorageSummarySections'; +import {TenantStorageTopUsageTable} from './TenantStorageTopUsageTable'; +import type {TenantStorageProps} from './types'; +import {useTenantStorageNewData} from './useTenantStorageNewData'; +import {buildTenantStorageMediaSections} from './utils'; + +import './TenantStorageNew.scss'; + +const b = cn('ydb-tenant-storage-new'); + +function TenantStorageSummarySkeleton() { + return ( + + + {[0, 1].map((cardIndex) => ( + + + + + + +
+ + {[0, 1, 2].map((metricIndex) => ( + + ))} + + + + + + + + + + ))} + + + ); +} + +export function TenantStorageNew({ + database, + databaseFullPath, + metrics, + blobStorageStats, + tabletStorageStats, +}: TenantStorageProps) { + const {currentData, data, error, isFetching} = useTenantStorageNewData({ + database, + databaseFullPath, + metrics, + }); + const loading = isFetching && currentData === undefined; + const mediaSections = React.useMemo(() => { + return buildTenantStorageMediaSections({ + blobStorageStats, + metrics, + tabletStorageStats, + }); + }, [blobStorageStats, metrics, tabletStorageStats]); + + if (error && !currentData) { + return ; + } + + const topRowsError = data.topRowsError ?? error; + const grouped = mediaSections.length > 1; + + if (loading) { + return ( + + + + + ); + } + + return ( + +
+
+ {grouped ? ( + + ) : ( + mediaSections.map((section, index) => ( + + )) + )} +
+
+ +
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.scss new file mode 100644 index 0000000000..8d40bb5c99 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.scss @@ -0,0 +1,123 @@ +@use '../../../../../styles/mixins.scss'; + +.ydb-tenant-storage-segments { + &__progress { + display: flex; + overflow: hidden; + gap: 4px; + + height: 20px; + + border-radius: var(--g-border-radius-xs); + } + + &__progress-bar { + --g-progress-empty-background-color: var(--g-color-sfx-shadow); + + overflow: hidden; + + height: 20px; + + border-radius: var(--g-border-radius-xs); + } + + &__progress-bar .g-progress__item { + border-radius: var(--g-border-radius-xs); + + transition: none; + } + + &__item { + min-width: 8px; + height: 100%; + + border-radius: var(--g-border-radius-xs); + + &_inactive { + opacity: 0.5; + } + + &:focus-visible { + outline: 2px solid var(--g-color-line-focus); + outline-offset: 1px; + } + } + + &__empty { + flex: 1; + + height: 100%; + + opacity: 0.75; + border-radius: var(--g-border-radius-xs); + background: var(--g-color-sfx-shadow); + + &_inactive { + opacity: 0.5; + } + } + + &__tooltip { + margin: 0; + padding-inline-start: var(--g-spacing-4); + + white-space: nowrap; + + color: var(--g-tooltip-text-color, var(--g-color-text-primary)); + + @include mixins.body-1-typography(); + } + + &__tooltip li + li { + margin-top: var(--g-spacing-half); + } + + &__legend-items { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--g-spacing-4); + + min-width: 0; + } + + &__legend-item { + display: flex; + align-items: center; + gap: var(--g-spacing-2); + + white-space: nowrap; + + &_inactive { + opacity: 0.5; + } + + &:focus-visible { + outline: 2px solid var(--g-color-line-focus); + outline-offset: 2px; + } + } + + &__legend-label { + display: inline-flex; + align-items: center; + gap: var(--g-spacing-1); + } + + &__legend-dot { + flex: 0 0 8px; + + width: 8px; + height: 8px; + + border-radius: 50%; + } + + &__system-tooltip { + min-width: 180px; + } + + &__system-tooltip-row { + white-space: nowrap; + } +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.tsx new file mode 100644 index 0000000000..bfe5d837f1 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSegments.tsx @@ -0,0 +1,304 @@ +import React from 'react'; + +import {Flex, HelpMark, Progress, Text, Tooltip} from '@gravity-ui/uikit'; +import type {PopupPlacement} from '@gravity-ui/uikit'; + +import {cn} from '../../../../../utils/cn'; +import {formatPercent} from '../../../../../utils/dataFormatters/dataFormatters'; + +import i18n from './i18n'; +import type { + TenantStorageSegment, + TenantStorageSegmentKey, + TenantStorageSystemDetail, + TenantStorageSystemDetailKey, +} from './utils'; +import { + TENANT_STORAGE_SEGMENT_KEYS, + TENANT_STORAGE_SYSTEM_DETAIL_KEYS, + getTenantStorageSegmentDisplayValue, +} from './utils'; + +import './TenantStorageSegments.scss'; + +const b = cn('ydb-tenant-storage-segments'); + +const SEGMENT_TOOLTIP_OPEN_DELAY = 100; +const SEGMENT_TOOLTIP_CLOSE_DELAY = 100; +const SEGMENT_TOOLTIP_PLACEMENT: PopupPlacement = ['top', 'bottom']; + +const SEGMENT_COLORS: Record = { + [TENANT_STORAGE_SEGMENT_KEYS.rowTables]: 'var(--ydb-storage-segment-row-tables)', + [TENANT_STORAGE_SEGMENT_KEYS.columnTables]: 'var(--ydb-storage-segment-column-tables)', + [TENANT_STORAGE_SEGMENT_KEYS.topics]: 'var(--ydb-storage-segment-topics)', + [TENANT_STORAGE_SEGMENT_KEYS.system]: 'var(--ydb-storage-segment-system)', + [TENANT_STORAGE_SEGMENT_KEYS.unknown]: 'var(--ydb-storage-segment-unknown)', +}; + +const SEGMENT_LABELS: Record = { + [TENANT_STORAGE_SEGMENT_KEYS.rowTables]: i18n('value_row-tables'), + [TENANT_STORAGE_SEGMENT_KEYS.columnTables]: i18n('value_column-tables'), + [TENANT_STORAGE_SEGMENT_KEYS.topics]: i18n('value_topics'), + [TENANT_STORAGE_SEGMENT_KEYS.system]: i18n('value_system'), + [TENANT_STORAGE_SEGMENT_KEYS.unknown]: i18n('value_unknown'), +}; + +const SEGMENT_ORDER_INDEX: Record = { + [TENANT_STORAGE_SEGMENT_KEYS.system]: 0, + [TENANT_STORAGE_SEGMENT_KEYS.rowTables]: 1, + [TENANT_STORAGE_SEGMENT_KEYS.columnTables]: 2, + [TENANT_STORAGE_SEGMENT_KEYS.topics]: 3, + [TENANT_STORAGE_SEGMENT_KEYS.unknown]: 4, +}; + +const SYSTEM_DETAIL_LABELS: Record = { + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive]: i18n('value_system-detail-hive'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator]: i18n('value_system-detail-coordinator'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.mediator]: i18n('value_system-detail-mediator'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard]: i18n('value_system-detail-scheme-shard'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.sysViewProcessor]: i18n( + 'value_system-detail-sys-view-processor', + ), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.graphShard]: i18n('value_system-detail-graph-shard'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.statisticsAggregator]: i18n( + 'value_system-detail-statistics-aggregator', + ), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController]: i18n('value_system-detail-bs-controller'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.cms]: i18n('value_system-detail-cms'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.nodeBroker]: i18n('value_system-detail-node-broker'), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.tenantSlotBroker]: i18n( + 'value_system-detail-tenant-slot-broker', + ), + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.console]: i18n('value_system-detail-console'), +}; + +function getSegmentProgressValue(segment: TenantStorageSegment) { + return segment.progressValue ?? segment.value; +} + +function getActiveDisplaySegments(segments: TenantStorageSegment[]) { + return segments + .filter((segment) => getSegmentProgressValue(segment) > 0) + .sort((left, right) => SEGMENT_ORDER_INDEX[left.key] - SEGMENT_ORDER_INDEX[right.key]); +} + +function formatSegmentPercent(value: number, total: number) { + if (!Number.isFinite(total) || total <= 0 || value <= 0) { + return ''; + } + + const percent = value / total; + + return formatPercent(percent, 2); +} + +export function SegmentTooltipContent({ + formatValue, + segment, + total, + totalLabel, +}: { + formatValue: (value: number) => string; + segment: TenantStorageSegment; + total: number; + totalLabel: string; +}) { + const percent = formatSegmentPercent(getSegmentProgressValue(segment), total); + + return ( +
    +
  • {formatValue(getTenantStorageSegmentDisplayValue(segment))}
  • + {percent ? ( +
  • {i18n('context_segment-share', {value: percent, totalLabel})}
  • + ) : null} +
+ ); +} + +function SegmentTooltip({ + children, + formatValue, + onOpenChange, + segment, + total, + totalLabel, +}: { + children: React.ReactElement; + formatValue: (value: number) => string; + onOpenChange: (segmentKey: TenantStorageSegmentKey, open: boolean) => void; + segment: TenantStorageSegment; + total: number; + totalLabel: string; +}) { + return ( + + } + onOpenChange={(open) => onOpenChange(segment.key, open)} + > + {children} + + ); +} + +export function SegmentedProgressBar({ + activeSegmentKey, + formatValue, + formatTooltipValue, + onSegmentOpenChange, + segments, + tooltipTotal, + tooltipTotalLabel, + total, +}: { + activeSegmentKey?: TenantStorageSegmentKey; + formatValue: (value: number) => string; + formatTooltipValue: (value: number) => string; + onSegmentOpenChange: (segmentKey: TenantStorageSegmentKey, open: boolean) => void; + segments: TenantStorageSegment[]; + tooltipTotal: number; + tooltipTotalLabel: string; + total?: number; +}) { + const activeSegments = getActiveDisplaySegments(segments); + const segmentSum = activeSegments.reduce((sum, s) => sum + getSegmentProgressValue(s), 0); + const effectiveTotal = total === undefined ? segmentSum : total; + + if (!Number.isFinite(effectiveTotal) || effectiveTotal <= 0 || segmentSum <= 0) { + return ; + } + + const cappedTotal = Math.max(effectiveTotal, segmentSum); + + return ( +
+ {activeSegments.map((segment) => { + const inactive = activeSegmentKey !== undefined && activeSegmentKey !== segment.key; + + return ( + +
event.preventDefault()} + style={{ + width: `${(getSegmentProgressValue(segment) / cappedTotal) * 100}%`, + background: SEGMENT_COLORS[segment.key], + }} + tabIndex={0} + /> + + ); + })} +
+
+ ); +} + +export function LegendItems({ + activeSegmentKey, + segments, + formatValue, + formatTooltipValue, + formatSystemDetailValue, + onSegmentOpenChange, + systemDetails, + tooltipTotal, + tooltipTotalLabel, +}: { + activeSegmentKey?: TenantStorageSegmentKey; + segments: TenantStorageSegment[]; + formatValue: (value: number) => string; + formatTooltipValue: (value: number) => string; + formatSystemDetailValue?: (value: number) => string; + onSegmentOpenChange: (segmentKey: TenantStorageSegmentKey, open: boolean) => void; + systemDetails?: TenantStorageSystemDetail[]; + tooltipTotal: number; + tooltipTotalLabel: string; +}) { + const activeSegments = getActiveDisplaySegments(segments); + const visibleSystemDetails = (systemDetails ?? []).filter((detail) => detail.value > 0); + + if (activeSegments.length === 0) { + return null; + } + + return ( +
+ {activeSegments.map((segment) => { + const isSystemSegment = segment.key === TENANT_STORAGE_SEGMENT_KEYS.system; + const showSystemDetails = isSystemSegment && visibleSystemDetails.length > 0; + const inactive = activeSegmentKey !== undefined && activeSegmentKey !== segment.key; + + return ( + +
event.preventDefault()} + tabIndex={0} + > +
+
+ {SEGMENT_LABELS[segment.key]} +
+ + {formatValue(getTenantStorageSegmentDisplayValue(segment))} + + {showSystemDetails ? ( + + + {visibleSystemDetails.map((detail) => ( + + {SYSTEM_DETAIL_LABELS[detail.key]} + + {formatSystemDetailValue?.(detail.value) ?? + formatValue(detail.value)} + + + ))} + + + ) : null} +
+ + ); + })} +
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.scss new file mode 100644 index 0000000000..449425f558 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.scss @@ -0,0 +1,190 @@ +.ydb-tenant-storage-summary-card { + display: flex; + flex-direction: column; + gap: var(--g-spacing-4); + + padding: var(--g-spacing-4); + + border-radius: var(--g-border-radius-xs); + background: var(--g-color-base-generic); + + &_first { + padding-bottom: var(--g-spacing-3); + + border-radius: var(--g-border-radius-m) var(--g-border-radius-xs) var(--g-border-radius-xs) + var(--g-border-radius-xs); + } + + &_last { + border-radius: var(--g-border-radius-xs) var(--g-border-radius-xs) var(--g-border-radius-m) + var(--g-border-radius-m); + } + + &_grouped { + gap: var(--g-spacing-3); + } + + &__copy { + display: flex; + flex-direction: column; + gap: var(--g-spacing-half); + + min-width: 0; + } + + &__rows { + display: flex; + flex-direction: column; + gap: var(--g-spacing-3); + } + + &__row { + display: flex; + flex-direction: column; + gap: 0; + + min-width: 0; + } + + &__row_grouped { + gap: 0; + } + + &__row-header { + min-width: 0; + } + + &__row-label { + min-width: 72px; + padding-top: var(--g-spacing-2); + } + + &__metrics { + display: flex; + flex: 0 0 auto; + align-items: stretch; + gap: var(--g-spacing-10); + } + + &__row_grouped &__metrics { + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + } + + &__metric { + display: flex; + flex-direction: column; + justify-content: center; + + width: 101px; + min-width: 0; + padding-left: var(--g-spacing-10); + + border-left: 1px solid var(--g-color-line-generic); + + &_emphasize { + width: auto; + margin-top: calc(-1 * var(--g-spacing-2)); + margin-bottom: calc(-1 * var(--g-spacing-2)); + padding: var(--g-spacing-2) var(--g-spacing-3); + + border-left: 0; + border-radius: var(--g-border-radius-m); + background: var(--g-color-base-generic-accent); + } + + &_hide-divider { + width: 70px; + padding-left: 0; + + border-left: 0; + } + + &_grouped { + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: var(--g-spacing-1); + + width: auto; + min-width: 88px; + padding-left: var(--g-spacing-6); + + white-space: nowrap; + } + + &_grouped#{&}_hide-divider { + min-width: 118px; + padding-left: 0; + } + + &_grouped#{&}_emphasize { + gap: var(--g-spacing-1); + + width: auto; + padding: 0; + + background: transparent; + } + } + + &__metric-label { + margin-top: var(--g-spacing-1); + } + + &__metric-note { + display: flex; + flex-direction: column; + gap: var(--g-spacing-2); + + max-width: 280px; + } + + &__row_grouped &__metric-label { + margin-top: 0; + } + + &__metric-value { + flex-shrink: 0; + } + + &__progress { + width: 100%; + margin-top: var(--g-spacing-4); + } + + &__legend { + margin-top: var(--g-spacing-3); + } + + &__row_grouped &__progress, + &__row_grouped &__legend { + margin-top: var(--g-spacing-2); + } + + &__row_grouped &__progress-bar, + &__row_grouped .ydb-tenant-storage-segments__progress { + height: 10px; + } + + &__progress-bar { + --g-progress-empty-background-color: var(--g-color-sfx-shadow); + + overflow: hidden; + + height: 20px; + + border-radius: var(--g-border-radius-xs); + } + + &__progress-bar .g-progress__item { + border-radius: var(--g-border-radius-xs); + + transition: none; + } + + &__used { + white-space: nowrap; + } +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.tsx new file mode 100644 index 0000000000..6fa7d8dcf3 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummaryCard.tsx @@ -0,0 +1,329 @@ +import React from 'react'; + +import {Flex, HelpMark, Label, Progress, Text} from '@gravity-ui/uikit'; + +import {cn} from '../../../../../utils/cn'; +import {formatNumber} from '../../../../../utils/dataFormatters/dataFormatters'; + +import {LegendItems, SegmentedProgressBar} from './TenantStorageSegments'; +import {formatSummaryPercent} from './displayFormatters'; +import i18n from './i18n'; +import type { + TenantStorageSegment, + TenantStorageSegmentKey, + TenantStorageSummary, + TenantStorageSystemDetail, +} from './utils'; + +import './TenantStorageSummaryCard.scss'; + +const b = cn('ydb-tenant-storage-summary-card'); + +export interface SummaryMetricProps { + label: string; + note?: string; + notePlacement?: 'label' | 'value'; + noteTitle?: string; + value: string; + emphasize?: boolean; + hideDivider?: boolean; +} + +interface SummaryCardRowBaseProps { + summary: TenantStorageSummary; + metrics: SummaryMetricProps[]; + displayNoLimit?: 'empty' | 'filled'; + segments?: TenantStorageSegment[]; + formatLegendValue?: (value: number) => string; + formatSystemDetailValue?: (value: number) => string; + formatTooltipValue?: (value: number) => string; + systemDetails?: TenantStorageSystemDetail[]; + tooltipTotalLabel: string; +} + +interface SummaryCardProps { + title: string; + description: string; + descriptionHelpText?: string; + position?: 'first' | 'last'; +} + +type SummaryCardPropsWithRow = SummaryCardProps & SummaryCardRowBaseProps; + +export interface GroupedSummaryCardRow extends SummaryCardRowBaseProps { + id: string; + mediaLabel?: string; +} + +interface GroupedSummaryCardProps extends SummaryCardProps { + rows: GroupedSummaryCardRow[]; +} + +function SummaryMetricNote({note, noteTitle}: Pick) { + if (!note && !noteTitle) { + return null; + } + + const content = noteTitle ? ( +
+ {noteTitle} + {note ? {note} : null} +
+ ) : ( + note + ); + + return {content}; +} + +function SummaryMetric({ + label, + note, + notePlacement = 'label', + noteTitle, + value, + emphasize, + hideDivider, +}: SummaryMetricProps) { + const noteElement = ; + + return ( +
+ + + {value} + + {notePlacement === 'value' ? noteElement : null} + + + {label} + {notePlacement === 'label' ? noteElement : null} + +
+ ); +} + +function GroupedSummaryMetric({ + label, + note, + notePlacement = 'label', + noteTitle, + value, + emphasize, + hideDivider, +}: SummaryMetricProps) { + const noteElement = ; + + if (emphasize) { + return ( +
+ + {noteElement} +
+ ); + } + + return ( +
+ + {label} + {notePlacement === 'label' ? noteElement : null} + + + + {value} + + {notePlacement === 'value' ? noteElement : null} + +
+ ); +} + +function SummaryCardCopy({ + description, + descriptionHelpText, + title, +}: { + description: string; + descriptionHelpText?: string; + title: string; +}) { + return ( +
+ {title} + + {description} + {descriptionHelpText ? ( + {descriptionHelpText} + ) : null} + +
+ ); +} + +function SummaryCardRow({ + displayNoLimit, + formatLegendValue, + formatSystemDetailValue, + formatTooltipValue, + header, + grouped, + metrics, + segments, + summary, + systemDetails, + tooltipTotalLabel, +}: SummaryCardRowBaseProps & {grouped?: boolean; header: React.ReactNode}) { + const total = summary.quota ?? summary.total; + const activeSegments = (segments ?? []).filter((s) => s.value > 0); + const hasSegments = activeSegments.length > 0; + const [activeSegmentKey, setActiveSegmentKey] = React.useState(); + const handleSegmentOpenChange = React.useCallback( + (segmentKey: TenantStorageSegmentKey, open: boolean) => { + setActiveSegmentKey((currentKey) => { + if (open) { + return segmentKey; + } + + return currentKey === segmentKey ? undefined : currentKey; + }); + }, + [], + ); + + let percent = 0; + + if (total) { + percent = summary.usedPercent; + } else if (displayNoLimit === 'filled') { + percent = 100; + } + + return ( +
+ + {header} +
+ {metrics.map((metric) => + grouped ? ( + + ) : ( + + ), + )} +
+
+
+ {hasSegments ? ( + + ) : ( + + )} +
+ + {hasSegments && formatLegendValue ? ( + + ) : ( +
+ )} + + {!total && displayNoLimit === 'filled' + ? i18n('value_no-limit') + : formatSummaryPercent(summary.usedPercent)} + + +
+ ); +} + +export function SummaryCard({ + title, + description, + descriptionHelpText, + position, + ...rowProps +}: SummaryCardPropsWithRow) { + return ( +
+ + } + /> +
+ ); +} + +export function GroupedSummaryCard({ + title, + description, + descriptionHelpText, + position, + rows, +}: GroupedSummaryCardProps) { + return ( +
+ +
+ {rows.map(({id, mediaLabel, ...rowProps}) => ( + + {mediaLabel} + + } + /> + ))} +
+
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.scss new file mode 100644 index 0000000000..9a8c7421c6 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.scss @@ -0,0 +1,3 @@ +.ydb-tenant-storage-summary-sections { + min-width: 0; +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.tsx new file mode 100644 index 0000000000..eb9cc1921e --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageSummarySections.tsx @@ -0,0 +1,315 @@ +import {Flex, Text} from '@gravity-ui/uikit'; + +import {EType} from '../../../../../types/api/tenant'; +import {cn} from '../../../../../utils/cn'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../utils/constants'; + +import {GroupedSummaryCard, SummaryCard} from './TenantStorageSummaryCard'; +import type {GroupedSummaryCardRow} from './TenantStorageSummaryCard'; +import { + formatOverheadValue, + formatTenantStorageApproximateMetric, + formatTenantStorageSummaryMetric, + getTenantStorageLegendValueFormatter, + getTenantStorageSegmentValueFormatters, + getTenantStorageSummaryMetricUnit, +} from './displayFormatters'; +import i18n from './i18n'; +import type { + TenantStorageData, + TenantStorageMediaSection, + TenantStorageSegment, + TenantStorageSummary, + TenantStorageSystemDetail, +} from './utils'; +import { + getTenantStoragePhysicalDisplaySegments, + getTenantStoragePhysicalMediaBreakdown, + getTenantStorageSegmentDisplayValue, + getTenantStorageUserDataDisplaySummary, + mergeSystemDetailsByMedia, +} from './utils'; + +import './TenantStorageSummarySections.scss'; + +const b = cn('ydb-tenant-storage-summary-sections'); + +function getMediaSectionLabel(mediaType?: string) { + if (!mediaType || mediaType === EType.None) { + return undefined; + } + + return mediaType; +} + +function getUserSummaryRow({ + id, + mediaLabel, + segments, + summary, +}: { + id: string; + mediaLabel?: string; + segments?: TenantStorageSegment[]; + summary: TenantStorageSummary; +}): GroupedSummaryCardRow { + const metricsSize = getTenantStorageSummaryMetricUnit([ + summary.available, + summary.used, + summary.quota, + ]); + + const {formatLegendValue, formatTooltipValue} = getTenantStorageSegmentValueFormatters( + (segments ?? []).map(getTenantStorageSegmentDisplayValue), + ); + const formattedAvailableValue = summary.availableApproximate + ? formatTenantStorageApproximateMetric(summary.available, metricsSize) + : formatTenantStorageSummaryMetric(summary.available, metricsSize); + const hasQuota = summary.quota !== undefined; + + return { + id, + mediaLabel, + summary, + segments, + formatLegendValue, + formatTooltipValue, + tooltipTotalLabel: i18n('value_total-user-data'), + metrics: [ + { + hideDivider: true, + label: i18n('field_available'), + note: summary.availableApproximate + ? i18n('context_available-approximate') + : undefined, + value: formattedAvailableValue, + }, + { + label: i18n('field_used'), + value: formatTenantStorageSummaryMetric(summary.used, metricsSize), + }, + { + label: i18n('field_quota'), + note: hasQuota ? undefined : i18n('alert_missing-quota-description'), + notePlacement: 'value', + noteTitle: hasQuota ? undefined : i18n('alert_missing-quota-title'), + value: hasQuota + ? formatTenantStorageSummaryMetric(summary.quota, metricsSize) + : EMPTY_DATA_PLACEHOLDER, + }, + ], + }; +} + +function renderUserSummary(summary: TenantStorageSummary, segments?: TenantStorageSegment[]) { + const row = getUserSummaryRow({id: 'user-data', summary, segments}); + + return ( + + ); +} + +function getPhysicalSummaryRow({ + id, + mediaLabel, + segments, + summary, + systemDetails, +}: { + id: string; + mediaLabel?: string; + segments?: TenantStorageSegment[]; + summary: TenantStorageSummary; + systemDetails?: TenantStorageSystemDetail[]; +}): GroupedSummaryCardRow { + const metricsSize = getTenantStorageSummaryMetricUnit([ + summary.available, + summary.used, + summary.total, + ]); + + const {formatLegendValue, formatTooltipValue} = getTenantStorageSegmentValueFormatters( + (segments ?? []).map(getTenantStorageSegmentDisplayValue), + ); + const formatSystemDetailValue = getTenantStorageLegendValueFormatter( + (systemDetails ?? []).map((detail) => detail.value), + ); + + return { + id, + mediaLabel, + summary, + segments, + formatLegendValue, + formatSystemDetailValue, + formatTooltipValue, + tooltipTotalLabel: i18n('value_total-physical-disk-usage'), + displayNoLimit: 'filled', + systemDetails, + metrics: [ + { + emphasize: true, + label: i18n('field_overhead'), + note: i18n('context_overhead-description'), + value: formatOverheadValue(summary.overhead), + }, + { + hideDivider: true, + label: i18n('field_available'), + value: formatTenantStorageSummaryMetric(summary.available, metricsSize), + }, + { + label: i18n('field_used'), + value: formatTenantStorageSummaryMetric(summary.used, metricsSize), + }, + { + label: i18n('field_total'), + value: formatTenantStorageSummaryMetric(summary.total, metricsSize), + }, + ], + }; +} + +function renderPhysicalSummary( + summary: TenantStorageSummary, + segments?: TenantStorageSegment[], + systemDetails?: TenantStorageSystemDetail[], +) { + const row = getPhysicalSummaryRow({id: 'physical', summary, segments, systemDetails}); + + return ( + + ); +} + +function getMediaSectionRows({ + data, + index = 0, + section, + showMediaTypeLabel, +}: { + data: TenantStorageData; + index?: number; + section: TenantStorageMediaSection; + showMediaTypeLabel: boolean; +}) { + const mediaLabel = getMediaSectionLabel(section.mediaType); + const userDataSummary = getTenantStorageUserDataDisplaySummary({ + summary: section.userData, + logicalUserData: data.logicalUserData, + useLogicalBreakdown: !showMediaTypeLabel, + physical: section.physical, + }); + const userSegments = showMediaTypeLabel ? undefined : data.userDataSegments; + let physicalSegments: TenantStorageSegment[]; + let systemDetails: TenantStorageSystemDetail[] | undefined; + + if (section.mediaType === EType.None) { + physicalSegments = data.summary.physical.segments; + systemDetails = mergeSystemDetailsByMedia(data.systemDetailsByMedia); + } else { + const mediaBreakdown = getTenantStoragePhysicalMediaBreakdown({ + allowAggregateFallback: !showMediaTypeLabel, + mediaType: section.mediaType, + physicalSegmentsByMedia: data.physicalSegmentsByMedia, + systemDetailsByMedia: data.systemDetailsByMedia, + }); + + physicalSegments = getTenantStoragePhysicalDisplaySegments({ + segments: mediaBreakdown.segments, + used: section.physical.used, + }); + systemDetails = mediaBreakdown.systemDetails; + } + + return { + mediaLabel, + physical: getPhysicalSummaryRow({ + id: `physical-${section.mediaType}-${index}`, + mediaLabel, + summary: section.physical, + segments: physicalSegments, + systemDetails, + }), + user: getUserSummaryRow({ + id: `user-${section.mediaType}-${index}`, + mediaLabel, + summary: userDataSummary, + segments: userSegments, + }), + }; +} + +export function TenantStorageMediaSectionView({ + section, + showMediaTypeLabel, + data, +}: { + section: TenantStorageMediaSection; + showMediaTypeLabel: boolean; + data: TenantStorageData; +}) { + const {mediaLabel, physical, user} = getMediaSectionRows({ + data, + section, + showMediaTypeLabel, + }); + + return ( + + {showMediaTypeLabel && mediaLabel ? ( + + {mediaLabel} + + ) : null} + {renderUserSummary(user.summary, user.segments)} + {renderPhysicalSummary(physical.summary, physical.segments, physical.systemDetails)} + + ); +} + +export function TenantStorageGroupedMediaSectionsView({ + data, + sections, +}: { + data: TenantStorageData; + sections: TenantStorageMediaSection[]; +}) { + const rows = sections.map((section, index) => { + return getMediaSectionRows({ + data, + index, + section, + showMediaTypeLabel: true, + }); + }); + + return ( + + row.user)} + /> + row.physical)} + /> + + ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.scss b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.scss new file mode 100644 index 0000000000..eda069d671 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.scss @@ -0,0 +1,121 @@ +@use '../../../../../styles/mixins.scss'; + +.ydb-tenant-storage-top-usage-table { + --data-table-cell-align: top; + --data-table-cell-vertical-padding: 10.5px; + + .tenant-overview__title { + display: flex; + align-items: baseline; + gap: var(--g-spacing-1); + + margin-bottom: var(--g-spacing-3); + } + + .tenant-overview__table { + padding: var(--g-spacing-2) var(--g-spacing-4); + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m); + } + + .ydb-resizeable-data-table { + width: 100%; + } + + .data-table__th { + @include mixins.subheader-1-typography(); + } + + .data-table__row { + height: auto; + } + + .data-table__td { + line-height: var(--g-text-body-1-line-height); + } + + &__type-cell { + min-width: 0; + } + + &__type-icon { + display: inline-flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + + height: var(--g-text-body-1-line-height); + } + + &__object-cell { + min-width: 0; + } + + &__object-link { + min-width: 0; + @include mixins.body-1-typography(); + } + + &__path-row { + display: flex; + align-items: center; + gap: var(--g-spacing-1); + + min-width: 0; + height: var(--g-text-caption-2-line-height); + } + + &__path-text { + min-width: 0; + } + + &__path-copy.g-button { + --g-button-height: 16px; + + flex: 0 0 auto; + + min-width: 16px; + height: 16px; + min-height: 16px; + + line-height: 16px; + } + + &__space-cell { + flex-wrap: nowrap; + + min-width: 0; + } + + &__space-progress { + flex: 1 1 0; + + min-width: 0; + /* visually center the 10px progress bar with the body-1 text baseline */ + padding-top: 4px; + } + + &__space-progress-bar { + --g-progress-filled-background-color: var(--g-color-base-info-medium); + --g-progress-empty-background-color: var(--g-color-base-info-light); + + height: 10px; + + border-radius: var(--g-border-radius-xs); + } + + &__space-progress-bar .g-progress__item { + border-radius: var(--g-border-radius-xs) 0 0 var(--g-border-radius-xs); + + transition: none; + } + + &__space-value { + flex: 0 0 auto; + + min-width: 42px; + + white-space: nowrap; + } +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.tsx new file mode 100644 index 0000000000..ba565c7443 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/TenantStorageTopUsageTable.tsx @@ -0,0 +1,236 @@ +import React from 'react'; + +import type {Column} from '@gravity-ui/react-data-table'; +import DataTable from '@gravity-ui/react-data-table'; +import {ClipboardButton, Flex, HelpMark, Progress, Text} from '@gravity-ui/uikit'; +import { + ColumnTableIcon, + ExternalTableIcon, + TableIcon, + TableIndexIcon, + TopicIcon, + ViewIcon, +} from 'ydb-ui-components'; + +import {CellWithPopover} from '../../../../../components/CellWithPopover/CellWithPopover'; +import {LinkToSchemaObject} from '../../../../../components/LinkToSchemaObject/LinkToSchemaObject'; +import {ResizeableDataTable} from '../../../../../components/ResizeableDataTable/ResizeableDataTable'; +import {EPathType} from '../../../../../types/api/schema'; +import {cn} from '../../../../../utils/cn'; +import { + EMPTY_DATA_PLACEHOLDER, + TENANT_OVERVIEW_TABLES_SETTINGS, +} from '../../../../../utils/constants'; +import {formatPercent} from '../../../../../utils/dataFormatters/dataFormatters'; +import {mapPathTypeToEntityName, mapPathTypeToNavigationTreeType} from '../../../utils/schema'; +import {TenantOverviewTableLayout} from '../TenantOverviewTableLayout'; + +import { + formatTenantStorageTableMetric, + formatTenantStorageTableOverhead, +} from './displayFormatters'; +import i18n from './i18n'; +import type {TenantStorageTopRow} from './utils'; +import {isSystemStoragePath} from './utils'; + +import './TenantStorageTopUsageTable.scss'; + +const b = cn('ydb-tenant-storage-top-usage-table'); + +const TENANT_STORAGE_COLUMNS_WIDTH_LS_KEY = 'tenantStorageTopUsageTableColumnsWidth'; + +interface TenantStorageTopUsageTableProps { + error?: unknown; + loading: boolean; + rows: TenantStorageTopRow[]; + withData: boolean; +} + +function renderPathTypeIcon(row: TenantStorageTopRow) { + if (isSystemStoragePath(row.path)) { + return ; + } + + switch (mapPathTypeToNavigationTreeType(row.pathType, row.pathSubType, 'directory')) { + case 'column_table': + return ; + case 'topic': + return ; + case 'index': + return ; + case 'view': + return ; + case 'external_table': + return ; + case 'table': + case 'index_table': + case 'system_table': + return ; + default: + return null; + } +} + +function getObjectName(path: string) { + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path; + const pathParts = normalizedPath.split('/'); + + return pathParts[pathParts.length - 1] || normalizedPath; +} + +function TypeCell({row}: {row: TenantStorageTopRow}) { + let label = + row.displayTypeLabel ?? + mapPathTypeToEntityName(row.pathType, row.pathSubType) ?? + i18n('value_object'); + + if (!row.displayTypeLabel && isSystemStoragePath(row.path)) { + label = i18n('value_system-table'); + } else if (!row.displayTypeLabel && row.pathType === EPathType.EPathTypeTable) { + label = i18n('value_row-table'); + } + + return ( + +
{renderPathTypeIcon(row)}
+ {label} +
+ ); +} + +function ObjectPathCell({row}: {row: TenantStorageTopRow}) { + const pathLabel = row.displayPath ?? row.path; + const objectName = getObjectName(pathLabel); + + return ( + + + + {objectName} + + + +
+ + {pathLabel} + + +
+
+
+ ); +} + +function DatabaseSpaceCell({row}: {row: TenantStorageTopRow}) { + const share = Math.min(Math.max(row.dbShare, 0), 1); + const percent = share * 100; + const precision = percent > 0 && percent < 1 ? 1 : 0; + + return ( + +
+ +
+ + {formatPercent(share, precision) || EMPTY_DATA_PLACEHOLDER} + +
+ ); +} + +function getTopUsageColumns(): Column[] { + return [ + { + name: 'type', + header: i18n('field_type'), + width: 130, + align: DataTable.LEFT, + render: ({row}) => , + }, + { + name: 'path', + header: i18n('field_object-path'), + width: undefined, + align: DataTable.LEFT, + render: ({row}) => , + }, + { + name: 'dbShare', + header: i18n('field_database-space'), + width: 220, + align: DataTable.LEFT, + render: ({row}) => , + }, + { + name: 'dataSize', + header: i18n('field_user-data'), + width: 100, + align: DataTable.LEFT, + render: ({row}) => ( + {formatTenantStorageTableMetric(row.userData)} + ), + }, + { + name: 'storageSize', + header: i18n('field_physical-disk'), + width: 140, + align: DataTable.LEFT, + render: ({row}) => ( + {formatTenantStorageTableMetric(row.physicalDisk)} + ), + }, + { + name: 'overhead', + header: ( + + {i18n('field_overhead')} + {i18n('context_overhead-description')} + + ), + width: 106, + align: DataTable.LEFT, + render: ({row}) => ( + {formatTenantStorageTableOverhead(row.overhead)} + ), + }, + ]; +} + +export function TenantStorageTopUsageTable({ + error, + loading, + rows, + withData, +}: TenantStorageTopUsageTableProps) { + const columns = React.useMemo(() => getTopUsageColumns(), []); + + return ( +
+ + {i18n('title_top-space-usage-main')} + + {i18n('title_top-space-usage-suffix')} + + + } + > + + +
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/displayFormatters.test.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/displayFormatters.test.ts new file mode 100644 index 0000000000..a722060222 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/displayFormatters.test.ts @@ -0,0 +1,209 @@ +import {EMPTY_DATA_PLACEHOLDER, UNBREAKABLE_GAP} from '../../../../../../utils/constants'; +import { + formatSummaryPercent, + formatTenantStorageAdaptiveMetric, + formatTenantStorageApproximateMetric, + formatTenantStorageSummaryMetric, + formatTenantStorageTableMetric, + formatTenantStorageTableOverhead, + getTenantStorageLegendValueFormatter, + getTenantStorageSegmentValueFormatters, + getTenantStorageSummaryMetricUnit, +} from '../displayFormatters'; +import type {TenantStorageSegment} from '../utils'; +import {TENANT_STORAGE_SEGMENT_KEYS, getTenantStorageSegmentDisplayValue} from '../utils'; + +const withUnit = (value: string, unit: string) => `${value}${UNBREAKABLE_GAP}${unit}`; +const GB = 1_000_000_000; +const TB = 1_000_000_000_000; +const PB = 1_000_000_000_000_000; + +describe('TenantStorage display formatters', () => { + test('formats summary terabyte values without redundant decimal zeros', () => { + expect(formatTenantStorageSummaryMetric(21_000_000_000_000, 'tb')).toBe( + withUnit('21', 'TB'), + ); + expect(formatTenantStorageSummaryMetric(4_800_000_000_000, 'tb')).toBe( + withUnit('4.8', 'TB'), + ); + expect(formatTenantStorageSummaryMetric(3_450_000_000_000, 'tb')).toBe( + withUnit('3.45', 'TB'), + ); + expect(formatTenantStorageSummaryMetric(174_100_000_000_000, 'tb')).toBe( + withUnit('174.1', 'TB'), + ); + expect(formatTenantStorageSummaryMetric(201_000_000_000_000, 'tb')).toBe( + withUnit('201', 'TB'), + ); + }); + + test('formats summary petabyte values without redundant decimal zeros', () => { + expect(formatTenantStorageSummaryMetric(1_200_000_000_000_000, 'pb')).toBe( + withUnit('1.2', 'PB'), + ); + expect(formatTenantStorageSummaryMetric(3_450_000_000_000_000, 'pb')).toBe( + withUnit('3.45', 'PB'), + ); + expect(formatTenantStorageSummaryMetric(18_000_000_000_000_000, 'pb')).toBe( + withUnit('18', 'PB'), + ); + expect(formatTenantStorageSummaryMetric(306_400_000_000_000_000, 'pb')).toBe( + withUnit('306.4', 'PB'), + ); + }); + + test('selects summary units by PB, TB, GB, MB priority', () => { + expect(getTenantStorageSummaryMetricUnit([1.2 * PB, 300 * TB, 900 * TB])).toBe('pb'); + expect( + getTenantStorageSummaryMetricUnit([ + 18_000_000_000_000, 4_800_000_000_000, 21_000_000_000_000, + ]), + ).toBe('tb'); + expect( + getTenantStorageSummaryMetricUnit([ + 600_000_000_000, 250_000_000_000, 2_000_000_000_000, + ]), + ).toBe('tb'); + expect( + getTenantStorageSummaryMetricUnit([600_000_000_000, 250_000_000_000, 900_000_000_000]), + ).toBe('gb'); + expect(getTenantStorageSummaryMetricUnit([600_000_000, 250_000_000, 900_000_000])).toBe( + 'mb', + ); + expect(getTenantStorageSummaryMetricUnit([500_000, undefined])).toBe('mb'); + }); + + test('keeps summary values in the requested unit', () => { + expect(formatTenantStorageSummaryMetric(600_000_000_000, 'tb')).toBe(withUnit('0.6', 'TB')); + }); + + test('formats summary used percent with one decimal below one percent', () => { + expect(formatSummaryPercent(0)).toBe(''); + expect(formatSummaryPercent(0.5)).toBe('used 0.5%'); + expect(formatSummaryPercent(23.4)).toBe('used 23%'); + }); + + test('formats adaptive byte values with readable units', () => { + expect(formatTenantStorageAdaptiveMetric(70_000_000_000)).toBe(withUnit('70', 'GB')); + expect(formatTenantStorageAdaptiveMetric(432_000_000_000)).toBe(withUnit('432', 'GB')); + expect(formatTenantStorageAdaptiveMetric(236_000_000_000)).toBe(withUnit('236', 'GB')); + expect(formatTenantStorageAdaptiveMetric(1_200_000_000)).toBe(withUnit('1.2', 'GB')); + expect(formatTenantStorageAdaptiveMetric(500_000_000)).toBe(withUnit('500', 'MB')); + }); + + test('formats top usage table metrics adaptively', () => { + expect(formatTenantStorageTableMetric(35_600_000_000)).toBe(withUnit('35.6', 'GB')); + expect(formatTenantStorageTableMetric(10_000_000)).toBe(withUnit('10', 'MB')); + expect(formatTenantStorageTableMetric(500_000)).toBe(withUnit('500', 'KB')); + expect(formatTenantStorageTableMetric(0)).toBe(withUnit('0', 'B')); + expect(formatTenantStorageTableMetric(undefined)).toBe(EMPTY_DATA_PLACEHOLDER); + }); + + test('caps top usage table overhead above 500x', () => { + expect(formatTenantStorageTableOverhead(2)).toBe('2x'); + expect(formatTenantStorageTableOverhead(4.7)).toBe('4.7x'); + expect(formatTenantStorageTableOverhead(500)).toBe('500x'); + expect(formatTenantStorageTableOverhead(513)).toBe('>500x'); + }); + + test('formats approximate metrics with a readable lower unit when needed', () => { + expect(formatTenantStorageApproximateMetric(18_000_000_000_000, 'tb')).toBe( + `~${withUnit('18', 'TB')}`, + ); + expect(formatTenantStorageApproximateMetric(4_800_000_000_000, 'tb')).toBe( + `~${withUnit('4.8', 'TB')}`, + ); + expect(formatTenantStorageApproximateMetric(600_000_000_000, 'tb')).toBe( + `~${withUnit('600', 'GB')}`, + ); + expect(formatTenantStorageApproximateMetric(undefined, 'tb')).toBe(EMPTY_DATA_PLACEHOLDER); + }); + + test('keeps legend values in a common unit below mixed-unit threshold', () => { + const formatValue = getTenantStorageLegendValueFormatter([ + 600_000_000_000, 40_000_000_000_000, + ]); + + expect(formatValue(600_000_000_000)).toBe(withUnit('0.6', 'TB')); + expect(formatValue(40_000_000_000_000)).toBe(withUnit('40', 'TB')); + }); + + test('uses adaptive legend units at mixed-unit threshold', () => { + const formatValue = getTenantStorageLegendValueFormatter([ + 70_000_000_000, 21_000_000_000_000, + ]); + + expect(formatValue(70_000_000_000)).toBe(withUnit('70', 'GB')); + expect(formatValue(21_000_000_000_000)).toBe(withUnit('21', 'TB')); + }); + + test('selects legend units from displayed segment values', () => { + const segments: TenantStorageSegment[] = [ + { + key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, + value: 1.8 * TB, + displayValue: 118 * GB, + progressValue: 2.56 * TB, + }, + { + key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, + value: 0.65 * TB, + displayValue: GB, + progressValue: 1.135 * TB, + }, + { + key: TENANT_STORAGE_SEGMENT_KEYS.topics, + value: 0.65 * TB, + displayValue: GB, + progressValue: 1.135 * TB, + }, + ]; + const displayValues = segments.map(getTenantStorageSegmentDisplayValue); + const formatValue = getTenantStorageLegendValueFormatter(displayValues); + + expect(formatValue(displayValues[0])).toBe(withUnit('118', 'GB')); + expect(formatValue(displayValues[1])).toBe(withUnit('1', 'GB')); + expect(formatValue(displayValues[2])).toBe(withUnit('1', 'GB')); + }); + + test('formats segment tooltip values one unit below the common legend unit', () => { + const gbFormatters = getTenantStorageSegmentValueFormatters([ + 600_000_000_000, 900_000_000_000, + ]); + const mbFormatters = getTenantStorageSegmentValueFormatters([3_000_000, 5_000_000]); + + expect(gbFormatters.formatLegendValue(600_000_000_000)).toBe(withUnit('600', 'GB')); + expect(gbFormatters.formatTooltipValue(600_000_000_000)).toBe( + withUnit(['600', '000'].join(UNBREAKABLE_GAP), 'MB'), + ); + expect(mbFormatters.formatLegendValue(3_000_000)).toBe(withUnit('3', 'MB')); + expect(mbFormatters.formatTooltipValue(3_000_000)).toBe( + withUnit(['3', '000'].join(UNBREAKABLE_GAP), 'KB'), + ); + }); + + test('formats mixed-unit segment tooltips one unit below each adaptive legend unit', () => { + const formatters = getTenantStorageSegmentValueFormatters([ + 70_000_000_000, 21_000_000_000_000, + ]); + + expect(formatters.formatLegendValue(21_000_000_000_000)).toBe(withUnit('21', 'TB')); + expect(formatters.formatTooltipValue(21_000_000_000_000)).toBe( + withUnit(['21', '000'].join(UNBREAKABLE_GAP), 'GB'), + ); + expect(formatters.formatLegendValue(70_000_000_000)).toBe(withUnit('70', 'GB')); + expect(formatters.formatTooltipValue(70_000_000_000)).toBe( + withUnit(['70', '000'].join(UNBREAKABLE_GAP), 'MB'), + ); + }); + + test('keeps bytes as the lowest segment tooltip unit', () => { + const formatters = getTenantStorageSegmentValueFormatters([500_000, 800_000]); + + expect(formatters.formatLegendValue(500_000)).toBe(withUnit('500', 'KB')); + expect(formatters.formatTooltipValue(500_000)).toBe( + withUnit(['500', '000'].join(UNBREAKABLE_GAP), 'B'), + ); + expect(formatters.formatTooltipValue(undefined)).toBe(EMPTY_DATA_PLACEHOLDER); + }); +}); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/utils.test.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/utils.test.ts new file mode 100644 index 0000000000..362dfa9be1 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/__test__/utils.test.ts @@ -0,0 +1,953 @@ +import {EPathType} from '../../../../../../types/api/schema'; +import {EType} from '../../../../../../types/api/tenant'; +import { + TENANT_STORAGE_SEGMENT_KEYS, + TENANT_STORAGE_SYSTEM_DETAIL_KEYS, + buildTenantStorageData, + buildTenantStorageMediaSections, + getTenantStorageMediaKey, + getTenantStoragePhysicalDisplaySegments, + getTenantStoragePhysicalMediaBreakdown, + getTenantStorageUserDataDisplaySummary, + isSystemStoragePath, + mergeSystemDetailsByMedia, +} from '../utils'; + +describe('buildTenantStorageData', () => { + test('maps logical user data and physical segments by media', () => { + const topRowsError = new Error('top rows failed'); + const result = buildTenantStorageData( + { + logicalUserData: { + rowTables: 300, + topics: 50, + }, + tabletTypeRows: [ + { + Type: 'DataShard', + StorageSize: 500, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 500}], + }, + { + Type: 'ColumnShard', + StorageSize: 150, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 150}], + }, + { + Type: 'PersQueue', + StorageSize: 75, + Media: [{Kind: 'ROT,Kind:0', StorageSize: 75}], + }, + { + Type: 'PersQueueReadBalancer', + StorageSize: 25, + Media: [{Kind: 'ROT,Kind:0', StorageSize: 25}], + }, + { + Type: 'Hive', + StorageSize: 175, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 175}], + }, + { + Type: 'Unknown', + StorageSize: 50, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 50}], + }, + ], + topRows: [ + { + path: '/local/db/table-a', + pathType: EPathType.EPathTypeTable, + userData: 200, + physicalDisk: 300, + }, + ], + topRowsError, + }, + { + blobStorageUsed: 950, + blobStorageLimit: 1_200, + tabletStorageUsed: 450, + tabletStorageLimit: 1_000, + }, + ); + + expect(result.logicalUserData).toEqual({ + rowTables: 300, + topics: 50, + }); + expect(result.userDataSegments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 300}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 50}, + ]); + expect(result.summary.userData.used).toBe(350); + expect(result.summary.userData.available).toBe(650); + + expect(result.physicalSegmentsByMedia.SSD).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 175}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 150}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 50}, + ]); + expect(result.physicalSegmentsByMedia.HDD).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(result.systemDetailsByMedia.SSD).toEqual([ + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 175}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.mediator, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.sysViewProcessor, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.graphShard, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.statisticsAggregator, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.cms, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.nodeBroker, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.tenantSlotBroker, value: 0}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.console, value: 0}, + ]); + expect(result.summary.physical.used).toBe(950); + expect(result.summary.physical.overhead).toBeCloseTo(950 / 350); + + expect(result.topRows).toEqual([ + { + path: '/local/db/table-a', + pathType: EPathType.EPathTypeTable, + userData: 200, + physicalDisk: 300, + dbShare: 200 / 350, + overhead: 1.5, + }, + ]); + expect(result.topRowsError).toBe(topRowsError); + }); + + test('maps old system tablet types to system physical details', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [ + { + Type: 'OldHive', + StorageSize: 10, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 10}], + }, + { + Type: 'OldCoordinator', + StorageSize: 20, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 20}], + }, + { + Type: 'OldSchemeShard', + StorageSize: 30, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 30}], + }, + { + Type: 'OldBSController', + StorageSize: 40, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 40}], + }, + ], + topRows: [], + }, + { + blobStorageUsed: 100, + tabletStorageUsed: 50, + }, + ); + + expect(result.physicalSegmentsByMedia.SSD).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(result.systemDetailsByMedia.SSD).toEqual( + expect.arrayContaining([ + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 10}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, value: 20}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, value: 30}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController, value: 40}, + ]), + ); + }); + + test('maps unknown tablet media to aggregate physical breakdown', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [ + { + Type: 'DataShard', + StorageSize: 120, + Media: [{Kind: 'Unknown', StorageSize: 120}], + }, + { + Type: 'Hive', + StorageSize: 30, + Media: [{Kind: 'UNKNOWN,Kind:0', StorageSize: 30}], + }, + ], + topRows: [], + }, + { + blobStorageUsed: 150, + tabletStorageUsed: 50, + }, + ); + + expect(getTenantStorageMediaKey('Unknown')).toBe(EType.None); + expect(getTenantStorageMediaKey('UNKNOWN,Kind:0')).toBe(EType.None); + expect(result.physicalSegmentsByMedia.Unknown).toBeUndefined(); + expect(result.physicalSegmentsByMedia[EType.None]).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 30}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 120}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(result.systemDetailsByMedia[EType.None]).toEqual( + expect.arrayContaining([{key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 30}]), + ); + }); + + test('falls back to tablet storage metric for database share', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [], + topRows: [ + { + path: '/local/db/table-a', + userData: 120, + physicalDisk: 360, + }, + ], + }, + { + blobStorageUsed: 900, + tabletStorageUsed: 480, + }, + ); + + expect(result.summary.userData.used).toBe(480); + expect(result.topRows[0]?.dbShare).toBe(120 / 480); + expect(result.topRows[0]?.overhead).toBe(3); + }); + + test('falls back to tablet storage metric when logical user data is partial', () => { + const result = buildTenantStorageData( + { + logicalUserData: { + rowTables: 120, + topics: undefined, + }, + tabletTypeRows: [], + topRows: [ + { + path: '/local/db/table-a', + userData: 120, + physicalDisk: 360, + }, + ], + }, + { + blobStorageUsed: 900, + tabletStorageUsed: 480, + }, + ); + + expect(result.summary.userData.used).toBe(480); + expect(result.userDataSegments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 120}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 360}, + ]); + expect(result.topRows[0]?.dbShare).toBe(120 / 480); + expect(result.topRows[0]?.overhead).toBe(3); + }); + + test('preserves missing top row physical disk size as unknown', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [], + topRows: [ + { + path: '/local/db/table-a', + userData: 120, + }, + ], + }, + { + blobStorageUsed: 900, + tabletStorageUsed: 480, + }, + ); + + expect(result.topRows[0]).toEqual({ + path: '/local/db/table-a', + userData: 120, + physicalDisk: undefined, + dbShare: 120 / 480, + overhead: undefined, + }); + }); + + test('falls back to aggregate tablet type storage size when media breakdown is missing', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [ + { + Type: 'DataShard', + StorageSize: 500, + }, + { + Type: 'Hive', + StorageSize: 100, + }, + { + Type: 'PersQueue', + StorageSize: 50, + }, + ], + topRows: [], + }, + { + blobStorageUsed: 650, + blobStorageLimit: 1_000, + tabletStorageUsed: 350, + tabletStorageLimit: 700, + }, + ); + + expect(result.physicalSegmentsByMedia[EType.None]).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 50}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(result.summary.physical.segments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 50}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(result.summary.physical.overhead).toBeCloseTo(650 / 350); + }); + + test('uses aggregate physical breakdown for single concrete media when per-media breakdown is missing', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [ + { + Type: 'DataShard', + StorageSize: 500, + }, + { + Type: 'Hive', + StorageSize: 100, + }, + ], + topRows: [], + }, + { + blobStorageUsed: 600, + blobStorageLimit: 1_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + ); + + const breakdownWithFallback = getTenantStoragePhysicalMediaBreakdown({ + allowAggregateFallback: true, + mediaType: EType.SSD, + physicalSegmentsByMedia: result.physicalSegmentsByMedia, + systemDetailsByMedia: result.systemDetailsByMedia, + }); + const breakdownWithoutFallback = getTenantStoragePhysicalMediaBreakdown({ + mediaType: EType.SSD, + physicalSegmentsByMedia: result.physicalSegmentsByMedia, + systemDetailsByMedia: result.systemDetailsByMedia, + }); + + expect(breakdownWithFallback.segments).toBe(result.physicalSegmentsByMedia[EType.None]); + expect(breakdownWithFallback.systemDetails).toBe(result.systemDetailsByMedia[EType.None]); + expect(breakdownWithFallback.segments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(breakdownWithFallback.systemDetails).toEqual( + expect.arrayContaining([{key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 100}]), + ); + expect(breakdownWithoutFallback).toEqual({ + segments: undefined, + systemDetails: undefined, + }); + }); + + test('merges aggregate and concrete physical breakdown for single concrete media', () => { + const result = buildTenantStorageData( + { + tabletTypeRows: [ + { + Type: 'DataShard', + StorageSize: 500, + Media: [{Kind: 'SSD,Kind:0', StorageSize: 500}], + }, + { + Type: 'Hive', + StorageSize: 100, + }, + ], + topRows: [], + }, + { + blobStorageUsed: 600, + blobStorageLimit: 1_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + ); + + const breakdownWithFallback = getTenantStoragePhysicalMediaBreakdown({ + allowAggregateFallback: true, + mediaType: EType.SSD, + physicalSegmentsByMedia: result.physicalSegmentsByMedia, + systemDetailsByMedia: result.systemDetailsByMedia, + }); + const breakdownWithoutFallback = getTenantStoragePhysicalMediaBreakdown({ + mediaType: EType.SSD, + physicalSegmentsByMedia: result.physicalSegmentsByMedia, + systemDetailsByMedia: result.systemDetailsByMedia, + }); + + expect(breakdownWithFallback.segments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + expect(breakdownWithFallback.systemDetails).toEqual( + expect.arrayContaining([{key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 100}]), + ); + expect(breakdownWithoutFallback.segments).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 0}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ]); + }); + + test('returns zero database share when logical used is zero', () => { + const result = buildTenantStorageData( + { + logicalUserData: { + rowTables: 0, + topics: 0, + }, + tabletTypeRows: [], + topRows: [ + { + path: '/local/db/table-a', + userData: 120, + physicalDisk: 360, + }, + ], + }, + { + blobStorageUsed: 900, + tabletStorageUsed: 480, + }, + ); + + expect(result.summary.userData.used).toBe(0); + expect(result.topRows[0]?.dbShare).toBe(0); + expect(result.topRows[0]?.overhead).toBe(3); + }); + + test('adds unknown remainder to physical display segments', () => { + const result = getTenantStoragePhysicalDisplaySegments({ + segments: [ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 175}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 150}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 0}, + ], + used: 1_000, + }); + + expect(result).toEqual([ + {key: TENANT_STORAGE_SEGMENT_KEYS.system, value: 175}, + {key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, value: 500}, + {key: TENANT_STORAGE_SEGMENT_KEYS.columnTables, value: 150}, + {key: TENANT_STORAGE_SEGMENT_KEYS.topics, value: 100}, + {key: TENANT_STORAGE_SEGMENT_KEYS.unknown, value: 75}, + ]); + }); + + test('uses logical user data only when requested', () => { + const result = getTenantStorageUserDataDisplaySummary({ + summary: { + used: 120, + quota: 300, + available: 180, + usedPercent: 40, + segments: [], + }, + logicalUserData: { + rowTables: 60, + topics: 20, + }, + useLogicalBreakdown: true, + physical: { + used: 400, + total: 800, + available: 400, + usedPercent: 50, + segments: [], + }, + }); + + expect(result).toEqual({ + used: 80, + quota: 300, + available: 220, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result.usedPercent).toBeCloseTo(26.666666666666668); + }); + + test('keeps summary unchanged when logical breakdown is disabled', () => { + const summary = { + used: 120, + quota: 300, + available: 180, + usedPercent: 40, + segments: [], + }; + + expect( + getTenantStorageUserDataDisplaySummary({ + summary, + logicalUserData: { + rowTables: 60, + topics: 20, + }, + useLogicalBreakdown: false, + physical: { + used: 400, + total: 800, + available: 400, + usedPercent: 50, + segments: [], + }, + }), + ).toBe(summary); + }); + + test('estimates logical available when quota is missing', () => { + const result = getTenantStorageUserDataDisplaySummary({ + summary: { + used: 120, + quota: undefined, + available: undefined, + usedPercent: 0, + segments: [], + }, + logicalUserData: { + rowTables: 100, + topics: 20, + }, + useLogicalBreakdown: true, + physical: { + used: 360, + total: 600, + available: 240, + usedPercent: 60, + segments: [], + }, + }); + + expect(result.availableApproximate).toBe(true); + expect(result.used).toBe(120); + expect(result.available).toBeCloseTo(80); + expect(result.total).toBeCloseTo(200); + expect(result.usedPercent).toBeCloseTo(60); + expect(result.quota).toBeUndefined(); + }); + + test('estimates media user data available when quota is missing', () => { + const result = getTenantStorageUserDataDisplaySummary({ + summary: { + used: 120, + quota: undefined, + available: undefined, + usedPercent: 0, + segments: [], + }, + useLogicalBreakdown: false, + physical: { + used: 360, + total: 600, + available: 240, + usedPercent: 60, + segments: [], + }, + }); + + expect(result.availableApproximate).toBe(true); + expect(result.available).toBeCloseTo(80); + expect(result.usedPercent).toBeCloseTo(60); + }); + + test('does not estimate logical available when overhead inputs are missing', () => { + const result = getTenantStorageUserDataDisplaySummary({ + summary: { + used: 0, + quota: undefined, + available: undefined, + usedPercent: 0, + segments: [], + }, + logicalUserData: { + rowTables: 0, + topics: 0, + }, + useLogicalBreakdown: true, + physical: { + used: 360, + total: 600, + available: 240, + usedPercent: 60, + segments: [], + }, + }); + + expect(result.availableApproximate).toBeUndefined(); + expect(result.available).toBeUndefined(); + expect(result.total).toBeUndefined(); + expect(result.usedPercent).toBe(0); + }); + + test('treats paths under .sys and .metadata as system objects', () => { + expect(isSystemStoragePath('/local/db/.sys/internal/table')).toBe(true); + expect(isSystemStoragePath('/local/db/.metadata/migrations')).toBe(true); + expect(isSystemStoragePath('/local/db/regular-table')).toBe(false); + }); + + test('aggregates system details across media', () => { + const result = mergeSystemDetailsByMedia({ + SSD: [ + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 10}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, value: 3}, + ], + HDD: [ + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 15}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, value: 7}, + ], + }); + + expect(result).toEqual( + expect.arrayContaining([ + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, value: 25}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, value: 3}, + {key: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, value: 7}, + ]), + ); + }); + + test('builds media sections from TablesStorage and DatabaseStorage', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [ + {name: EType.SSD, used: 400, limit: 1_000}, + {name: EType.HDD, used: 600, limit: 2_000}, + ], + metrics: { + blobStorageUsed: 1_000, + blobStorageLimit: 3_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + tabletStorageStats: [ + {name: EType.SSD, used: 250, limit: 500}, + {name: EType.HDD, used: 50, limit: 200}, + ], + }); + + expect(result).toEqual([ + { + mediaType: EType.SSD, + userData: { + available: 250, + quota: 500, + used: 250, + usedPercent: 50, + segments: [], + }, + physical: { + available: 600, + overhead: 1.6, + total: 1_000, + used: 400, + usedPercent: 40, + segments: [], + }, + }, + { + mediaType: EType.HDD, + userData: { + available: 150, + quota: 200, + used: 50, + usedPercent: 25, + segments: [], + }, + physical: { + available: 1_400, + overhead: 12, + total: 2_000, + used: 600, + usedPercent: 30, + segments: [], + }, + }, + ]); + }); + + test('normalizes and sums duplicate media stats', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [ + {name: 'ssd', used: 100, limit: 1_000}, + {name: EType.SSD, used: 50, limit: 500}, + ], + metrics: { + blobStorageUsed: 150, + blobStorageLimit: 1_500, + tabletStorageUsed: 50, + tabletStorageLimit: 300, + }, + tabletStorageStats: [ + {name: 'SSD', used: 20, limit: 100}, + {name: 'ssd', used: 30, limit: 200}, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.SSD); + expect(result[0]?.userData).toEqual({ + available: 250, + quota: 300, + used: 50, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result[0]?.physical).toEqual({ + available: 1_350, + overhead: 3, + total: 1_500, + used: 150, + usedPercent: 10, + segments: [], + }); + expect(result[0]?.userData.usedPercent).toBeCloseTo(16.666666666666668); + }); + + test('matches aliased blob and tablet media types', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [{name: 'ROT', used: 200, limit: 400}], + metrics: { + blobStorageUsed: 200, + blobStorageLimit: 400, + tabletStorageUsed: 50, + tabletStorageLimit: 100, + }, + tabletStorageStats: [{name: 'hdd', used: 50, limit: 100}], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.HDD); + expect(result[0]?.userData.used).toBe(50); + expect(result[0]?.userData.quota).toBe(100); + expect(result[0]?.physical.used).toBe(200); + }); + + test('uses aggregate section when only tablet stats have media split', () => { + const result = buildTenantStorageMediaSections({ + metrics: { + blobStorageUsed: 1_000, + blobStorageLimit: 3_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + tabletStorageStats: [ + {name: EType.SSD, used: 250, limit: 500}, + {name: EType.HDD, used: 50, limit: 200}, + ], + }); + + expect(result).toEqual([ + { + mediaType: EType.None, + userData: { + available: 400, + quota: 700, + used: 300, + usedPercent: expect.any(Number), + segments: [], + }, + physical: { + available: 2_000, + overhead: expect.any(Number), + total: 3_000, + used: 1_000, + usedPercent: expect.any(Number), + segments: [], + }, + }, + ]); + expect(result[0]?.userData.usedPercent).toBeCloseTo(42.857142857142854); + expect(result[0]?.physical.overhead).toBeCloseTo(1_000 / 300); + expect(result[0]?.physical.usedPercent).toBeCloseTo(33.333333333333336); + }); + + test('uses aggregate section when blob storage stats have only None fallback', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [{name: EType.None, used: 1_000, limit: 3_000}], + metrics: { + blobStorageUsed: 1_000, + blobStorageLimit: 3_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + tabletStorageStats: [ + {name: EType.SSD, used: 250, limit: 500}, + {name: EType.HDD, used: 50, limit: 200}, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.None); + expect(result[0]?.userData.used).toBe(300); + expect(result[0]?.physical.used).toBe(1_000); + }); + + test('uses aggregate section when blob stats have media split but tablet stats are aggregate', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [ + {name: EType.SSD, used: 400, limit: 1_000}, + {name: EType.HDD, used: 600, limit: 2_000}, + ], + metrics: { + blobStorageUsed: 1_000, + blobStorageLimit: 3_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + tabletStorageStats: [{name: EType.None, used: 300, limit: 700}], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.None); + expect(result[0]?.userData).toEqual({ + available: 400, + quota: 700, + used: 300, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result[0]?.physical).toEqual({ + available: 2_000, + overhead: expect.any(Number), + total: 3_000, + used: 1_000, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result[0]?.userData.usedPercent).toBeCloseTo(42.857142857142854); + expect(result[0]?.physical.overhead).toBeCloseTo(1_000 / 300); + expect(result[0]?.physical.usedPercent).toBeCloseTo(33.333333333333336); + }); + + test('uses aggregate tablet fallback for a single concrete blob media section', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [{name: EType.SSD, used: 1_000, limit: 3_000}], + metrics: { + blobStorageUsed: 1_000, + blobStorageLimit: 3_000, + tabletStorageUsed: 300, + tabletStorageLimit: 700, + }, + tabletStorageStats: [{name: EType.None, used: 300, limit: 700}], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.SSD); + expect(result[0]?.userData.used).toBe(300); + expect(result[0]?.userData.quota).toBe(700); + }); + + test('falls back to overall quotas for single-media sections when per-type limits are missing', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [{name: EType.SSD, used: 500}], + metrics: { + blobStorageUsed: 500, + blobStorageLimit: 8_600, + tabletStorageUsed: 35.8, + tabletStorageLimit: 120, + }, + tabletStorageStats: [{name: EType.SSD, used: 35.8}], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.mediaType).toBe(EType.SSD); + expect(result[0]?.userData).toEqual({ + available: 84.2, + quota: 120, + used: 35.8, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result[0]?.physical).toEqual({ + available: 8_100, + overhead: expect.any(Number), + total: 8_600, + used: 500, + usedPercent: expect.any(Number), + segments: [], + }); + expect(result[0]?.userData.usedPercent).toBeCloseTo(29.833333333333332); + expect(result[0]?.physical.overhead).toBeCloseTo(500 / 35.8); + expect(result[0]?.physical.usedPercent).toBeCloseTo(5.813953488372093); + }); + + test('treats empty per-media limits as missing when applying aggregate fallback', () => { + const result = buildTenantStorageMediaSections({ + blobStorageStats: [{name: EType.SSD, used: 500, limit: '' as unknown as number}], + metrics: { + blobStorageUsed: 500, + blobStorageLimit: 8_600, + tabletStorageUsed: 35.8, + tabletStorageLimit: 120, + }, + tabletStorageStats: [{name: EType.SSD, used: 35.8, limit: '' as unknown as number}], + }); + + expect(result).toHaveLength(1); + expect(result[0]?.userData.quota).toBe(120); + expect(result[0]?.userData.available).toBe(84.2); + expect(result[0]?.physical.total).toBe(8_600); + expect(result[0]?.physical.available).toBe(8_100); + }); +}); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/displayFormatters.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/displayFormatters.ts new file mode 100644 index 0000000000..e303574bd3 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/displayFormatters.ts @@ -0,0 +1,141 @@ +import type {BytesSizes} from '../../../../../utils/bytesParsers'; +import {getBytesSizeUnit, sizes} from '../../../../../utils/bytesParsers'; +import {EMPTY_DATA_PLACEHOLDER} from '../../../../../utils/constants'; +import {formatNumber, formatPercent} from '../../../../../utils/dataFormatters/dataFormatters'; +import {formatMetricBytes} from '../../../../../utils/storageMetrics'; +import {parseOptionalNonNegativeNumber} from '../../../../../utils/utils'; + +import i18n from './i18n'; + +type TenantStorageSummaryMetricUnit = 'pb' | 'tb' | 'gb' | 'mb'; + +const MIXED_UNIT_RATIO_THRESHOLD = 100; +const TABLE_OVERHEAD_LIMIT = 500; +const BYTE_UNITS: BytesSizes[] = ['b', 'kb', 'mb', 'gb', 'tb', 'pb']; +const TENANT_STORAGE_FORMAT_OPTIONS = { + allowNegative: false, + bytesDecimalPlaces: 0, + gbDecimalPlacesBelowOne: 1, +} as const; + +export function formatSummaryPercent(value: number) { + const precision = value > 0 && value < 1 ? 1 : 0; + const formattedValue = formatPercent(value / 100, precision); + + return value > 0 && formattedValue ? i18n('context_used-percent', {value: formattedValue}) : ''; +} + +function formatByteMetric(value?: string | number, size?: BytesSizes) { + return formatMetricBytes(value, size, TENANT_STORAGE_FORMAT_OPTIONS); +} + +function normalizeTenantStorageSummaryMetricUnit(unit: BytesSizes): TenantStorageSummaryMetricUnit { + return unit === 'b' || unit === 'kb' ? 'mb' : unit; +} + +export function getTenantStorageSummaryMetricUnit( + values: Array, +): TenantStorageSummaryMetricUnit { + const maxValue = values.reduce((currentMax, value) => { + const numericValue = parseOptionalNonNegativeNumber(value); + + return numericValue === undefined ? currentMax : Math.max(currentMax, numericValue); + }, 0); + + return normalizeTenantStorageSummaryMetricUnit(getBytesSizeUnit(maxValue)); +} + +export function formatTenantStorageSummaryMetric( + value?: string | number, + size?: TenantStorageSummaryMetricUnit, +) { + return formatByteMetric(value, size); +} + +export function formatTenantStorageApproximateMetric( + value?: string | number, + size?: TenantStorageSummaryMetricUnit, +) { + const numericValue = parseOptionalNonNegativeNumber(value); + + if (numericValue === undefined) { + return EMPTY_DATA_PLACEHOLDER; + } + + const shouldUseAdaptiveUnit = size !== undefined && numericValue < sizes[size].value; + const formattedValue = shouldUseAdaptiveUnit + ? formatTenantStorageAdaptiveMetric(numericValue) + : formatTenantStorageSummaryMetric(numericValue, size); + + return formattedValue === EMPTY_DATA_PLACEHOLDER ? formattedValue : `~${formattedValue}`; +} + +export function formatTenantStorageAdaptiveMetric(value?: string | number) { + return formatByteMetric(value); +} + +function getLowerByteUnit(size: BytesSizes) { + const index = BYTE_UNITS.indexOf(size); + + return BYTE_UNITS[Math.max(index - 1, 0)] ?? 'b'; +} + +export function getTenantStorageSegmentValueFormatters(values: Array) { + const numericValues = values + .map((value) => parseOptionalNonNegativeNumber(value)) + .filter((value): value is number => value !== undefined && value > 0); + const minValue = Math.min(...numericValues); + const maxValue = Math.max(...numericValues); + const shouldUseMixedUnits = + numericValues.length === 0 || maxValue / minValue >= MIXED_UNIT_RATIO_THRESHOLD; + const commonSize = shouldUseMixedUnits ? undefined : getBytesSizeUnit(maxValue); + const getLegendSize = (value?: string | number) => { + const numericValue = parseOptionalNonNegativeNumber(value); + + if (numericValue === undefined) { + return undefined; + } + + return commonSize ?? getBytesSizeUnit(numericValue); + }; + + return { + formatLegendValue: (value?: string | number) => formatByteMetric(value, commonSize), + formatTooltipValue: (value?: string | number) => { + const legendSize = getLegendSize(value); + + if (legendSize === undefined) { + return EMPTY_DATA_PLACEHOLDER; + } + + return formatByteMetric(value, getLowerByteUnit(legendSize)); + }, + }; +} + +export function getTenantStorageLegendValueFormatter(values: Array) { + return getTenantStorageSegmentValueFormatters(values).formatLegendValue; +} + +export function formatTenantStorageTableMetric(value?: string | number) { + return formatTenantStorageAdaptiveMetric(value); +} + +export function formatOverheadValue(value?: number) { + if (value === undefined || !Number.isFinite(value) || value <= 0) { + return EMPTY_DATA_PLACEHOLDER; + } + + const precision = value >= 10 || Number.isInteger(value) ? 0 : 1; + const normalizedValue = Number(value.toFixed(precision)); + + return `${formatNumber(normalizedValue)}x`; +} + +export function formatTenantStorageTableOverhead(value?: number) { + if (value !== undefined && Number.isFinite(value) && value > TABLE_OVERHEAD_LIMIT) { + return `>${TABLE_OVERHEAD_LIMIT}x`; + } + + return formatOverheadValue(value); +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/en.json b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/en.json new file mode 100644 index 0000000000..89620386d1 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/en.json @@ -0,0 +1,56 @@ +{ + "title_tablet-storage-usage": "Tablet storage usage", + "title_storage-details": "Storage Details", + "title_top-tables-by-size": "Top tables by size", + "title_top-groups-by-usage": "Top groups by usage", + "title_tablet-storage": "Tablet storage", + "context_tablet-storage-description": "Size of user data and indexes stored in schema objects (tables, topics, etc.)", + "title_database-storage": "Database storage", + "context_database-storage-description": "Size of data stored in distributed storage with all overheads for redundancy", + "title_user-data": "User data", + "context_user-data-by-type": "Data usage by type", + "context_user-data-tooltip": "Logical user data volume by object type (row tables, column tables, topics, etc.). Does not include system overhead, replication, or physical storage size.", + "title_physical-disk-usage": "Physical disk usage", + "context_physical-disk-usage-description": "Including written data plus system and metadata overhead", + "context_available-approximate": "Approximate value based on your current physical-to-logical storage usage pattern.", + "field_available": "Available", + "field_used": "Used", + "field_quota": "Quota", + "field_total": "Total", + "field_overhead": "Overhead", + "alert_missing-quota-title": "No quota? This is wrong.", + "alert_missing-quota-description": "This mode lets your database consume shared storage and is only for dev/test. Set a quota for stability.", + "value_no-limit": "no limit", + "value_row-tables": "Row tables", + "value_column-tables": "Column tables", + "value_topics": "Topics", + "value_system": "System", + "value_unknown": "Unknown", + "value_system-detail-hive": "Hive", + "value_system-detail-coordinator": "Coordinator", + "value_system-detail-mediator": "Mediator", + "value_system-detail-scheme-shard": "SchemeShard", + "value_system-detail-sys-view-processor": "SysViewProcessor", + "value_system-detail-graph-shard": "GraphShard", + "value_system-detail-statistics-aggregator": "StatisticsAggregator", + "value_system-detail-bs-controller": "BSController", + "value_system-detail-cms": "Cms", + "value_system-detail-node-broker": "NodeBroker", + "value_system-detail-tenant-slot-broker": "TenantSlotBroker", + "value_system-detail-console": "Console", + "context_used-percent": "used {{value}}", + "context_segment-share": "{{value}} of total {{totalLabel}}", + "value_total-user-data": "user data", + "value_total-physical-disk-usage": "physical disk usage", + "title_top-space-usage-main": "Top 10", + "title_top-space-usage-suffix": "by space usage", + "field_type": "Type", + "field_object-path": "Object & path", + "field_database-space": "Database space", + "field_user-data": "User data", + "field_physical-disk": "Physical disk", + "context_overhead-description": "Physical disk usage divided by user data size for the current object or database.", + "value_row-table": "Row table", + "value_system-table": "System table", + "value_object": "Object" +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/index.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/index.ts new file mode 100644 index 0000000000..30c1a68fc2 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/i18n/index.ts @@ -0,0 +1,8 @@ +import {registerKeysets} from '../../../../../../utils/i18n'; + +import en from './en.json'; + +// Tenant storage overview: summary cards, segment legends, quota warnings, and top usage table. +const COMPONENT = 'ydb-diagnostics-tenant-storage'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/storageDashboardConfig.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/storageDashboardConfig.ts index 9add0b73ce..32f01b0a8c 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/storageDashboardConfig.ts +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/storageDashboardConfig.ts @@ -1,13 +1,14 @@ import type {ChartConfig} from '../TenantDashboard/TenantDashboard'; -import i18n from '../i18n'; + +import i18n from './i18n'; export const storageDashboardConfig: ChartConfig[] = [ { - title: i18n('charts.storage-usage'), + title: i18n('title_tablet-storage-usage'), metrics: [ { target: 'resources.storage.used_bytes', - title: i18n('charts.storage-usage'), + title: i18n('title_tablet-storage-usage'), }, ], options: { diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/types.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/types.ts new file mode 100644 index 0000000000..093ff879a8 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/types.ts @@ -0,0 +1,18 @@ +import type {TenantStorageStats} from '../../../../../store/reducers/tenants/utils'; +import type {ETenantType} from '../../../../../types/api/tenant'; + +export interface TenantStorageMetrics { + blobStorageUsed?: number; + blobStorageLimit?: number; + tabletStorageUsed?: number; + tabletStorageLimit?: number; +} + +export interface TenantStorageProps { + database: string; + databaseFullPath: string; + metrics: TenantStorageMetrics; + blobStorageStats?: TenantStorageStats[]; + tabletStorageStats?: TenantStorageStats[]; + databaseType?: ETenantType; +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/useTenantStorageNewData.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/useTenantStorageNewData.ts new file mode 100644 index 0000000000..487ec7ff00 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/useTenantStorageNewData.ts @@ -0,0 +1,38 @@ +import React from 'react'; + +import {useClusterWithProxy} from '../../../../../store/reducers/cluster/cluster'; +import {tenantOverviewStorageApi} from '../../../../../store/reducers/tenantOverview/storage/tenantOverviewStorage'; +import {useAutoRefreshInterval} from '../../../../../utils/hooks'; + +import type {TenantStorageMetrics} from './types'; +import {buildTenantStorageData} from './utils'; +import type {TenantStorageData} from './utils'; + +interface UseTenantStorageNewDataParams { + database: string; + databaseFullPath: string; + metrics: TenantStorageMetrics; +} + +export function useTenantStorageNewData({ + database, + databaseFullPath, + metrics, +}: UseTenantStorageNewDataParams) { + const useMetaProxy = useClusterWithProxy(); + const [autoRefreshInterval] = useAutoRefreshInterval(); + + const query = tenantOverviewStorageApi.useGetTenantStorageRawDataQuery( + {database, databaseFullPath, useMetaProxy}, + {pollingInterval: autoRefreshInterval}, + ); + + const data = React.useMemo(() => { + return buildTenantStorageData(query.currentData, metrics); + }, [metrics, query.currentData]); + + return { + ...query, + data, + }; +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/utils.ts b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/utils.ts new file mode 100644 index 0000000000..5d0ca27a04 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantStorage/utils.ts @@ -0,0 +1,803 @@ +import type {TenantStorageRawData} from '../../../../../store/reducers/tenantOverview/storage/tenantOverviewStorage'; +import type {TenantStorageStats} from '../../../../../store/reducers/tenants/utils'; +import type {EPathSubType, EPathType} from '../../../../../types/api/schema'; +import type {TStorageStatsTabletTypeEntry} from '../../../../../types/api/storage'; +import {EType as ETabletType} from '../../../../../types/api/tablet'; +import {EType} from '../../../../../types/api/tenant'; +import { + UNKNOWN_MEDIA_TYPE, + normalizeMediaType, +} from '../../../../../utils/disks/normalizeMediaType'; +import {parseNonNegativeNumber, parseOptionalNonNegativeNumber} from '../../../../../utils/utils'; + +import type {TenantStorageMetrics} from './types'; + +export const TENANT_STORAGE_SEGMENT_KEYS = { + rowTables: 'rowTables', + columnTables: 'columnTables', + topics: 'topics', + system: 'system', + unknown: 'unknown', +} as const; + +export const TENANT_STORAGE_SYSTEM_DETAIL_KEYS = { + hive: 'Hive', + coordinator: 'Coordinator', + mediator: 'Mediator', + schemeShard: 'SchemeShard', + sysViewProcessor: 'SysViewProcessor', + graphShard: 'GraphShard', + statisticsAggregator: 'StatisticsAggregator', + bsController: 'BSController', + cms: 'Cms', + nodeBroker: 'NodeBroker', + tenantSlotBroker: 'TenantSlotBroker', + console: 'Console', +} as const; + +export type TenantStorageSegmentKey = + (typeof TENANT_STORAGE_SEGMENT_KEYS)[keyof typeof TENANT_STORAGE_SEGMENT_KEYS]; + +export type TenantStorageSystemDetailKey = + (typeof TENANT_STORAGE_SYSTEM_DETAIL_KEYS)[keyof typeof TENANT_STORAGE_SYSTEM_DETAIL_KEYS]; + +export interface TenantStorageSegment { + key: TenantStorageSegmentKey; + value: number; + displayValue?: number; + progressValue?: number; +} + +export interface TenantStorageSystemDetail { + key: TenantStorageSystemDetailKey; + value: number; +} + +export interface TenantStorageSummary { + available?: number; + availableApproximate?: boolean; + overhead?: number; + preserveDisplayValues?: boolean; + quota?: number; + total?: number; + used: number; + usedPercent: number; + segments: TenantStorageSegment[]; +} + +export interface TenantStorageTopRow { + displayPath?: string; + displayTypeLabel?: string; + path: string; + pathType?: EPathType; + pathSubType?: EPathSubType; + userData: number; + physicalDisk?: number; + dbShare: number; + overhead?: number; +} + +export interface TenantStorageLogicalUserData { + rowTables?: number; + topics?: number; +} + +export interface TenantStorageData { + logicalUserData?: TenantStorageLogicalUserData; + summary: { + userData: TenantStorageSummary; + physical: TenantStorageSummary; + }; + topRows: TenantStorageTopRow[]; + topRowsError?: unknown; + userDataSegments: TenantStorageSegment[]; + physicalSegmentsByMedia: Record; + systemDetailsByMedia: Record; +} + +export interface TenantStorageMediaSection { + mediaType: string; + userData: TenantStorageSummary; + physical: TenantStorageSummary; +} + +export function getTenantStorageSegmentDisplayValue(segment: TenantStorageSegment) { + return segment.displayValue ?? segment.value; +} + +const SYSTEM_STORAGE_PATH_PATTERN = /(^|\/)\.(sys|metadata)(\/|$)/; + +const PHYSICAL_SEGMENT_KEYS = [ + TENANT_STORAGE_SEGMENT_KEYS.system, + TENANT_STORAGE_SEGMENT_KEYS.rowTables, + TENANT_STORAGE_SEGMENT_KEYS.columnTables, + TENANT_STORAGE_SEGMENT_KEYS.topics, + TENANT_STORAGE_SEGMENT_KEYS.unknown, +] as const; + +const PHYSICAL_SYSTEM_TABLET_DETAIL_KEY_BY_TYPE: Partial< + Record +> = { + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, + [ETabletType.OldHive]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.hive, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, + [ETabletType.OldCoordinator]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.coordinator, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.mediator]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.mediator, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, + [ETabletType.OldSchemeShard]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.schemeShard, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.sysViewProcessor]: + TENANT_STORAGE_SYSTEM_DETAIL_KEYS.sysViewProcessor, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.graphShard]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.graphShard, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.statisticsAggregator]: + TENANT_STORAGE_SYSTEM_DETAIL_KEYS.statisticsAggregator, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController]: + TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController, + [ETabletType.OldBSController]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.bsController, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.cms]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.cms, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.nodeBroker]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.nodeBroker, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.tenantSlotBroker]: + TENANT_STORAGE_SYSTEM_DETAIL_KEYS.tenantSlotBroker, + [TENANT_STORAGE_SYSTEM_DETAIL_KEYS.console]: TENANT_STORAGE_SYSTEM_DETAIL_KEYS.console, +}; + +function normalizeNumber(value: number | string | undefined) { + return parseNonNegativeNumber(value); +} + +function toOptionalNumber(value: number | string | undefined) { + return parseOptionalNonNegativeNumber(value); +} + +function calculateUsedPercent(used: number, total?: number) { + if (!total || total <= 0 || used <= 0) { + return 0; + } + + return Math.min((used / total) * 100, 100); +} + +function hasCompleteLogicalUserData( + logicalUserData?: TenantStorageLogicalUserData, +): logicalUserData is Required { + return logicalUserData?.rowTables !== undefined && logicalUserData.topics !== undefined; +} + +function getUserLogicalSegments( + logicalUserData: TenantStorageLogicalUserData | undefined, + used: number, +) { + const rowTables = logicalUserData?.rowTables ?? 0; + const topics = logicalUserData?.topics ?? 0; + const segments: TenantStorageSegment[] = [ + { + key: TENANT_STORAGE_SEGMENT_KEYS.rowTables, + value: rowTables, + }, + { + key: TENANT_STORAGE_SEGMENT_KEYS.topics, + value: topics, + }, + ]; + + if (!logicalUserData || hasCompleteLogicalUserData(logicalUserData)) { + return segments; + } + + return [ + ...segments, + { + key: TENANT_STORAGE_SEGMENT_KEYS.unknown, + value: Math.max(used - rowTables - topics, 0), + }, + ]; +} + +function getPhysicalSegmentsBase() { + return PHYSICAL_SEGMENT_KEYS.map((key) => ({key, value: 0})); +} + +function getSystemDetailsBase() { + return Object.values(TENANT_STORAGE_SYSTEM_DETAIL_KEYS).map((key) => ({key, value: 0})); +} + +function accumulateSegment( + segments: TenantStorageSegment[], + key: TenantStorageSegmentKey, + value: number, +) { + if (value <= 0) { + return segments; + } + + return segments.map((segment) => { + if (segment.key !== key) { + return segment; + } + + return { + ...segment, + value: segment.value + value, + }; + }); +} + +function accumulateSystemDetail( + details: TenantStorageSystemDetail[], + key: TenantStorageSystemDetailKey, + value: number, +) { + if (value <= 0) { + return details; + } + + return details.map((detail) => { + if (detail.key !== key) { + return detail; + } + + return { + ...detail, + value: detail.value + value, + }; + }); +} + +function sumSegments(segments: TenantStorageSegment[]) { + return segments.reduce((sum, segment) => sum + segment.value, 0); +} + +function getSystemDetailKey(type?: string) { + return type ? PHYSICAL_SYSTEM_TABLET_DETAIL_KEY_BY_TYPE[type] : undefined; +} + +function getPhysicalSegmentKey(type?: string) { + switch (type) { + case ETabletType.DataShard: + case ETabletType.OldDataShard: + return TENANT_STORAGE_SEGMENT_KEYS.rowTables; + case ETabletType.ColumnShard: + return TENANT_STORAGE_SEGMENT_KEYS.columnTables; + case ETabletType.PersQueue: + case ETabletType.PersQueueReadBalancer: + return TENANT_STORAGE_SEGMENT_KEYS.topics; + case 'Unknown': + return TENANT_STORAGE_SEGMENT_KEYS.unknown; + default: + return getSystemDetailKey(type) + ? TENANT_STORAGE_SEGMENT_KEYS.system + : TENANT_STORAGE_SEGMENT_KEYS.unknown; + } +} + +export function getTenantStorageMediaKey(mediaType?: string) { + if (!mediaType || mediaType === EType.None) { + return EType.None; + } + + const normalizedMediaType = normalizeMediaType(mediaType); + + if ( + normalizedMediaType === UNKNOWN_MEDIA_TYPE || + normalizedMediaType === EType.None.toUpperCase() + ) { + return EType.None; + } + + return normalizedMediaType; +} + +function getTabletTypeMediaEntries(row: TStorageStatsTabletTypeEntry) { + const mediaEntries = Array.isArray(row.Media) + ? row.Media.map((mediaEntry) => ({ + mediaKey: getTenantStorageMediaKey(mediaEntry.Kind), + storageSize: normalizeNumber(mediaEntry.StorageSize), + })).filter((mediaEntry) => mediaEntry.storageSize > 0) + : []; + + if (mediaEntries.length > 0) { + return mediaEntries; + } + + const aggregateStorageSize = normalizeNumber(row.StorageSize); + + if (aggregateStorageSize <= 0) { + return []; + } + + return [{mediaKey: EType.None, storageSize: aggregateStorageSize}]; +} + +function mergeSegments(segmentsByMedia: Record) { + return Object.values(segmentsByMedia).reduce((result, segments) => { + let nextResult = result; + + for (const segment of segments) { + nextResult = accumulateSegment(nextResult, segment.key, segment.value); + } + + return nextResult; + }, getPhysicalSegmentsBase()); +} + +function mergeOptionalSegments( + segments: TenantStorageSegment[] | undefined, + fallbackSegments: TenantStorageSegment[] | undefined, +) { + if (!segments) { + return fallbackSegments; + } + + if (!fallbackSegments) { + return segments; + } + + return mergeSegments({fallbackSegments, segments}); +} + +export function mergeSystemDetailsByMedia( + detailsByMedia: Record, +) { + return Object.values(detailsByMedia).reduce((result, details) => { + let nextResult = result; + + for (const detail of details) { + nextResult = accumulateSystemDetail(nextResult, detail.key, detail.value); + } + + return nextResult; + }, getSystemDetailsBase()); +} + +function mergeOptionalSystemDetails( + systemDetails: TenantStorageSystemDetail[] | undefined, + fallbackSystemDetails: TenantStorageSystemDetail[] | undefined, +) { + if (!systemDetails) { + return fallbackSystemDetails; + } + + if (!fallbackSystemDetails) { + return systemDetails; + } + + return mergeSystemDetailsByMedia({fallbackSystemDetails, systemDetails}); +} + +export function getTenantStoragePhysicalMediaBreakdown({ + allowAggregateFallback = false, + mediaType, + physicalSegmentsByMedia, + systemDetailsByMedia, +}: { + allowAggregateFallback?: boolean; + mediaType?: string; + physicalSegmentsByMedia: Record; + systemDetailsByMedia: Record; +}) { + const mediaKey = getTenantStorageMediaKey(mediaType); + const segments = physicalSegmentsByMedia[mediaKey]; + const systemDetails = systemDetailsByMedia[mediaKey]; + + if (!allowAggregateFallback || mediaKey === EType.None) { + return {segments, systemDetails}; + } + + const fallbackSegments = physicalSegmentsByMedia[EType.None]; + const fallbackSystemDetails = systemDetailsByMedia[EType.None]; + + return { + segments: mergeOptionalSegments(segments, fallbackSegments), + systemDetails: mergeOptionalSystemDetails(systemDetails, fallbackSystemDetails), + }; +} + +function buildPhysicalSegmentsByMedia(tabletTypeRows: TStorageStatsTabletTypeEntry[] | undefined) { + const segmentsByMedia: Record = {}; + const systemDetailsByMedia: Record = {}; + + for (const row of tabletTypeRows ?? []) { + const physicalKey = getPhysicalSegmentKey(row.Type); + const systemDetailKey = getSystemDetailKey(row.Type); + const mediaEntries = getTabletTypeMediaEntries(row); + + for (const mediaEntry of mediaEntries) { + const currentSegments = + segmentsByMedia[mediaEntry.mediaKey] ?? getPhysicalSegmentsBase(); + segmentsByMedia[mediaEntry.mediaKey] = accumulateSegment( + currentSegments, + physicalKey, + mediaEntry.storageSize, + ); + + if (!systemDetailKey) { + continue; + } + + const currentDetails = + systemDetailsByMedia[mediaEntry.mediaKey] ?? getSystemDetailsBase(); + systemDetailsByMedia[mediaEntry.mediaKey] = accumulateSystemDetail( + currentDetails, + systemDetailKey, + mediaEntry.storageSize, + ); + } + } + + return { + physicalSegmentsByMedia: segmentsByMedia, + systemDetailsByMedia, + }; +} + +export function getTenantStoragePhysicalDisplaySegments({ + segments, + used, +}: { + segments: TenantStorageSegment[] | undefined; + used: number; +}) { + const normalizedUsed = normalizeNumber(used); + const baseSegments = getPhysicalSegmentsBase(); + + const mergedSegments = (segments ?? []).reduce((result, segment) => { + return accumulateSegment(result, segment.key, segment.value); + }, baseSegments); + + const accounted = sumSegments(mergedSegments); + const remainder = Math.max(normalizedUsed - accounted, 0); + + return accumulateSegment(mergedSegments, TENANT_STORAGE_SEGMENT_KEYS.unknown, remainder); +} + +function getApproximateLogicalAvailability({ + hasQuota, + physical, + used, +}: { + hasQuota: boolean; + physical: TenantStorageSummary; + used: number; +}) { + if (hasQuota) { + return { + estimatedAvailable: undefined, + nextAvailable: undefined, + nextTotal: undefined, + usedPercent: 0, + }; + } + + const physicalUsed = normalizeNumber(physical.used); + const physicalAvailable = toOptionalNumber(physical.available); + const overhead = used > 0 && physicalUsed > 0 ? physicalUsed / used : undefined; + + if (physicalAvailable === undefined || overhead === undefined || overhead <= 0) { + return { + estimatedAvailable: undefined, + nextAvailable: undefined, + nextTotal: undefined, + usedPercent: 0, + }; + } + + const estimatedAvailable = physicalAvailable / overhead; + const nextTotal = used + estimatedAvailable; + + return { + estimatedAvailable, + nextAvailable: estimatedAvailable, + nextTotal, + usedPercent: calculateUsedPercent(used, nextTotal), + }; +} + +function isUnchangedUserDataSummary({ + summary, + nextAvailable, + nextTotal, + used, + usedPercent, +}: { + summary: TenantStorageSummary; + nextAvailable: number | undefined; + nextTotal: number | undefined; + used: number; + usedPercent: number; +}) { + return ( + used === summary.used && + nextAvailable === summary.available && + usedPercent === summary.usedPercent && + !summary.availableApproximate && + nextTotal === summary.total + ); +} + +export function getTenantStorageUserDataDisplaySummary({ + summary, + logicalUserData, + useLogicalBreakdown, + physical, +}: { + summary: TenantStorageSummary; + logicalUserData?: TenantStorageLogicalUserData; + useLogicalBreakdown: boolean; + physical: TenantStorageSummary; +}) { + if (summary.preserveDisplayValues) { + return summary; + } + + const used = + useLogicalBreakdown && hasCompleteLogicalUserData(logicalUserData) + ? logicalUserData.rowTables + logicalUserData.topics + : summary.used; + const quota = summary.quota; + const hasQuota = quota !== undefined; + const available = hasQuota ? Math.max((quota ?? 0) - used, 0) : undefined; + const { + estimatedAvailable, + nextAvailable: approximateAvailable, + nextTotal, + usedPercent: approximateUsedPercent, + } = getApproximateLogicalAvailability({ + hasQuota, + physical, + used, + }); + const nextAvailable = available ?? approximateAvailable; + const usedPercent = hasQuota ? calculateUsedPercent(used, quota) : approximateUsedPercent; + + if ( + isUnchangedUserDataSummary({ + summary, + nextAvailable, + nextTotal, + used, + usedPercent, + }) + ) { + return summary; + } + + const nextSummary: TenantStorageSummary = { + ...summary, + available: nextAvailable, + used, + usedPercent, + }; + + if (estimatedAvailable === undefined) { + return nextSummary; + } + + return { + ...nextSummary, + availableApproximate: true, + total: nextTotal, + }; +} + +export function isSystemStoragePath(path: string) { + return SYSTEM_STORAGE_PATH_PATTERN.test(path); +} + +function getMediaSortOrder(type: string) { + switch (type) { + case EType.SSD: + return 0; + case EType.HDD: + return 1; + default: + return 99; + } +} + +function buildStatsByType(stats: TenantStorageStats[] | undefined) { + const result = new Map(); + + for (const item of stats ?? []) { + const type = getTenantStorageMediaKey(item.name); + + if (!item.name || !type) { + continue; + } + + const previousStats = result.get(type); + const used = normalizeNumber(previousStats?.used) + normalizeNumber(item.used); + const previousLimit = toOptionalNumber(previousStats?.limit); + const itemLimit = toOptionalNumber(item.limit); + const limit = + previousLimit === undefined && itemLimit === undefined + ? undefined + : (previousLimit ?? 0) + (itemLimit ?? 0); + + result.set(type, { + ...item, + name: type, + used, + limit, + }); + } + + return result; +} + +function buildAggregateTenantStorageMediaSection({ + metrics, + userStats, +}: { + metrics: TenantStorageMetrics; + userStats?: TenantStorageStats; +}): TenantStorageMediaSection { + const userUsed = + userStats === undefined + ? normalizeNumber(metrics.tabletStorageUsed) + : normalizeNumber(userStats.used); + const userQuota = + toOptionalNumber(userStats?.limit) ?? toOptionalNumber(metrics.tabletStorageLimit); + const userAvailable = userQuota === undefined ? undefined : Math.max(userQuota - userUsed, 0); + + const physicalUsed = normalizeNumber(metrics.blobStorageUsed); + const physicalTotal = toOptionalNumber(metrics.blobStorageLimit); + const physicalAvailable = + physicalTotal === undefined ? undefined : Math.max(physicalTotal - physicalUsed, 0); + + return { + mediaType: EType.None, + userData: { + used: userUsed, + quota: userQuota, + available: userAvailable, + usedPercent: calculateUsedPercent(userUsed, userQuota), + segments: [], + }, + physical: { + used: physicalUsed, + total: physicalTotal, + available: physicalAvailable, + overhead: userUsed > 0 ? physicalUsed / userUsed : undefined, + usedPercent: calculateUsedPercent(physicalUsed, physicalTotal), + segments: [], + }, + }; +} + +export function buildTenantStorageMediaSections({ + blobStorageStats, + metrics, + tabletStorageStats, +}: { + blobStorageStats?: TenantStorageStats[]; + metrics: TenantStorageMetrics; + tabletStorageStats?: TenantStorageStats[]; +}) { + const blobStatsByType = buildStatsByType(blobStorageStats); + const tabletStatsByType = buildStatsByType(tabletStorageStats); + + const mediaTypes = Array.from(blobStatsByType.keys()) + .filter((mediaType) => mediaType !== EType.None) + .sort((left, right) => getMediaSortOrder(left) - getMediaSortOrder(right)); + + if (mediaTypes.length === 0) { + return [buildAggregateTenantStorageMediaSection({metrics})]; + } + + const hasUserMediaStats = mediaTypes.some((mediaType) => tabletStatsByType.has(mediaType)); + + if (mediaTypes.length > 1 && !hasUserMediaStats) { + return [ + buildAggregateTenantStorageMediaSection({ + metrics, + userStats: tabletStatsByType.get(EType.None), + }), + ]; + } + + return mediaTypes.map((mediaType) => { + const userStats = + tabletStatsByType.get(mediaType) ?? + (mediaTypes.length === 1 ? tabletStatsByType.get(EType.None) : undefined); + const physicalStats = blobStatsByType.get(mediaType); + + const userUsed = normalizeNumber(userStats?.used); + const userQuota = + toOptionalNumber(userStats?.limit) ?? + (mediaTypes.length === 1 ? toOptionalNumber(metrics.tabletStorageLimit) : undefined); + const userAvailable = + userQuota === undefined ? undefined : Math.max(userQuota - userUsed, 0); + + const physicalUsed = normalizeNumber(physicalStats?.used); + const physicalTotal = + toOptionalNumber(physicalStats?.limit) ?? + (mediaTypes.length === 1 ? toOptionalNumber(metrics.blobStorageLimit) : undefined); + const physicalAvailable = + physicalTotal === undefined ? undefined : Math.max(physicalTotal - physicalUsed, 0); + + return { + mediaType, + userData: { + used: userUsed, + quota: userQuota, + available: userAvailable, + usedPercent: calculateUsedPercent(userUsed, userQuota), + segments: [], + }, + physical: { + used: physicalUsed, + total: physicalTotal, + available: physicalAvailable, + overhead: userUsed > 0 ? physicalUsed / userUsed : undefined, + usedPercent: calculateUsedPercent(physicalUsed, physicalTotal), + segments: [], + }, + }; + }); +} + +export function buildTenantStorageData( + rawData: TenantStorageRawData | undefined, + metrics: TenantStorageMetrics, +): TenantStorageData { + const logicalUserData = rawData?.logicalUserData; + const {physicalSegmentsByMedia, systemDetailsByMedia} = buildPhysicalSegmentsByMedia( + rawData?.tabletTypeRows, + ); + + const logicalUsed = hasCompleteLogicalUserData(logicalUserData) + ? logicalUserData.rowTables + logicalUserData.topics + : undefined; + const userUsed = + logicalUsed === undefined ? normalizeNumber(metrics.tabletStorageUsed) : logicalUsed; + const userDataSegments = getUserLogicalSegments(logicalUserData, userUsed); + const userQuota = toOptionalNumber(metrics.tabletStorageLimit); + const userAvailable = userQuota === undefined ? undefined : Math.max(userQuota - userUsed, 0); + + const physicalUsed = normalizeNumber(metrics.blobStorageUsed); + const physicalTotal = toOptionalNumber(metrics.blobStorageLimit); + const physicalAvailable = + physicalTotal === undefined ? undefined : Math.max(physicalTotal - physicalUsed, 0); + const overhead = userUsed > 0 ? physicalUsed / userUsed : undefined; + + return { + logicalUserData, + summary: { + userData: { + available: userAvailable, + quota: userQuota, + used: userUsed, + usedPercent: calculateUsedPercent(userUsed, userQuota), + segments: userDataSegments, + }, + physical: { + available: physicalAvailable, + overhead, + total: physicalTotal, + used: physicalUsed, + usedPercent: calculateUsedPercent(physicalUsed, physicalTotal), + segments: getTenantStoragePhysicalDisplaySegments({ + segments: mergeSegments(physicalSegmentsByMedia), + used: physicalUsed, + }), + }, + }, + topRows: (rawData?.topRows ?? []).map((row) => { + const physicalDisk = + row.physicalDisk === undefined ? undefined : normalizeNumber(row.physicalDisk); + + return { + ...row, + physicalDisk, + dbShare: userUsed > 0 ? row.userData / userUsed : 0, + overhead: + row.userData > 0 && physicalDisk !== undefined + ? physicalDisk / row.userData + : undefined, + }; + }), + topRowsError: rawData?.topRowsError, + userDataSegments, + physicalSegmentsByMedia, + systemDetailsByMedia, + }; +} diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json b/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json index 13bc7ac5b9..5e1c9f7430 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json +++ b/src/containers/Tenant/Diagnostics/TenantOverview/i18n/en.json @@ -21,7 +21,6 @@ "Storage: {{count}} groups", "Storage: {{count}} groups" ], - "context_memory-used": "Memory used", "context_network-usage": "Network usage", "context_cpu-description": "CPU load is calculated as the cumulative usage across all actor system pools in the database except IO", @@ -31,20 +30,12 @@ "charts.queries-per-second": "Queries per second", "charts.queries-latency": "Queries latencies {{percentile}}", "charts.cpu-usage": "CPU usage by pool", - "charts.storage-usage": "Tablet storage usage", "charts.memory-usage": "Memory usage", "charts.network-utilization": "Network utilization", "charts.network-sent-bytes": "Sent", "charts.network-received-bytes": "Received", - "title_storage-details": "Storage Details", - "storage.tablet-storage-title": "Tablet storage", - "storage.tablet-storage-description": "Size of user data and indexes stored in schema objects (tables, topics, etc.)", - "storage.db-storage-title": "Database storage", - "storage.db-storage-description": "Size of data stored in distributed storage with all overheads for redundancy", "title_top-shards": "Top shards by CPU usage", "title_top-queries": "Top queries by CPU usage", - "title_top-tables-by-size": "Top tables by size", - "title_top-groups-by-usage": "Top groups by usage", "title_top-nodes-by-memory": "Top nodes by memory", "title_nodes-by-ping": "Top nodes by ping time", "title_nodes-by-skew": "Top nodes by clock skew", diff --git a/src/store/reducers/storageUsage/__test__/storageUsage.test.ts b/src/store/reducers/storageUsage/__test__/storageUsage.test.ts index d77b84b6d7..fb07bd468d 100644 --- a/src/store/reducers/storageUsage/__test__/storageUsage.test.ts +++ b/src/store/reducers/storageUsage/__test__/storageUsage.test.ts @@ -64,6 +64,10 @@ describe('storageUsage helpers', () => { expect(normalizeMediaType('SSD,Kind:0')).toBe('SSD'); }); + test('normalizeMediaType normalizes lowercase ssd to SSD', () => { + expect(normalizeMediaType('ssd')).toBe('SSD'); + }); + test('normalizeMediaType keeps unknown tokens stable', () => { expect(normalizeMediaType('NVME,Kind:0')).toBe('NVME'); }); diff --git a/src/store/reducers/tenantOverview/storage/__test__/tenantOverviewStorage.test.ts b/src/store/reducers/tenantOverview/storage/__test__/tenantOverviewStorage.test.ts new file mode 100644 index 0000000000..ff0f8d9512 --- /dev/null +++ b/src/store/reducers/tenantOverview/storage/__test__/tenantOverviewStorage.test.ts @@ -0,0 +1,365 @@ +import type {YdbEmbeddedAPI} from '../../../../../services/api'; +import {fetchTenantStorageRawData} from '../tenantOverviewStorage'; + +function createTopRowsQueryResponse(rows: Array<[string, number | string]>) { + return { + result: [ + { + columns: [ + {name: 'Path', type: 'Utf8?'}, + {name: 'UserData', type: 'Uint64?'}, + ], + rows, + }, + ], + }; +} + +describe('fetchTenantStorageRawData', () => { + const originalApi = window.api; + + afterEach(() => { + window.api = originalApi; + }); + + test('returns summaries with empty top rows when partition stats query fails', async () => { + const tabletTypeRows = [ + { + Type: 'DataShard', + StorageSize: 100, + Media: [{Kind: 'SSD', StorageSize: 100}], + }, + ]; + const getStorageStats = jest.fn().mockResolvedValue({Tablets: tabletTypeRows}); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: '30'}, + }, + }, + Children: [], + }, + }); + const topRowsError = new Error('partition stats unavailable'); + const sendQuery = jest.fn().mockRejectedValue(topRowsError); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result).toEqual({ + logicalUserData: { + rowTables: 120, + topics: 30, + }, + topRows: [], + topRowsError, + tabletTypeRows, + }); + expect(getStorageStats).toHaveBeenCalledTimes(1); + expect(getSchema).toHaveBeenCalledTimes(1); + expect(sendQuery).toHaveBeenCalledTimes(1); + }); + + test('keeps partial logical user data without converting missing fields to zero', async () => { + const getStorageStats = jest.fn().mockResolvedValue({Tablets: []}); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {}, + }, + }, + Children: [], + }, + }); + const sendQuery = jest.fn().mockResolvedValue(createTopRowsQueryResponse([])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result.logicalUserData).toEqual({ + rowTables: 120, + topics: undefined, + }); + }); + + test('treats empty logical data size as unknown', async () => { + const getStorageStats = jest.fn().mockResolvedValue({Tablets: []}); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: ''}, + }, + }, + Children: [], + }, + }); + const sendQuery = jest.fn().mockResolvedValue(createTopRowsQueryResponse([])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result.logicalUserData).toEqual({ + rowTables: 120, + topics: undefined, + }); + }); + + test('keeps zero logical data size when the field is present', async () => { + const getStorageStats = jest.fn().mockResolvedValue({Tablets: []}); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: '0'}, + }, + }, + Children: [], + }, + }); + const sendQuery = jest.fn().mockResolvedValue(createTopRowsQueryResponse([])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result.logicalUserData).toEqual({ + rowTables: 120, + topics: 0, + }); + }); + + test('keeps top rows when secondary storage stats request fails', async () => { + const tabletTypeRows = [ + { + Type: 'DataShard', + StorageSize: 100, + Media: [{Kind: 'SSD', StorageSize: 100}], + }, + ]; + const storageStatsError = new Error('storage stats unavailable'); + const getStorageStats = jest + .fn() + .mockResolvedValueOnce({Tablets: tabletTypeRows}) + .mockRejectedValueOnce(storageStatsError); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: '30'}, + }, + }, + Children: [ + { + Name: 'table-a', + PathType: 'EPathTypeTable', + }, + ], + }, + }); + const sendQuery = jest + .fn() + .mockResolvedValue(createTopRowsQueryResponse([['/local/table-a', '100']])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result.topRows).toEqual([ + { + path: '/local/table-a', + userData: 100, + physicalDisk: undefined, + pathType: 'EPathTypeTable', + pathSubType: undefined, + }, + ]); + expect(result.topRowsError).toBe(storageStatsError); + expect(result.tabletTypeRows).toBe(tabletTypeRows); + expect(getStorageStats).toHaveBeenCalledTimes(2); + expect(getSchema).toHaveBeenCalledTimes(1); + expect(sendQuery).toHaveBeenCalledTimes(1); + expect(sendQuery.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + query: expect.stringMatching(/WHERE\s+TabletId != 0\s+GROUP BY Path/), + }), + ); + }); + + test('keeps top rows and surfaces schema enrichment errors', async () => { + const schemaError = new Error('schema unavailable'); + const getStorageStats = jest + .fn() + .mockResolvedValueOnce({Tablets: []}) + .mockResolvedValueOnce({ + Paths: [{FullPath: '/local/table-a', StorageSize: 300}], + }); + const getSchema = jest + .fn() + .mockResolvedValueOnce({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: '30'}, + }, + }, + Children: [], + }, + }) + .mockRejectedValueOnce(schemaError); + const sendQuery = jest + .fn() + .mockResolvedValue(createTopRowsQueryResponse([['/local/table-a', 100]])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result.topRows).toEqual([ + { + path: '/local/table-a', + userData: 100, + physicalDisk: 300, + }, + ]); + expect(result.topRowsError).toBe(schemaError); + expect(getStorageStats).toHaveBeenCalledTimes(2); + expect(getSchema).toHaveBeenCalledTimes(2); + expect(sendQuery).toHaveBeenCalledTimes(1); + }); + + test('keeps summary data and surfaces error when tablet type stats request fails', async () => { + const tabletTypeError = new Error('tablet type stats unavailable'); + const getStorageStats = jest + .fn() + .mockRejectedValueOnce(tabletTypeError) + .mockResolvedValueOnce({ + Paths: [{FullPath: '/local/table-a', StorageSize: 300}], + }); + const getSchema = jest.fn().mockResolvedValue({ + Path: '/local', + PathDescription: { + DomainDescription: { + DiskSpaceUsage: { + Tables: {DataSize: '120'}, + Topics: {DataSize: '30'}, + }, + }, + Children: [ + { + Name: 'table-a', + PathType: 'EPathTypeTable', + }, + ], + }, + }); + const sendQuery = jest + .fn() + .mockResolvedValue(createTopRowsQueryResponse([['/local/table-a', 100]])); + + window.api = { + viewer: { + getStorageStats, + getSchema, + sendQuery, + }, + } as unknown as YdbEmbeddedAPI; + + const result = await fetchTenantStorageRawData({ + database: '/local', + databaseFullPath: '/local', + }); + + expect(result).toEqual({ + logicalUserData: { + rowTables: 120, + topics: 30, + }, + topRows: [ + { + path: '/local/table-a', + userData: 100, + physicalDisk: 300, + pathType: 'EPathTypeTable', + pathSubType: undefined, + }, + ], + topRowsError: tabletTypeError, + tabletTypeRows: [], + }); + expect(getStorageStats).toHaveBeenCalledTimes(2); + expect(getSchema).toHaveBeenCalledTimes(1); + expect(sendQuery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/reducers/tenantOverview/storage/tenantOverviewStorage.ts b/src/store/reducers/tenantOverview/storage/tenantOverviewStorage.ts new file mode 100644 index 0000000000..bb125ade77 --- /dev/null +++ b/src/store/reducers/tenantOverview/storage/tenantOverviewStorage.ts @@ -0,0 +1,435 @@ +import type {AxiosOptions} from '../../../../services/api/base'; +import type {EPathSubType, EPathType, TEvDescribeSchemeResult} from '../../../../types/api/schema'; +import type { + StorageStatsResponse, + TStorageStatsPathEntry, + TStorageStatsTabletTypeEntry, +} from '../../../../types/api/storage'; +import {QUERY_TECHNICAL_MARK} from '../../../../utils/constants'; +import {isQueryErrorResponse, parseQueryAPIResponse} from '../../../../utils/query'; +import {parseNonNegativeNumber, parseOptionalNonNegativeNumber} from '../../../../utils/utils'; +import {api} from '../../api'; + +export interface TenantStorageRawTopRow { + path: string; + userData: number; + physicalDisk?: number; + pathType?: EPathType; + pathSubType?: EPathSubType; +} + +export interface TenantStorageRawData { + logicalUserData?: { + rowTables?: number; + topics?: number; + }; + topRows: TenantStorageRawTopRow[]; + topRowsError?: unknown; + tabletTypeRows: TStorageStatsTabletTypeEntry[]; +} + +export interface GetTenantStorageRawParams { + database: string; + databaseFullPath: string; + useMetaProxy?: boolean; +} + +const TOP_STORAGE_OBJECTS_LIMIT = 10; + +function getTopStorageObjectsQuery() { + return `${QUERY_TECHNICAL_MARK} +SELECT + Path, + SUM(DataSize) AS UserData +FROM \`.sys/partition_stats\` +WHERE TabletId != 0 +GROUP BY Path +ORDER BY UserData DESC +LIMIT ${TOP_STORAGE_OBJECTS_LIMIT} +`; +} + +function normalizeNumericValue(value: number | string | undefined) { + return parseNonNegativeNumber(value); +} + +function toOptionalNumericValue(value: number | string | undefined) { + return parseOptionalNonNegativeNumber(value, {emptyStringAsUndefined: true}); +} + +function getAbsolutePath({ + fullPath, + path, + databaseFullPath, + useMetaProxy, +}: { + fullPath?: string; + path?: string; + databaseFullPath: string; + useMetaProxy?: boolean; +}) { + if (typeof fullPath === 'string' && fullPath) { + return fullPath; + } + + if (typeof path !== 'string') { + return undefined; + } + + if (path.startsWith('/')) { + return path; + } + + if (!useMetaProxy) { + return path || undefined; + } + + if (!path) { + return databaseFullPath; + } + + return `${databaseFullPath}/${path}`; +} + +function joinSchemaPath(parentPath: string, childName: string) { + if (parentPath === '/') { + return `/${childName}`; + } + + return `${parentPath}/${childName}`; +} + +function normalizeStorageStatsPath(path: string, databaseFullPath: string, useMetaProxy?: boolean) { + if (!useMetaProxy) { + return path; + } + + if (path === databaseFullPath) { + return ''; + } + + if (path.startsWith(databaseFullPath + '/')) { + return path.slice(databaseFullPath.length + 1); + } + + return path; +} + +function buildMultiPathStorageStatsParam( + paths: string[], + databaseFullPath: string, + useMetaProxy?: boolean, +) { + return paths + .map((path) => normalizeStorageStatsPath(path, databaseFullPath, useMetaProxy)) + .join(','); +} + +async function getTopRowsByQuery( + {database}: Pick, + options?: AxiosOptions, +) { + const response = await window.api.viewer.sendQuery( + { + query: getTopStorageObjectsQuery(), + database, + action: 'execute-query', + internal_call: true, + }, + {...options, withRetries: true}, + ); + + if (isQueryErrorResponse(response)) { + throw response; + } + + const rows = parseQueryAPIResponse(response).resultSets?.[0]?.result ?? []; + + return rows + .filter((row): row is {Path?: string; UserData?: string | number} => { + return Boolean(row && typeof row === 'object'); + }) + .map((row) => { + const path = typeof row.Path === 'string' ? row.Path : undefined; + + if (!path) { + return undefined; + } + + return { + path, + userData: normalizeNumericValue(row.UserData), + }; + }) + .filter((row): row is Pick => { + return row !== undefined; + }); +} + +function getLogicalUserData(schemaResponse: TEvDescribeSchemeResult | null | undefined) { + const diskSpaceUsage = schemaResponse?.PathDescription?.DomainDescription?.DiskSpaceUsage; + const tablesDataSize = toOptionalNumericValue(diskSpaceUsage?.Tables?.DataSize); + const topicsDataSize = toOptionalNumericValue(diskSpaceUsage?.Topics?.DataSize); + + if (tablesDataSize === undefined && topicsDataSize === undefined) { + return undefined; + } + + return { + rowTables: tablesDataSize, + topics: topicsDataSize, + }; +} + +async function getRootSchemaData( + {database, databaseFullPath, useMetaProxy}: GetTenantStorageRawParams, + options?: AxiosOptions, +) { + const result = new Map>(); + + try { + const rootSchema = await window.api.viewer.getSchema( + { + database, + path: {path: databaseFullPath, databaseFullPath, useMetaProxy}, + }, + options, + ); + + const rootPath = + getAbsolutePath({ + fullPath: rootSchema?.Path, + databaseFullPath, + useMetaProxy, + }) ?? databaseFullPath; + + const rootChildren = rootSchema?.PathDescription?.Children ?? []; + + for (const child of rootChildren) { + const {Name, PathType, PathSubType} = child; + + if (!Name) { + continue; + } + + result.set(joinSchemaPath(rootPath, Name), { + pathType: PathType, + pathSubType: PathSubType, + }); + } + + return { + logicalUserData: getLogicalUserData(rootSchema), + topRowTypes: result, + }; + } catch { + return { + logicalUserData: undefined, + topRowTypes: result, + }; + } +} + +async function getTopRowTypes( + rows: TenantStorageRawTopRow[], + {database, databaseFullPath, useMetaProxy}: GetTenantStorageRawParams, + initialTypes: Map>, + options?: AxiosOptions, +) { + const result = new Map(initialTypes); + + const missingPaths = rows.map(({path}) => path).filter((path) => !result.has(path)); + + const uniqueMissingPaths = Array.from(new Set(missingPaths)); + + if (uniqueMissingPaths.length === 0) { + return { + topRowTypes: result, + error: undefined, + }; + } + + const responses = await Promise.allSettled( + uniqueMissingPaths.map((path) => + window.api.viewer.getSchema( + { + database, + path: {path, databaseFullPath, useMetaProxy}, + }, + options, + ), + ), + ); + + const firstError = responses.find( + (response): response is PromiseRejectedResult => response.status === 'rejected', + )?.reason; + + responses.forEach((response, index) => { + if (response.status !== 'fulfilled') { + return; + } + + const path = uniqueMissingPaths[index]; + const self = response.value?.PathDescription?.Self; + + result.set(path, { + pathType: self?.PathType, + pathSubType: self?.PathSubType, + }); + }); + + return { + topRowTypes: result, + error: firstError, + }; +} + +function getStorageStatsByPath(response: StorageStatsResponse, params: GetTenantStorageRawParams) { + const {databaseFullPath, useMetaProxy} = params; + const rows = response.Paths ?? []; + + return rows.reduce>((result, row: TStorageStatsPathEntry) => { + const path = getAbsolutePath({ + fullPath: row.FullPath, + path: row.Path, + databaseFullPath, + useMetaProxy, + }); + + if (!path) { + return result; + } + + result.set(path, normalizeNumericValue(row.StorageSize)); + + return result; + }, new Map()); +} + +async function getStorageStatsForTopRows( + paths: string[], + params: GetTenantStorageRawParams, + options?: AxiosOptions, +) { + const {database, databaseFullPath, useMetaProxy} = params; + + if (paths.length === 0) { + return new Map(); + } + + const response = await window.api.viewer.getStorageStats( + { + database, + path: { + path: buildMultiPathStorageStatsParam(paths, databaseFullPath, useMetaProxy), + databaseFullPath, + useMetaProxy, + }, + groupBy: 'path', + everything: true, + }, + options, + ); + + return getStorageStatsByPath(response, params); +} + +function mergeTopRows( + queryRows: Array>, + topRowTypes: Map>, + storageStatsByPath: Map, +) { + return queryRows.map((row) => { + return { + ...row, + physicalDisk: storageStatsByPath.get(row.path), + ...topRowTypes.get(row.path), + }; + }); +} + +export async function fetchTenantStorageRawData( + params: GetTenantStorageRawParams, + options?: AxiosOptions, +) { + const {database} = params; + + const [tabletTypeResult, topRowsResult, rootSchemaData] = await Promise.all([ + window.api.viewer + .getStorageStats( + { + database, + groupBy: 'tablet_type', + tablets: true, + media: true, + }, + options, + ) + .then((response) => ({ + tabletTypeRows: response.Tablets ?? [], + error: undefined, + })) + .catch((error: unknown) => ({ + tabletTypeRows: [] as TStorageStatsTabletTypeEntry[], + error, + })), + getTopRowsByQuery(params, options) + .then((rows) => ({rows, error: undefined})) + .catch((error: unknown) => ({rows: [], error})), + getRootSchemaData(params, options), + ]); + const queryTopRows = topRowsResult.rows; + + const storageStatsResult = await getStorageStatsForTopRows( + queryTopRows.map(({path}) => path), + params, + options, + ) + .then((storageStatsByPath) => ({storageStatsByPath, error: undefined})) + .catch((error: unknown) => ({storageStatsByPath: new Map(), error})); + const storageStatsByPath = storageStatsResult.storageStatsByPath; + const topRowsWithoutTypes = queryTopRows.map((row) => ({ + ...row, + physicalDisk: storageStatsByPath.get(row.path), + })); + const initialTopRowTypes = rootSchemaData?.topRowTypes ?? new Map(); + const topRowTypesResult = await getTopRowTypes( + topRowsWithoutTypes, + params, + initialTopRowTypes, + options, + ).catch((error: unknown) => ({topRowTypes: initialTopRowTypes, error})); + const enrichmentError = storageStatsResult.error ?? topRowTypesResult.error; + + return { + logicalUserData: rootSchemaData?.logicalUserData, + topRows: mergeTopRows(queryTopRows, topRowTypesResult.topRowTypes, storageStatsByPath), + topRowsError: topRowsResult.error ?? enrichmentError ?? tabletTypeResult.error, + tabletTypeRows: tabletTypeResult.tabletTypeRows, + }; +} + +export const tenantOverviewStorageApi = api.injectEndpoints({ + endpoints: (build) => ({ + getTenantStorageRawData: build.query({ + queryFn: async (params, {signal}) => { + try { + const data = await fetchTenantStorageRawData(params, {signal}); + + return {data}; + } catch (error) { + return {error}; + } + }, + serializeQueryArgs: ({queryArgs}) => { + const {database, databaseFullPath, useMetaProxy} = queryArgs; + + return {database, databaseFullPath, useMetaProxy}; + }, + keepUnusedDataFor: 0, + providesTags: ['All'], + }), + }), + overrideExisting: 'throw', +}); diff --git a/src/store/reducers/tenants/__test__/utils.test.ts b/src/store/reducers/tenants/__test__/utils.test.ts new file mode 100644 index 0000000000..660966dc0f --- /dev/null +++ b/src/store/reducers/tenants/__test__/utils.test.ts @@ -0,0 +1,161 @@ +import {EType} from '../../../../types/api/tenant'; +import {calculateTenantMetrics} from '../utils'; + +describe('calculateTenantMetrics', () => { + test('keeps legacy storage metric card values for prod payload without tablet quota', () => { + const result = calculateTenantMetrics({ + TablesStorage: [{Type: EType.SSD, Size: '35915303563'}], + StorageAllocatedSize: '492778291200', + StorageAllocatedLimit: '6399113297920', + DatabaseStorage: [ + { + Type: EType.SSD, + Size: '492778291200', + Limit: '6399113297920', + }, + ], + DatabaseQuotas: {}, + }); + + expect(result.tabletStorage).toBe(35_915_303_563); + expect(result.tabletStorageStats).toEqual([ + { + name: EType.SSD, + used: 35_915_303_563, + limit: undefined, + usage: undefined, + }, + ]); + expect(result.storageMetricStats).toEqual([ + { + name: EType.SSD, + used: 492_778_291_200, + limit: 6_399_113_297_920, + usage: expect.any(Number), + }, + ]); + expect(result.storageMetricStats[0]?.usage).toBeCloseTo(7.701); + }); + + test('keeps per-media quotas out of legacy storage metric card values', () => { + const result = calculateTenantMetrics({ + StorageAllocatedSize: '500', + StorageAllocatedLimit: '1000', + DatabaseStorage: [{Type: EType.SSD, Size: '500', Limit: '1000'}], + DatabaseQuotas: { + storage_quotas: [{unit_kind: EType.SSD, data_size_soft_quota: '900'}], + }, + TablesStorage: [{Type: EType.SSD, Size: '90'}], + }); + + expect(result.tabletStorageStats).toEqual([ + {name: EType.SSD, used: 90, limit: 900, usage: 10}, + ]); + expect(result.storageMetricStats).toEqual([ + {name: EType.SSD, used: 500, limit: 1000, usage: 50}, + ]); + }); + + test('normalizes invalid storage sizes and limits', () => { + const result = calculateTenantMetrics({ + DatabaseStorage: [{Type: EType.SSD}, {Type: EType.HDD, Size: '200', Limit: 'invalid'}], + DatabaseQuotas: { + storage_quotas: [{unit_kind: EType.HDD, data_size_soft_quota: '800'}], + }, + TablesStorage: [ + {Type: EType.SSD, Size: 'invalid', Limit: 'invalid'}, + {Type: EType.HDD, Size: '100', Limit: '300', SoftQuota: 'invalid'}, + ], + }); + + expect(result.blobStorageStats).toEqual([ + {name: EType.SSD, used: 0, limit: undefined, usage: undefined}, + {name: EType.HDD, used: 200, limit: undefined, usage: undefined}, + ]); + expect(result.tabletStorageStats).toEqual([ + {name: EType.SSD, used: 0, limit: undefined, usage: undefined}, + {name: EType.HDD, used: 100, limit: 800, usage: 12.5}, + ]); + }); + + test('uses per-media storage quotas when table storage limit is missing', () => { + const result = calculateTenantMetrics({ + DatabaseQuotas: { + data_size_soft_quota: '612032839680', + storage_quotas: [ + {unit_kind: 'ssd', data_size_soft_quota: '306016419840'}, + {unit_kind: 'hdd', data_size_soft_quota: '2089072092774'}, + ], + }, + DatabaseStorage: [ + {Type: EType.HDD, Size: '1353743073280', Limit: '17999012094860'}, + {Type: EType.SSD, Size: '98419343360', Limit: '1873981472766'}, + ], + TablesStorage: [ + { + Type: EType.HDD, + Size: '289166965049', + Limit: '612032839680', + SoftQuota: '612032839680', + }, + {Type: EType.SSD, Size: '986'}, + ], + }); + + expect(result.tabletStorageStats).toEqual([ + { + name: EType.HDD, + used: 289166965049, + limit: 612032839680, + usage: expect.any(Number), + }, + {name: EType.SSD, used: 986, limit: 306016419840, usage: expect.any(Number)}, + ]); + expect(result.tabletStorageStats?.[0]?.usage).toBeCloseTo(47.247); + expect(result.tabletStorageStats?.[1]?.usage).toBeCloseTo(0.000000322); + }); + + test('ignores invalid per-media quota and falls back to table storage limit', () => { + const result = calculateTenantMetrics({ + DatabaseQuotas: { + storage_quotas: [{unit_kind: EType.SSD, data_size_soft_quota: 'invalid'}], + }, + TablesStorage: [{Type: EType.SSD, Size: '100', Limit: '300'}], + }); + + expect(result.tabletStorageStats).toEqual([ + {name: EType.SSD, used: 100, limit: 300, usage: expect.any(Number)}, + ]); + expect(result.tabletStorageStats?.[0]?.usage).toBeCloseTo(33.333333333333336); + }); + + test('normalizes table storage type before per-media quota lookup', () => { + const result = calculateTenantMetrics({ + DatabaseQuotas: { + storage_quotas: [{unit_kind: 'hdd', data_size_soft_quota: '900'}], + }, + TablesStorage: [{Type: 'ROT', Size: '90'}], + }); + + expect(result.tabletStorageStats).toEqual([ + {name: EType.HDD, used: 90, limit: 900, usage: 10}, + ]); + }); + + test('marks aggregate storage fallbacks with None media type', () => { + const result = calculateTenantMetrics({ + StorageAllocatedSize: '100', + StorageAllocatedLimit: '500', + DatabaseQuotas: { + data_size_soft_quota: '1000', + }, + }); + + expect(result.blobStorageStats).toEqual([ + {name: EType.None, used: 100, limit: 500, usage: 20}, + ]); + expect(result.tabletStorageStats).toEqual([ + {name: EType.None, used: 0, limit: 1000, usage: undefined}, + ]); + }); +}); diff --git a/src/store/reducers/tenants/utils.ts b/src/store/reducers/tenants/utils.ts index 309ad321d2..1582eb77eb 100644 --- a/src/store/reducers/tenants/utils.ts +++ b/src/store/reducers/tenants/utils.ts @@ -8,6 +8,7 @@ import { DEFAULT_WARNING_THRESHOLD, EMPTY_DATA_PLACEHOLDER, } from '../../../utils/constants'; +import {UNKNOWN_MEDIA_TYPE, normalizeMediaType} from '../../../utils/disks/normalizeMediaType'; import {isNumeric, safeParseNumber} from '../../../utils/utils'; import {METRIC_STATUS} from './contants'; @@ -29,7 +30,12 @@ export interface TenantMetricStats { } export type TenantPoolsStats = TenantMetricStats; -export type TenantStorageStats = TenantMetricStats; +export type TenantStorageStats = TenantMetricStats; + +type TenantStorageUsage = NonNullable[number]; +type TenantStorageQuota = NonNullable< + NonNullable['storage_quotas'] +>[number]; const calculatePoolsStats = ( poolsStats: TPoolStats[] | undefined, @@ -54,6 +60,202 @@ const calculatePoolsStats = ( .filter((stats): stats is TenantPoolsStats => stats !== undefined); }; +function getTenantStorageType(unitKind?: string) { + if (!unitKind || unitKind === EType.None) { + return EType.None; + } + + const normalizedType = normalizeMediaType(unitKind); + + if (normalizedType === UNKNOWN_MEDIA_TYPE || normalizedType === EType.None.toUpperCase()) { + return EType.None; + } + + return normalizedType; +} + +function getStorageUsed(value?: string | number) { + return isNumeric(value) ? Number(value) : 0; +} + +function getStorageLimit(value?: string | number) { + return isNumeric(value) ? Number(value) : undefined; +} + +function buildStorageQuotaMap(storageQuotas: TenantStorageQuota[] | undefined) { + return ( + storageQuotas?.reduce>((result, quota) => { + const type = getTenantStorageType(quota.unit_kind); + const softQuota = getStorageLimit(quota.data_size_soft_quota); + + if (type === EType.None || softQuota === undefined) { + return result; + } + + const currentQuota = result.get(type) ?? 0; + + result.set(type, currentQuota + softQuota); + + return result; + }, new Map()) ?? new Map() + ); +} + +function buildStorageStats(values: TenantStorageUsage[]) { + return values.map((value) => { + const {Type, Size, Limit} = value; + + const used = getStorageUsed(Size); + const limit = getStorageLimit(Limit); + + return { + name: Type, + used, + limit, + usage: calculateUsage(used, limit), + }; + }); +} + +function buildBlobStorageStats({ + blobStorage, + blobStorageLimit, + databaseStorage, + storageUsage, +}: { + blobStorage: number; + blobStorageLimit?: number; + databaseStorage: TTenant['DatabaseStorage']; + storageUsage: TTenant['StorageUsage']; +}) { + const source = databaseStorage?.length ? databaseStorage : storageUsage; + + if (source) { + return buildStorageStats(source); + } + + return [ + { + name: EType.None, + used: blobStorage, + limit: blobStorageLimit, + usage: calculateUsage(blobStorage, blobStorageLimit), + }, + ]; +} + +function buildLegacyBlobStorageStats({ + blobStorage, + blobStorageLimit, + storageUsage, +}: { + blobStorage: number; + blobStorageLimit?: number; + storageUsage: TTenant['StorageUsage']; +}) { + if (storageUsage) { + return buildStorageStats(storageUsage); + } + + return [ + { + name: EType.SSD, + used: blobStorage, + limit: blobStorageLimit, + usage: calculateUsage(blobStorage, blobStorageLimit), + }, + ]; +} + +function buildTabletStorageStats({ + quotaUsage, + storageQuotasByType, + tabletStorage, + tabletStorageLimit, + tablesStorage, +}: { + quotaUsage: TTenant['QuotaUsage']; + storageQuotasByType: Map; + tabletStorage: number; + tabletStorageLimit?: number; + tablesStorage: TTenant['TablesStorage']; +}) { + if (tablesStorage?.length) { + return tablesStorage.map((value) => { + const {Type, Size, Limit, SoftQuota} = value; + + const type = getTenantStorageType(Type); + const used = getStorageUsed(Size); + const typedQuota = type === EType.None ? undefined : storageQuotasByType.get(type); + const limit = getStorageLimit(SoftQuota) ?? typedQuota ?? getStorageLimit(Limit); + + return { + name: type, + used, + limit, + usage: calculateUsage(used, limit), + }; + }); + } + + if (quotaUsage) { + return quotaUsage.map((value) => { + const {Type, Size, Limit} = value; + + const type = getTenantStorageType(Type); + const used = getStorageUsed(Size); + const limit = getStorageLimit(Limit); + + return { + name: type, + used, + limit, + usage: calculateUsage(used, limit), + }; + }); + } + + if (tabletStorageLimit) { + return [ + { + name: EType.None, + used: tabletStorage, + limit: tabletStorageLimit, + usage: calculateUsage(tabletStorage, tabletStorageLimit), + }, + ]; + } + + return undefined; +} + +function buildLegacyTabletStorageStats({ + quotaUsage, + tabletStorage, + tabletStorageLimit, +}: { + quotaUsage: TTenant['QuotaUsage']; + tabletStorage: number; + tabletStorageLimit?: number; +}) { + if (quotaUsage) { + return buildStorageStats(quotaUsage); + } + + if (tabletStorageLimit) { + return [ + { + name: EType.SSD, + used: tabletStorage, + limit: tabletStorageLimit, + usage: calculateUsage(tabletStorage, tabletStorageLimit), + }, + ]; + } + + return undefined; +} + export const calculateTenantMetrics = (tenant: TTenant = {}) => { const { CoresUsed, @@ -63,6 +265,7 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { StorageAllocatedLimit, PoolStats, DatabaseQuotas = {}, + DatabaseStorage, StorageUsage, QuotaUsage, TablesStorage, @@ -90,59 +293,30 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { const size = Number(storageType.Size) || 0; return sum + size; }, 0) ?? 0; - - let blobStorageStats: TenantStorageStats[]; - let tabletStorageStats: TenantStorageStats[] | undefined; - - if (StorageUsage) { - blobStorageStats = StorageUsage.map((value) => { - const {Type, Size, Limit} = value; - - const used = Number(Size); - const limit = Number(Limit); - - return { - name: Type, - used, - limit, - usage: calculateUsage(used, limit), - }; - }); - } else { - blobStorageStats = [ - { - name: EType.SSD, - used: blobStorage, - limit: blobStorageLimit, - usage: calculateUsage(blobStorage, blobStorageLimit), - }, - ]; - } - - if (QuotaUsage) { - tabletStorageStats = QuotaUsage.map((value) => { - const {Type, Size, Limit} = value; - - const used = Number(Size); - const limit = Number(Limit); - - return { - name: Type, - used, - limit, - usage: calculateUsage(used, limit), - }; - }); - } else if (tabletStorageLimit) { - tabletStorageStats = [ - { - name: EType.SSD, - used: tabletStorage, - limit: tabletStorageLimit, - usage: calculateUsage(tabletStorage, tabletStorageLimit), - }, - ]; - } + const legacyBlobStorageStats = buildLegacyBlobStorageStats({ + blobStorage, + blobStorageLimit, + storageUsage: StorageUsage, + }); + const legacyTabletStorageStats = buildLegacyTabletStorageStats({ + quotaUsage: QuotaUsage, + tabletStorage, + tabletStorageLimit, + }); + const storageQuotasByType = buildStorageQuotaMap(DatabaseQuotas.storage_quotas); + const blobStorageStats = buildBlobStorageStats({ + blobStorage, + blobStorageLimit, + databaseStorage: DatabaseStorage, + storageUsage: StorageUsage, + }); + const tabletStorageStats = buildTabletStorageStats({ + quotaUsage: QuotaUsage, + storageQuotasByType, + tabletStorage, + tabletStorageLimit, + tablesStorage: TablesStorage, + }); const memoryStats: TenantMetricStats[] = [ { @@ -172,6 +346,7 @@ export const calculateTenantMetrics = (tenant: TTenant = {}) => { cpu, poolsStats, memoryStats, + storageMetricStats: legacyTabletStorageStats || legacyBlobStorageStats || [], blobStorageStats, tabletStorageStats, networkUtilization, diff --git a/src/types/api/storage.ts b/src/types/api/storage.ts index 48e57b3ca9..9fe53c9ea4 100644 --- a/src/types/api/storage.ts +++ b/src/types/api/storage.ts @@ -422,7 +422,7 @@ export interface StorageStatsResponse { export interface StorageStatsRequestParams { database: string; - path: SchemaPathParam; + path?: SchemaPathParam; groupBy?: StorageStatsGroupBy; everything?: boolean; groups?: boolean; diff --git a/src/types/api/tenant.ts b/src/types/api/tenant.ts index 8d2ccf79dc..0c1ca65ccf 100644 --- a/src/types/api/tenant.ts +++ b/src/types/api/tenant.ts @@ -65,6 +65,7 @@ export interface TTenant { StorageUsage?: TStorageUsage[]; QuotaUsage?: TStorageUsage[]; TablesStorage?: TStorageUsage[]; + DatabaseStorage?: TStorageUsage[]; /** value is float */ NetworkUtilization?: number; @@ -192,14 +193,27 @@ export interface DatabaseQuotas { data_stream_reserved_storage_quota?: string; /** uint32 */ ttl_min_run_internal_seconds?: string; + storage_quotas?: StorageQuotas[]; +} + +interface StorageQuotas { + unit_kind?: string; + /** uint64 */ + data_size_hard_quota?: string; + /** uint64 */ + data_size_soft_quota?: string; } interface TStorageUsage { - Type: EType; + Type: string; /** uint64 */ Size?: string; /** uint64 */ Limit?: string; + /** uint64 */ + SoftQuota?: string; + /** uint64 */ + HardQuota?: string; } export enum EType { diff --git a/src/utils/__test__/storageMetrics.test.ts b/src/utils/__test__/storageMetrics.test.ts index f91682b218..0db4de2a04 100644 --- a/src/utils/__test__/storageMetrics.test.ts +++ b/src/utils/__test__/storageMetrics.test.ts @@ -14,6 +14,21 @@ describe('storageMetrics', () => { expect(formatMetricBytes(521_000_000, 'gb')).toBe(`0.52${UNBREAKABLE_GAP}GB`); }); + test('formatMetricBytes supports custom precision for shared unit formatting', () => { + expect(formatMetricBytes(521_000_000, 'gb', {gbDecimalPlacesBelowOne: 1})).toBe( + `0.5${UNBREAKABLE_GAP}GB`, + ); + expect(formatMetricBytes(123.4, 'b', {bytesDecimalPlaces: 0})).toBe( + `123${UNBREAKABLE_GAP}B`, + ); + }); + + test('formatMetricBytes can reject negative values', () => { + expect(formatMetricBytes(-1, undefined, {allowNegative: false})).toBe( + EMPTY_DATA_PLACEHOLDER, + ); + }); + test('formatMetricPercent keeps integer usage values without decimals', () => { expect(formatMetricPercent(50)).toBe('50%'); }); diff --git a/src/utils/__test__/utils.test.ts b/src/utils/__test__/utils.test.ts new file mode 100644 index 0000000000..1f26e93645 --- /dev/null +++ b/src/utils/__test__/utils.test.ts @@ -0,0 +1,34 @@ +import {parseNonNegativeNumber, parseOptionalNonNegativeNumber} from '../utils'; + +describe('parseOptionalNonNegativeNumber', () => { + test('returns non-negative finite numbers', () => { + expect(parseOptionalNonNegativeNumber(0)).toBe(0); + expect(parseOptionalNonNegativeNumber('42')).toBe(42); + }); + + test('returns undefined for invalid or negative values', () => { + expect(parseOptionalNonNegativeNumber(undefined)).toBeUndefined(); + expect(parseOptionalNonNegativeNumber('abc')).toBeUndefined(); + expect(parseOptionalNonNegativeNumber(-1)).toBeUndefined(); + expect(parseOptionalNonNegativeNumber(Infinity)).toBeUndefined(); + }); + + test('treats empty strings as undefined by default', () => { + expect(parseOptionalNonNegativeNumber('')).toBeUndefined(); + expect(parseOptionalNonNegativeNumber(' ')).toBeUndefined(); + }); + + test('can treat empty strings as zero for compatibility', () => { + expect(parseOptionalNonNegativeNumber('', {emptyStringAsUndefined: false})).toBe(0); + }); +}); + +describe('parseNonNegativeNumber', () => { + test('returns fallback for invalid or negative values', () => { + expect(parseNonNegativeNumber(undefined)).toBe(0); + expect(parseNonNegativeNumber('')).toBe(0); + expect(parseNonNegativeNumber('', 10)).toBe(10); + expect(parseNonNegativeNumber('abc')).toBe(0); + expect(parseNonNegativeNumber(-1, 10)).toBe(10); + }); +}); diff --git a/src/utils/disks/normalizeMediaType.ts b/src/utils/disks/normalizeMediaType.ts index f0d418b70f..a4013b9b9d 100644 --- a/src/utils/disks/normalizeMediaType.ts +++ b/src/utils/disks/normalizeMediaType.ts @@ -11,7 +11,7 @@ export function normalizeMediaType(mediaType?: string) { } const [rawMediaType] = mediaType.split(MEDIA_TYPE_SEPARATOR); - const normalizedMediaType = rawMediaType?.trim(); + const normalizedMediaType = rawMediaType?.trim().toUpperCase(); if (!normalizedMediaType) { return UNKNOWN_MEDIA_TYPE; @@ -21,5 +21,9 @@ export function normalizeMediaType(mediaType?: string) { return HDD_MEDIA_TYPE; } + if (normalizedMediaType === UNKNOWN_MEDIA_TYPE.toUpperCase()) { + return UNKNOWN_MEDIA_TYPE; + } + return normalizedMediaType; } diff --git a/src/utils/storageMetrics.ts b/src/utils/storageMetrics.ts index 6cf6a3141f..c3e97554e2 100644 --- a/src/utils/storageMetrics.ts +++ b/src/utils/storageMetrics.ts @@ -3,13 +3,28 @@ import {getBytesSizeUnit, sizes} from './bytesParsers'; import {EMPTY_DATA_PLACEHOLDER, UNBREAKABLE_GAP} from './constants'; import {formatNumber, formatPercent} from './dataFormatters/dataFormatters'; -function getMetricBytesDecimalPlaces(size: BytesSizes, convertedValue: number) { - if (size === 'b' || size === 'kb' || size === 'mb') { +interface FormatMetricBytesOptions { + allowNegative?: boolean; + bytesDecimalPlaces?: 0 | 1; + gbDecimalPlacesBelowOne?: 1 | 2; +} + +function getMetricBytesDecimalPlaces( + size: BytesSizes, + convertedValue: number, + {bytesDecimalPlaces = 1, gbDecimalPlacesBelowOne = 2}: FormatMetricBytesOptions = {}, +) { + if (size === 'b') { + return convertedValue % 1 === 0 ? 0 : bytesDecimalPlaces; + } + + if (size === 'kb' || size === 'mb') { return convertedValue % 1 === 0 ? 0 : 1; } + if (size === 'gb') { if (convertedValue < 1) { - return 2; + return convertedValue % 1 === 0 ? 0 : gbDecimalPlacesBelowOne; } return convertedValue % 1 === 0 ? 0 : 1; } @@ -33,16 +48,20 @@ export function getConsistentMetricBytesSize(values: Array= 0 ? numericValue : undefined; +} + +export function parseNonNegativeNumber(value: unknown, defaultValue = 0) { + return parseOptionalNonNegativeNumber(value) ?? defaultValue; +} + export function toExponential(value: number, precision?: number) { return Number(value).toExponential(precision); } diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts new file mode 100644 index 0000000000..d8ae27cc1a --- /dev/null +++ b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts @@ -0,0 +1,867 @@ +import {expect, test} from '@playwright/test'; +import type {Locator, Page, Route} from '@playwright/test'; + +import {database} from '../../../../utils/constants'; +import {TenantPage} from '../../TenantPage'; + +const TOP_ROW_PATH = '/local/kv_test'; +const SECOND_TOP_ROW_PATH = '/local/orders_cdc'; +const STORAGE_VIEW_SELECTOR = '.ydb-tenant-storage-new'; +const STORAGE_SECTIONS_SELECTOR = '.ydb-tenant-storage-new__sections-inner'; +const MEDIA_SECTION_SELECTOR = '.ydb-tenant-storage-summary-sections'; +const SUMMARY_CARD_SELECTOR = '.ydb-tenant-storage-summary-card'; +const SUMMARY_METRIC_SELECTOR = '.ydb-tenant-storage-summary-card__metric'; +const SUMMARY_ROW_SELECTOR = '.ydb-tenant-storage-summary-card__row'; +const SEGMENT_ITEM_INACTIVE_SELECTOR = '.ydb-tenant-storage-segments__item_inactive'; +const SEGMENT_EMPTY_INACTIVE_SELECTOR = '.ydb-tenant-storage-segments__empty_inactive'; +const LEGEND_ITEM_SELECTOR = '.ydb-tenant-storage-segments__legend-item'; +const LEGEND_ITEM_INACTIVE_SELECTOR = '.ydb-tenant-storage-segments__legend-item_inactive'; +const TOP_USAGE_TABLE_SELECTOR = '.ydb-tenant-storage-top-usage-table'; +const TOP_USAGE_PATH_COPY_SELECTOR = '.ydb-tenant-storage-top-usage-table__path-copy'; +const HELP_MARK_SELECTOR = '.g-help-mark'; +const STORAGE_SCREENSHOT_THEMES = ['light', 'dark'] as const; +const STORAGE_SCREENSHOT_VIEWPORT = {width: 1600, height: 1000}; +const EXACT_COLUMN_TABLE_TOOLTIP_REGEXP = /2\s244\.6\sMB/; +const EMPTY_DATA_PLACEHOLDER_TEXT = String.fromCharCode(8212); +const QUOTA_MISSING_TITLE = 'No quota? This is wrong.'; +const QUOTA_MISSING_DESCRIPTION = + 'This mode lets your database consume shared storage and is only for dev/test. Set a quota for stability.'; + +type StorageScreenshotTheme = (typeof STORAGE_SCREENSHOT_THEMES)[number]; + +async function enableNewStorageView(page: Page, theme?: StorageScreenshotTheme) { + await page.addInitScript(() => { + localStorage.setItem('enableNewStorageView', JSON.stringify(true)); + }); + + if (theme) { + await page.addInitScript((themeName) => { + localStorage.setItem('theme', themeName); + }, theme); + } +} + +async function setupStorageScreenshotViewport(page: Page) { + await page.setViewportSize(STORAGE_SCREENSHOT_VIEWPORT); +} + +async function setupCapabilities(page: Page, storageStatsVersion: number) { + await page.route('**/viewer/capabilities*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Database: database, + Capabilities: { + '/viewer/storage_stats': storageStatsVersion, + }, + }), + }); + }); +} + +async function setupWhoami(page: Page) { + await page.route('**/viewer/json/whoami?*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + UserSID: 'test-user', + UserID: 'test-user-id', + AuthType: 'Login', + IsViewerAllowed: true, + IsMonitoringAllowed: true, + IsAdministrationAllowed: true, + }), + }); + }); +} + +async function setupTenantInfo( + page: Page, + tenantType: 'Dedicated' | 'Serverless' = 'Dedicated', + { + databaseQuotas, + databaseStorage, + tablesStorage, + }: { + databaseQuotas?: { + data_size_soft_quota?: string; + storage_quotas?: Array<{ + data_size_hard_quota?: string; + data_size_soft_quota?: string; + unit_kind: string; + }>; + }; + databaseStorage?: Array<{Type: string; Size: string; Limit: string}>; + tablesStorage?: Array<{ + Type: string; + Size: string; + Limit?: string; + SoftQuota?: string; + HardQuota?: string; + }>; + } = {}, +) { + await page.route('**/viewer/json/tenantinfo?*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + TenantInfo: [ + { + Name: database, + Type: tenantType, + Overall: 'Green', + StorageAllocatedSize: '26400000000000', + StorageAllocatedLimit: '201000000000000', + StorageGroups: '2', + DatabaseQuotas: databaseQuotas, + TablesStorage: tablesStorage ?? [ + { + Type: 'SSD', + Size: '3100000000000', + Limit: '21000000000000', + SoftQuota: '21000000000000', + }, + ], + DatabaseStorage: databaseStorage ?? [ + { + Type: 'SSD', + Size: '26400000000000', + Limit: '201000000000000', + }, + ], + CoresTotal: 8, + }, + ], + }), + }); + }); +} + +async function setupStorageStats( + page: Page, + {withMultiMedia = false}: {withMultiMedia?: boolean} = {}, +) { + await page.route('**/viewer/storage_stats?*', async (route: Route) => { + const url = new URL(route.request().url()); + const groupBy = url.searchParams.get('group_by'); + + if (groupBy === 'tablet_type') { + const tablets = withMultiMedia + ? [ + { + Type: 'Unknown', + StorageSize: 6605648, + Media: [{Kind: 'ssd', StorageSize: 6605648}], + }, + { + Type: 'Mediator', + StorageSize: 9873, + Media: [{Kind: 'ssd', StorageSize: 9873}], + }, + { + Type: 'Coordinator', + StorageSize: 13759836, + Media: [{Kind: 'ssd', StorageSize: 13759836}], + }, + { + Type: 'Hive', + StorageSize: 36872858, + Media: [{Kind: 'ssd', StorageSize: 36872858}], + }, + { + Type: 'SchemeShard', + StorageSize: 39347449, + Media: [{Kind: 'ssd', StorageSize: 39347449}], + }, + { + Type: 'DataShard', + StorageSize: 1045486055921, + Media: [ + {Kind: 'hdd', StorageSize: 1045106959268}, + {Kind: 'ssd', StorageSize: 379096653}, + ], + }, + { + Type: 'SysViewProcessor', + StorageSize: 15764712, + Media: [{Kind: 'ssd', StorageSize: 15764712}], + }, + { + Type: 'StatisticsAggregator', + StorageSize: 1448581, + Media: [{Kind: 'ssd', StorageSize: 1448581}], + }, + ] + : [ + { + Type: 'Mediator', + StorageSize: 12820, + Media: [{Kind: 'ssd', StorageSize: 12820}], + }, + { + Type: 'Coordinator', + StorageSize: 13026534, + Media: [{Kind: 'ssd', StorageSize: 13026534}], + }, + { + Type: 'Hive', + StorageSize: 142572169, + Media: [{Kind: 'ssd', StorageSize: 142572169}], + }, + { + Type: 'SchemeShard', + StorageSize: 14850162, + Media: [{Kind: 'ssd', StorageSize: 14850162}], + }, + { + Type: 'DataShard', + StorageSize: 71262508656, + Media: [{Kind: 'ssd', StorageSize: 71262508656}], + }, + { + Type: 'PersQueue', + StorageSize: 490192343, + Media: [{Kind: 'ssd', StorageSize: 490192343}], + }, + { + Type: 'PersQueueReadBalancer', + StorageSize: 23506713, + Media: [{Kind: 'ssd', StorageSize: 23506713}], + }, + { + Type: 'SysViewProcessor', + StorageSize: 72956425, + Media: [{Kind: 'ssd', StorageSize: 72956425}], + }, + { + Type: 'ColumnShard', + StorageSize: 2244552896, + Media: [{Kind: 'ssd', StorageSize: 2244552896}], + }, + { + Type: 'StatisticsAggregator', + StorageSize: 2179029, + Media: [{Kind: 'ssd', StorageSize: 2179029}], + }, + { + Type: 'GraphShard', + StorageSize: 15781, + Media: [{Kind: 'ssd', StorageSize: 15781}], + }, + ]; + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Tablets: tablets, + }), + }); + return; + } + + if (groupBy === 'path') { + const requestPath = url.searchParams.get('path') ?? ''; + const requestedPaths = requestPath + .split(',') + .map((path) => { + return path.startsWith('/') ? path : `${database}/${path}`; + }) + .filter(Boolean); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Paths: requestedPaths.map((path) => { + if (path === TOP_ROW_PATH) { + return { + FullPath: TOP_ROW_PATH, + StorageSize: 360000000000, + }; + } + + if (path === SECOND_TOP_ROW_PATH) { + return { + FullPath: SECOND_TOP_ROW_PATH, + StorageSize: 300000000, + }; + } + + return { + FullPath: path, + StorageSize: 0, + }; + }), + }), + }); + return; + } + + await route.continue(); + }); +} + +async function setupPartitionStatsQuery(page: Page) { + await page.route('**/viewer/json/query?*', async (route: Route) => { + const request = route.request().postDataJSON() as {query?: string} | null; + const queryText = request?.query ?? ''; + + if (!queryText.includes('.sys/partition_stats')) { + await route.continue(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + result: [ + { + columns: [ + {name: 'Path', type: 'Utf8?'}, + {name: 'Size', type: 'Uint64?'}, + {name: 'UserData', type: 'Uint64?'}, + ], + rows: [ + [TOP_ROW_PATH, 112000000000, 112000000000], + [SECOND_TOP_ROW_PATH, 500000, 500000], + ], + }, + ], + }), + }); + }); +} + +async function setupDescribe(page: Page) { + await page.route('**/viewer/json/describe?*', async (route: Route) => { + const url = new URL(route.request().url()); + const requestPath = url.searchParams.get('path'); + + if (requestPath === database) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Path: database, + PathDescription: { + Self: { + Name: 'local', + PathType: 'EPathTypeSubDomain', + }, + DomainDescription: { + DiskSpaceUsage: { + Tables: { + DataSize: '3100000000000', + }, + Topics: { + DataSize: '0', + }, + }, + }, + Children: [ + { + Name: 'kv_test', + PathType: 'EPathTypeTable', + }, + { + Name: 'orders_cdc', + PathType: 'EPathTypePersQueueGroup', + }, + ], + }, + }), + }); + return; + } + + await route.continue(); + }); +} + +async function openStorageMetricsTab(page: Page) { + const storageTab = page.locator('.tenant-metrics-tabs__link-container:has-text("Storage")'); + await storageTab.click(); +} + +function getSummaryCard(container: Locator, title: string) { + return container.locator(SUMMARY_CARD_SELECTOR).filter({hasText: title}); +} + +function getSummaryMetric(card: Locator, label: string) { + return card.locator(SUMMARY_METRIC_SELECTOR).filter({hasText: label}); +} + +function getSummaryRow(card: Locator, label: string) { + return card.locator(SUMMARY_ROW_SELECTOR).filter({hasText: label}); +} + +async function expectQuotaMissingHelpMark(page: Page, metric: Locator) { + const helpMark = metric.locator(HELP_MARK_SELECTOR); + + await expect(metric.getByText(EMPTY_DATA_PLACEHOLDER_TEXT, {exact: true})).toBeVisible(); + await expect(helpMark).toHaveCount(1); + + await helpMark.hover(); + + await expect(page.getByText(QUOTA_MISSING_TITLE, {exact: true})).toBeVisible(); + await expect(page.getByText(QUOTA_MISSING_DESCRIPTION, {exact: true})).toBeVisible(); + await expect(page.getByText('Learn more', {exact: true})).toHaveCount(0); + await expect(page.getByText('Set up quota', {exact: true})).toHaveCount(0); +} + +test.describe('Tenant Overview storage metrics tab', () => { + test.describe.configure({timeout: 60_000}); + + for (const theme of STORAGE_SCREENSHOT_THEMES) { + test(`renders the new storage layout in ${theme} theme`, async ({page}) => { + await setupStorageScreenshotViewport(page); + await enableNewStorageView(page, theme); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated'); + await setupPartitionStatsQuery(page); + await setupStorageStats(page); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const topUsageTable = storageView.locator(TOP_USAGE_TABLE_SELECTOR); + const userDataSummary = getSummaryCard(storageView, 'User data'); + const physicalSummary = getSummaryCard(storageView, 'Physical disk usage'); + + await expect(storageView).toBeVisible(); + await expect(userDataSummary).toBeVisible(); + await expect(physicalSummary).toBeVisible(); + await expect(topUsageTable.getByText('Top 10', {exact: true})).toBeVisible(); + await expect(topUsageTable.getByText('by space usage', {exact: true})).toBeVisible(); + await expect(storageView.getByText('Row table', {exact: true})).toBeVisible(); + await expect(storageView.getByText('System', {exact: true})).toBeVisible(); + await expect( + getSummaryMetric(userDataSummary, 'Used').getByText('3.1 TB', {exact: true}), + ).toBeVisible(); + await expect( + getSummaryMetric(physicalSummary, 'Used').getByText('26.4 TB', {exact: true}), + ).toBeVisible(); + await expect( + physicalSummary + .locator(LEGEND_ITEM_SELECTOR) + .filter({hasText: 'System'}) + .getByText('245.6 MB', {exact: true}), + ).toBeVisible(); + await expect( + physicalSummary + .locator(LEGEND_ITEM_SELECTOR) + .filter({hasText: 'Row tables'}) + .getByText('71.3 GB', {exact: true}), + ).toBeVisible(); + await expect( + physicalSummary + .locator(LEGEND_ITEM_SELECTOR) + .filter({hasText: 'Column tables'}) + .getByText('2.2 GB', {exact: true}), + ).toBeVisible(); + await expect( + physicalSummary + .locator(LEGEND_ITEM_SELECTOR) + .filter({hasText: 'Unknown'}) + .getByText('26.3 TB', {exact: true}), + ).toBeVisible(); + await expect(physicalSummary.getByText('0 TB', {exact: true})).toHaveCount(0); + await expect(topUsageTable.getByText('User data, GB', {exact: true})).toHaveCount(0); + await expect(topUsageTable.getByText('Physical disk, GB', {exact: true})).toHaveCount( + 0, + ); + await expect(topUsageTable.getByText('User data', {exact: true}).first()).toBeVisible(); + await expect( + topUsageTable.getByText('Physical disk', {exact: true}).first(), + ).toBeVisible(); + await expect(topUsageTable.getByText('112 GB', {exact: true})).toBeVisible(); + await expect(topUsageTable.getByText('360 GB', {exact: true})).toBeVisible(); + await expect(topUsageTable.getByText('500 KB', {exact: true})).toBeVisible(); + await expect(topUsageTable.getByText('300 MB', {exact: true})).toBeVisible(); + await expect(topUsageTable.getByText('>500x', {exact: true})).toBeVisible(); + await expect(topUsageTable.locator(TOP_USAGE_PATH_COPY_SELECTOR).first()).toBeVisible(); + await expect(userDataSummary.getByText('used 15%', {exact: true})).toBeVisible(); + await expect(physicalSummary.getByText('used 13%', {exact: true})).toBeVisible(); + await expect(storageView.getByRole('link', {name: 'kv_test'})).toHaveAttribute( + 'href', + /schema=%2Flocal%2Fkv_test/, + ); + await expect(storageView.locator(STORAGE_SECTIONS_SELECTOR)).toHaveScreenshot( + `tenant-overview-storage-single-media-${theme}.png`, + ); + await expect(topUsageTable).toHaveScreenshot( + `tenant-overview-storage-top-usage-${theme}.png`, + ); + await expect(storageView).toHaveScreenshot(`tenant-overview-storage-full-${theme}.png`); + }); + + test(`renders grouped summary sections for multiple storage types in ${theme} theme`, async ({ + page, + }) => { + await setupStorageScreenshotViewport(page); + await enableNewStorageView(page, theme); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated', { + databaseQuotas: { + data_size_soft_quota: '612032839680', + storage_quotas: [ + { + unit_kind: 'ssd', + data_size_soft_quota: '306016419840', + data_size_hard_quota: '322122547200', + }, + { + unit_kind: 'hdd', + data_size_soft_quota: '2089072092774', + data_size_hard_quota: '2199023255552', + }, + ], + }, + databaseStorage: [ + {Type: 'HDD', Size: '1353743073280', Limit: '17999012094860'}, + {Type: 'SSD', Size: '98419343360', Limit: '1873981472766'}, + ], + tablesStorage: [ + { + Type: 'HDD', + Size: '289166965049', + Limit: '612032839680', + SoftQuota: '612032839680', + HardQuota: '644245094400', + }, + { + Type: 'SSD', + Size: '986', + }, + ], + }); + await setupPartitionStatsQuery(page); + await setupStorageStats(page, {withMultiMedia: true}); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const userDataSummary = getSummaryCard(storageView, 'User data'); + const physicalSummary = getSummaryCard(storageView, 'Physical disk usage'); + const ssdUserDataRow = getSummaryRow(userDataSummary, 'SSD'); + const hddUserDataRow = getSummaryRow(userDataSummary, 'HDD'); + const ssdPhysicalRow = getSummaryRow(physicalSummary, 'SSD'); + const hddPhysicalRow = getSummaryRow(physicalSummary, 'HDD'); + + await expect(storageView.locator(MEDIA_SECTION_SELECTOR)).toHaveCount(1); + await expect(userDataSummary).toHaveCount(1); + await expect(physicalSummary).toHaveCount(1); + await expect(ssdUserDataRow).toBeVisible(); + await expect(hddUserDataRow).toBeVisible(); + await expect(ssdPhysicalRow).toBeVisible(); + await expect(hddPhysicalRow).toBeVisible(); + await expect( + getSummaryMetric(hddUserDataRow, 'Used').getByText('289.2 GB', {exact: true}), + ).toBeVisible(); + await expect( + getSummaryMetric(ssdUserDataRow, 'Quota').getByText('306 GB', {exact: true}), + ).toBeVisible(); + await expect(hddUserDataRow.getByText('used 47%', {exact: true})).toBeVisible(); + await expect(userDataSummary.locator(LEGEND_ITEM_SELECTOR)).toHaveCount(0); + await expect( + getSummaryMetric(hddPhysicalRow, 'Overhead').getByText('4.7x', {exact: true}), + ).toBeVisible(); + await expect( + hddPhysicalRow.locator(LEGEND_ITEM_SELECTOR).filter({hasText: 'Row tables'}), + ).toBeVisible(); + await expect( + hddPhysicalRow.locator(LEGEND_ITEM_SELECTOR).filter({hasText: 'Unknown'}), + ).toBeVisible(); + await expect(storageView.locator(STORAGE_SECTIONS_SELECTOR)).toHaveScreenshot( + `tenant-overview-storage-multi-media-${theme}.png`, + ); + + const hddRowTablesSegment = hddPhysicalRow.locator( + '.ydb-tenant-storage-segments__item[aria-label^="Row tables:"]', + ); + + await hddRowTablesSegment.hover(); + + await expect( + page.getByText('77.2% of total physical disk usage', {exact: true}), + ).toBeVisible(); + await expect(hddPhysicalRow.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(1); + await expect(hddPhysicalRow.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(1); + await expect(ssdPhysicalRow.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + await expect(ssdPhysicalRow.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + + await hddRowTablesSegment.click(); + await page.mouse.move(0, 0); + + await expect(hddPhysicalRow.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + await expect(ssdPhysicalRow.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + }); + } + + test('shows quota missing helpmark for a single-media user data summary without quota', async ({ + page, + }) => { + await enableNewStorageView(page); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated', { + tablesStorage: [ + { + Type: 'SSD', + Size: '3100000000000', + }, + ], + }); + await setupPartitionStatsQuery(page); + await setupStorageStats(page); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const userDataSummary = getSummaryCard(storageView, 'User data'); + const quotaMetric = getSummaryMetric(userDataSummary, 'Quota'); + + await expectQuotaMissingHelpMark(page, quotaMetric); + }); + + test('shows quota missing helpmark only for multi-media rows without quota', async ({page}) => { + await enableNewStorageView(page); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated', { + databaseQuotas: { + storage_quotas: [ + { + unit_kind: 'ssd', + data_size_soft_quota: '306016419840', + }, + ], + }, + databaseStorage: [ + {Type: 'HDD', Size: '1353743073280', Limit: '17999012094860'}, + {Type: 'SSD', Size: '98419343360', Limit: '1873981472766'}, + ], + tablesStorage: [ + { + Type: 'HDD', + Size: '289166965049', + }, + { + Type: 'SSD', + Size: '986', + }, + ], + }); + await setupPartitionStatsQuery(page); + await setupStorageStats(page, {withMultiMedia: true}); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const userDataSummary = getSummaryCard(storageView, 'User data'); + const hddUserDataRow = getSummaryRow(userDataSummary, 'HDD'); + const ssdUserDataRow = getSummaryRow(userDataSummary, 'SSD'); + const hddQuotaMetric = getSummaryMetric(hddUserDataRow, 'Quota'); + const ssdQuotaMetric = getSummaryMetric(ssdUserDataRow, 'Quota'); + + await expectQuotaMissingHelpMark(page, hddQuotaMetric); + await expect(ssdQuotaMetric.locator(HELP_MARK_SELECTOR)).toHaveCount(0); + await expect(ssdQuotaMetric.getByText('306 GB', {exact: true})).toBeVisible(); + }); + + test('keeps legacy dedicated storage layout when experiment is disabled', async ({page}) => { + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated'); + await setupPartitionStatsQuery(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + await expect(page.locator(STORAGE_VIEW_SELECTOR)).toHaveCount(0); + await expect(page.getByText('Storage Details', {exact: true})).toBeVisible(); + await expect(page.getByText('Top tables by size', {exact: true})).toBeVisible(); + }); + + for (const theme of STORAGE_SCREENSHOT_THEMES) { + test(`highlights hovered storage segment and shows rich tooltip in ${theme} theme`, async ({ + page, + }) => { + await setupStorageScreenshotViewport(page); + await enableNewStorageView(page, theme); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated'); + await setupPartitionStatsQuery(page); + await setupStorageStats(page); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const physicalSummary = getSummaryCard(storageView, 'Physical disk usage'); + const columnSegment = physicalSummary.locator('[aria-label^="Column tables:"]').first(); + + await columnSegment.hover(); + + await expect(page.getByText(EXACT_COLUMN_TABLE_TOOLTIP_REGEXP)).toBeVisible(); + await expect(page.getByText('2.2 GB', {exact: true})).toBeVisible(); + await expect( + page.getByText('0.01% of total physical disk usage', {exact: true}), + ).toBeVisible(); + await expect(physicalSummary.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(4); + await expect(physicalSummary.locator(SEGMENT_EMPTY_INACTIVE_SELECTOR)).toHaveCount(1); + await expect(physicalSummary.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(4); + await expect( + physicalSummary.locator(LEGEND_ITEM_SELECTOR).filter({hasText: 'Column tables'}), + ).not.toHaveClass(/legend-item_inactive/); + await expect(storageView.locator(STORAGE_SECTIONS_SELECTOR)).toHaveScreenshot( + `tenant-overview-storage-hover-${theme}.png`, + ); + + await page.mouse.move(0, 0); + + await expect(physicalSummary.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + await expect(physicalSummary.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + }); + + test(`shows tooltip on legend hover and clears state after click in ${theme} theme`, async ({ + page, + }) => { + await setupStorageScreenshotViewport(page); + await enableNewStorageView(page, theme); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Dedicated'); + await setupPartitionStatsQuery(page); + await setupStorageStats(page); + await setupDescribe(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + const storageView = page.locator(STORAGE_VIEW_SELECTOR); + const physicalSummary = getSummaryCard(storageView, 'Physical disk usage'); + const columnLegendItem = physicalSummary + .locator(LEGEND_ITEM_SELECTOR) + .filter({hasText: 'Column tables'}); + + await columnLegendItem.hover(); + + await expect( + page.getByText('0.01% of total physical disk usage', {exact: true}), + ).toBeVisible(); + await expect(physicalSummary.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(4); + await expect(storageView.locator(STORAGE_SECTIONS_SELECTOR)).toHaveScreenshot( + `tenant-overview-storage-legend-hover-${theme}.png`, + ); + + await columnLegendItem.click(); + await page.mouse.move(0, 0); + + await expect(physicalSummary.locator(SEGMENT_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + await expect(physicalSummary.locator(LEGEND_ITEM_INACTIVE_SELECTOR)).toHaveCount(0); + }); + } + + test('keeps legacy dedicated storage layout when storage_stats capability is unavailable', async ({ + page, + }) => { + await enableNewStorageView(page); + await setupWhoami(page); + await setupCapabilities(page, 0); + await setupTenantInfo(page, 'Dedicated'); + await setupPartitionStatsQuery(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + await expect(page.locator(STORAGE_VIEW_SELECTOR)).toHaveCount(0); + await expect(page.getByText('Storage Details', {exact: true})).toBeVisible(); + await expect(page.getByText('Top tables by size', {exact: true})).toBeVisible(); + }); + + test('keeps legacy serverless storage layout when experiment is enabled', async ({page}) => { + await enableNewStorageView(page); + await setupWhoami(page); + await setupCapabilities(page, 1); + await setupTenantInfo(page, 'Serverless'); + await setupPartitionStatsQuery(page); + + const tenantPage = new TenantPage(page); + await tenantPage.goto({ + schema: database, + database, + tenantPage: 'diagnostics', + }); + + await openStorageMetricsTab(page); + + await expect(page.locator(STORAGE_VIEW_SELECTOR)).toHaveCount(0); + await expect(page.getByText('Top tables by size', {exact: true})).toBeVisible(); + }); +}); diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-chromium-linux.png new file mode 100644 index 0000000000..4a47c8251b Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-safari-linux.png new file mode 100644 index 0000000000..3c481aeea4 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-chromium-linux.png new file mode 100644 index 0000000000..abc0b833da Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-safari-linux.png new file mode 100644 index 0000000000..27505422ab Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-full-light-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-chromium-linux.png new file mode 100644 index 0000000000..6da7640203 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-safari-linux.png new file mode 100644 index 0000000000..186bc52450 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-chromium-linux.png new file mode 100644 index 0000000000..89a32720af Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-safari-linux.png new file mode 100644 index 0000000000..113a5cd1f0 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-hover-light-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-chromium-linux.png new file mode 100644 index 0000000000..edb80467f4 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-safari-linux.png new file mode 100644 index 0000000000..445cca9761 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-chromium-linux.png new file mode 100644 index 0000000000..2ace2888ba Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-safari-linux.png new file mode 100644 index 0000000000..eecaa2cabc Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-legend-hover-light-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-chromium-linux.png new file mode 100644 index 0000000000..e27e716765 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-safari-linux.png new file mode 100644 index 0000000000..9f988ef7c1 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-chromium-linux.png new file mode 100644 index 0000000000..bccf58a66a Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-safari-linux.png new file mode 100644 index 0000000000..fa7acf1010 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-multi-media-light-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-chromium-linux.png new file mode 100644 index 0000000000..f29ecd358f Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-safari-linux.png new file mode 100644 index 0000000000..dbbd6ff1c6 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-chromium-linux.png new file mode 100644 index 0000000000..9900eec54c Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-safari-linux.png new file mode 100644 index 0000000000..b0ed52ede3 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-single-media-light-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-chromium-linux.png new file mode 100644 index 0000000000..da861b72cc Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-safari-linux.png new file mode 100644 index 0000000000..97de964836 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-dark-safari-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-chromium-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-chromium-linux.png new file mode 100644 index 0000000000..9d3e993eac Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-chromium-linux.png differ diff --git a/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-safari-linux.png b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-safari-linux.png new file mode 100644 index 0000000000..5ff80de585 Binary files /dev/null and b/tests/suites/tenant/diagnostics/tabs/tenantOverviewStorage.test.ts-snapshots/tenant-overview-storage-top-usage-light-safari-linux.png differ