diff --git a/src/components/MemoryViewer/MemoryViewer.scss b/src/components/MemoryViewer/MemoryViewer.scss new file mode 100644 index 0000000000..049df2db48 --- /dev/null +++ b/src/components/MemoryViewer/MemoryViewer.scss @@ -0,0 +1,104 @@ +@import '../../styles/mixins.scss'; + +$memory-type-colors: ( + 'AllocatorCachesMemory': var(--g-color-base-utility-medium-hover), + 'SharedCacheConsumption': var(--g-color-base-info-medium-hover), + 'MemTableConsumption': var(--g-color-base-warning-medium-hover), + 'QueryExecutionConsumption': var(--g-color-base-positive-medium-hover), + 'Other': var(--g-color-base-neutral-light-hover), +); + +@mixin memory-type-color($type) { + background-color: map-get($memory-type-colors, $type); +} + +.memory-viewer { + $block: &; + + position: relative; + z-index: 0; + + min-width: 150px; + padding: 0 var(--g-spacing-1); + + &__progress-container { + position: relative; + + overflow: hidden; + + height: 20px; + + border-radius: 2px; + background: var(--g-color-base-generic); + } + + &__container { + display: flex; + + padding: 2px 0; + } + + &__legend { + position: absolute; + bottom: 2px; + + width: 20px; + height: 20px; + + border-radius: 2px; + + @each $type, $color in $memory-type-colors { + &_type_#{$type} { + @include memory-type-color($type); + } + } + } + + &__segment { + position: absolute; + + height: 100%; + + @each $type, $color in $memory-type-colors { + &_type_#{$type} { + @include memory-type-color($type); + } + } + } + + &__name { + padding-left: 28px; + } + + &_theme_dark { + color: var(--g-color-text-light-primary); + + #{$block}__segment { + opacity: 0.75; + } + } + + &_status { + &_good { + #{$block}__progress-container { + background-color: var(--g-color-base-positive-light); + } + } + &_warning { + #{$block}__progress-container { + background-color: var(--g-color-base-yellow-light); + } + } + &_danger { + #{$block}__progress-container { + background-color: var(--g-color-base-danger-light); + } + } + } + + &__text { + display: flex; + justify-content: center; + align-items: center; + } +} diff --git a/src/components/MemoryViewer/MemoryViewer.tsx b/src/components/MemoryViewer/MemoryViewer.tsx new file mode 100644 index 0000000000..65b1b23bae --- /dev/null +++ b/src/components/MemoryViewer/MemoryViewer.tsx @@ -0,0 +1,169 @@ +import {DefinitionList, useTheme} from '@gravity-ui/uikit'; + +import type {TMemoryStats} from '../../types/api/nodes'; +import {formatBytes} from '../../utils/bytesParsers'; +import {cn} from '../../utils/cn'; +import {GIGABYTE} from '../../utils/constants'; +import {calculateProgressStatus} from '../../utils/progress'; +import {isNumeric} from '../../utils/utils'; +import {HoverPopup} from '../HoverPopup/HoverPopup'; +import type {FormatProgressViewerValues} from '../ProgressViewer/ProgressViewer'; +import {ProgressViewer} from '../ProgressViewer/ProgressViewer'; + +import {getMemorySegments} from './utils'; + +import './MemoryViewer.scss'; + +const MIN_VISIBLE_MEMORY_SHARE = 1; +const MIN_VISIBLE_MEMORY_VALUE = 0.01 * GIGABYTE; + +const b = cn('memory-viewer'); + +const formatDetailedValues: FormatProgressViewerValues = (value, total) => { + return [ + formatBytes({ + value, + size: 'gb', + withSizeLabel: false, + precision: 2, + }), + formatBytes({ + value: total, + size: 'gb', + withSizeLabel: true, + precision: 1, + }), + ]; +}; + +export interface MemoryProgressViewerProps { + stats: TMemoryStats; + className?: string; + warningThreshold?: number; + value?: number | string; + capacity?: number | string; + formatValues: FormatProgressViewerValues; + percents?: boolean; + dangerThreshold?: number; +} + +export function MemoryViewer({ + stats, + value, + capacity, + percents, + formatValues, + className, + warningThreshold = 60, + dangerThreshold = 80, +}: MemoryProgressViewerProps) { + const theme = useTheme(); + let fillWidth = + Math.round((parseFloat(String(value)) / parseFloat(String(capacity))) * 100) || 0; + fillWidth = fillWidth > 100 ? 100 : fillWidth; + let valueText: number | string | undefined = value, + capacityText: number | string | undefined = capacity, + divider = '/'; + if (percents) { + valueText = fillWidth + '%'; + capacityText = ''; + divider = ''; + } else if (formatValues) { + [valueText, capacityText] = formatValues(Number(value), Number(capacity)); + } + + const renderContent = () => { + if (isNumeric(capacity)) { + return `${valueText} ${divider} ${capacityText}`; + } + + return valueText; + }; + + const calculateMemoryShare = (segmentSize: number) => { + if (!value) { + return 0; + } + return (segmentSize / parseFloat(String(capacity))) * 100; + }; + + const memorySegments = getMemorySegments(stats); + + const status = calculateProgressStatus({ + fillWidth, + warningThreshold, + dangerThreshold, + colorizeProgress: true, + }); + + let currentPosition = 0; + + return ( + + {memorySegments.map( + ({label, value: segmentSize, capacity: segmentCapacity, key}) => ( + +
+
{label}
+ + } + > + {segmentCapacity ? ( + + ) : ( + formatBytes({ + value: segmentSize, + size: 'gb', + withSizeLabel: true, + precision: 2, + }) + )} +
+ ), + )} + + } + > +
+
+ {memorySegments + .filter(({isInfo}) => !isInfo) + .map((segment) => { + if (segment.value < MIN_VISIBLE_MEMORY_VALUE) { + return null; + } + + const currentMemoryShare = Math.max( + calculateMemoryShare(segment.value), + MIN_VISIBLE_MEMORY_SHARE, + ); + const position = currentPosition; + currentPosition += currentMemoryShare; + + return ( +
+ ); + })} +
{renderContent()}
+
+
+ + ); +} diff --git a/src/components/MemoryViewer/i18n/en.json b/src/components/MemoryViewer/i18n/en.json new file mode 100644 index 0000000000..4f51efc6bc --- /dev/null +++ b/src/components/MemoryViewer/i18n/en.json @@ -0,0 +1,11 @@ +{ + "text_external-consumption": "External Consumption", + "text_allocator-caches": "Allocator Caches", + "text_shared-cache": "Shared Cache", + "text_memtable": "MemTable", + "text_query-execution": "Query Execution", + "text_usage": "Usage", + "text_soft-limit": "Soft Limit", + "text_hard-limit": "Hard Limit", + "text_other": "Other" +} diff --git a/src/components/MemoryViewer/i18n/index.ts b/src/components/MemoryViewer/i18n/index.ts new file mode 100644 index 0000000000..831cd3ea1f --- /dev/null +++ b/src/components/MemoryViewer/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-memory-viewer'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/components/MemoryViewer/utils.ts b/src/components/MemoryViewer/utils.ts new file mode 100644 index 0000000000..e6504c6dea --- /dev/null +++ b/src/components/MemoryViewer/utils.ts @@ -0,0 +1,95 @@ +import type {TMemoryStats} from '../../types/api/nodes'; +import {isNumeric} from '../../utils/utils'; + +import i18n from './i18n'; + +function getMaybeNumber(value: string | number | undefined): number | undefined { + return isNumeric(value) ? parseFloat(String(value)) : undefined; +} + +interface MemorySegment { + label: string; + key: string; + value: number; + capacity?: number; + isInfo?: boolean; +} + +export function getMemorySegments(stats: TMemoryStats): MemorySegment[] { + const segments = [ + { + label: i18n('text_shared-cache'), + key: 'SharedCacheConsumption', + value: getMaybeNumber(stats.SharedCacheConsumption), + capacity: getMaybeNumber(stats.SharedCacheLimit), + isInfo: false, + }, + { + label: i18n('text_query-execution'), + key: 'QueryExecutionConsumption', + value: getMaybeNumber(stats.QueryExecutionConsumption), + capacity: getMaybeNumber(stats.QueryExecutionLimit), + isInfo: false, + }, + { + label: i18n('text_memtable'), + key: 'MemTableConsumption', + value: getMaybeNumber(stats.MemTableConsumption), + capacity: getMaybeNumber(stats.MemTableLimit), + isInfo: false, + }, + { + label: i18n('text_allocator-caches'), + key: 'AllocatorCachesMemory', + value: getMaybeNumber(stats.AllocatorCachesMemory), + isInfo: false, + }, + ]; + + const nonInfoSegments = segments.filter( + (segment) => segment.value !== undefined, + ) as MemorySegment[]; + const sumNonInfoSegments = nonInfoSegments.reduce((acc, segment) => acc + segment.value, 0); + + const totalMemory = getMaybeNumber(stats.AnonRss); + + if (totalMemory) { + const otherMemory = Math.max(0, totalMemory - sumNonInfoSegments); + + segments.push({ + label: i18n('text_other'), + key: 'Other', + value: otherMemory, + isInfo: false, + }); + } + + segments.push( + { + label: i18n('text_external-consumption'), + key: 'ExternalConsumption', + value: getMaybeNumber(stats.ExternalConsumption), + isInfo: true, + }, + { + label: i18n('text_usage'), + key: 'Usage', + value: getMaybeNumber(stats.AnonRss), + isInfo: true, + }, + { + label: i18n('text_soft-limit'), + key: 'SoftLimit', + value: getMaybeNumber(stats.SoftLimit), + isInfo: true, + }, + { + label: i18n('text_hard-limit'), + key: 'HardLimit', + value: getMaybeNumber(stats.HardLimit), + isInfo: true, + }, + ); + + return segments.filter((segment) => segment.value !== undefined) as MemorySegment[]; +} diff --git a/src/components/ProgressViewer/ProgressViewer.tsx b/src/components/ProgressViewer/ProgressViewer.tsx index 2176de3cac..d0a45f56dd 100644 --- a/src/components/ProgressViewer/ProgressViewer.tsx +++ b/src/components/ProgressViewer/ProgressViewer.tsx @@ -11,7 +11,7 @@ const b = cn('progress-viewer'); type ProgressViewerSize = 'xs' | 's' | 'ns' | 'm' | 'n' | 'l' | 'head'; -type FormatProgressViewerValues = ( +export type FormatProgressViewerValues = ( value?: number, capacity?: number, ) => (string | number | undefined)[]; diff --git a/src/components/nodesColumns/columns.tsx b/src/components/nodesColumns/columns.tsx index e5a09a7805..aee2c9f906 100644 --- a/src/components/nodesColumns/columns.tsx +++ b/src/components/nodesColumns/columns.tsx @@ -2,7 +2,7 @@ import DataTable from '@gravity-ui/react-data-table'; import {DefinitionList} from '@gravity-ui/uikit'; import {getLoadSeverityForNode} from '../../store/reducers/nodes/utils'; -import type {TPoolStats} from '../../types/api/nodes'; +import type {TMemoryStats, TPoolStats} from '../../types/api/nodes'; import type {TTabletStateInfo} from '../../types/api/tablet'; import {valueIsDefined} from '../../utils'; import {cn} from '../../utils/cn'; @@ -15,6 +15,7 @@ import {getSpaceUsageSeverity} from '../../utils/storage'; import type {Column} from '../../utils/tableUtils/types'; import {isNumeric} from '../../utils/utils'; import {CellWithPopover} from '../CellWithPopover/CellWithPopover'; +import {MemoryViewer} from '../MemoryViewer/MemoryViewer'; import {NodeHostWrapper} from '../NodeHostWrapper/NodeHostWrapper'; import type {NodeHostData} from '../NodeHostWrapper/NodeHostWrapper'; import {PoolsGraph} from '../PoolsGraph/PoolsGraph'; @@ -102,27 +103,6 @@ export function getUptimeColumn width: 110, }; } -export function getMemoryColumn< - T extends {MemoryUsed?: string; MemoryLimit?: string}, ->(): Column { - return { - name: NODES_COLUMNS_IDS.Memory, - header: NODES_COLUMNS_TITLES.Memory, - sortAccessor: ({MemoryUsed = 0}) => Number(MemoryUsed), - defaultOrder: DataTable.DESCENDING, - render: ({row}) => ( - - ), - align: DataTable.LEFT, - width: 170, - resizeMinWidth: 170, - }; -} export function getRAMColumn(): Column { return { @@ -193,6 +173,35 @@ export function getSharedCacheUsageColumn< resizeMinWidth: 170, }; } +export function getMemoryColumn< + T extends {MemoryStats?: TMemoryStats; MemoryUsed?: string; MemoryLimit?: string}, +>(): Column { + return { + name: NODES_COLUMNS_IDS.Memory, + header: NODES_COLUMNS_TITLES.Memory, + defaultOrder: DataTable.DESCENDING, + render: ({row}) => { + return row.MemoryStats ? ( + + ) : ( + + ); + }, + align: DataTable.LEFT, + width: 300, + resizeMinWidth: 170, + }; +} export function getPoolsColumn(): Column { return { name: NODES_COLUMNS_IDS.Pools, diff --git a/src/components/nodesColumns/constants.ts b/src/components/nodesColumns/constants.ts index 887b3fa870..bc9ea2e112 100644 --- a/src/components/nodesColumns/constants.ts +++ b/src/components/nodesColumns/constants.ts @@ -111,7 +111,7 @@ export const NODES_COLUMNS_TO_DATA_FIELDS: Record[], NodesRequiredField[]] { - const memoryColumn = { - ...getMemoryColumn(), - header: i18n('column-header.process'), - }; - const columns = [ getNodeIdColumn(), getHostColumn(params), getUptimeColumn(), getLoadColumn(), - memoryColumn, - getSharedCacheUsageColumn(), + getMemoryColumn(), getSessionsColumn(), getTabletsColumn(params), ]; diff --git a/src/types/api/nodes.ts b/src/types/api/nodes.ts index 285b090b42..d5899d3414 100644 --- a/src/types/api/nodes.ts +++ b/src/types/api/nodes.ts @@ -38,6 +38,24 @@ export interface TNodesGroup { NodeCount: number; } +export interface TMemoryStats { + AnonRss: string; + ExternalConsumption?: string; + AllocatorCachesMemory?: string; + + SharedCacheConsumption?: string; + SharedCacheLimit?: string; + + MemTableConsumption?: string; + MemTableLimit?: string; + + QueryExecutionConsumption?: string; + QueryExecutionLimit?: string; + + HardLimit?: string; + SoftLimit?: string; +} + /** * source: https://github.com/ydb-platform/ydb/blob/main/ydb/core/protos/node_whiteboard.proto */ @@ -80,6 +98,8 @@ export interface TSystemStateInfo { CoresUsed?: number; CoresTotal?: number; + MemoryStats?: TMemoryStats; + /** * int64 * @@ -175,6 +195,7 @@ export type NodesRequiredField = | 'Version' | 'Uptime' | 'Memory' + | 'MemoryDetailed' | 'CPU' | 'LoadAverage' | 'Missing' diff --git a/tests/suites/nodes/nodes.test.ts b/tests/suites/nodes/nodes.test.ts index 90d070e8e1..c8a8756c70 100644 --- a/tests/suites/nodes/nodes.test.ts +++ b/tests/suites/nodes/nodes.test.ts @@ -130,7 +130,7 @@ test.describe('Test Nodes Paginated Table', async () => { expect(rowData).toHaveProperty('Host'); expect(rowData).toHaveProperty('Uptime'); - expect(rowData).toHaveProperty('Memory'); + expect(rowData).toHaveProperty('Detailed Memory'); expect(rowData).toHaveProperty('Pools'); });