From c31d0480a1b91cf01a660fd1d9726c6708f7c252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9C=D1=83=D1=84=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D0=BB=D0=BE=D0=B2?= <67755036+artemmufazalov@users.noreply.github.com> Date: Fri, 1 Dec 2023 17:58:32 +0300 Subject: [PATCH] feat(ClusterInfo): display groups stats (#598) --- .../ProgressViewer/ProgressViewer.tsx | 2 +- src/containers/Cluster/Cluster.tsx | 2 + .../Cluster/ClusterInfo/ClusterInfo.scss | 19 ++- .../Cluster/ClusterInfo/ClusterInfo.tsx | 128 ++++++++++++++---- .../ClusterInfoSkeleton.tsx | 2 +- src/containers/Cluster/i18n/en.json | 16 +++ src/containers/Cluster/i18n/index.ts | 11 ++ src/containers/Cluster/i18n/ru.json | 16 +++ .../parseGroupsStatsQueryResponse.test.ts | 121 +++++++++++++++++ src/store/reducers/cluster/cluster.ts | 48 ++++++- src/store/reducers/cluster/types.ts | 33 ++++- src/store/reducers/cluster/utils.ts | 88 ++++++++++++ src/types/api/cluster.ts | 3 + 13 files changed, 452 insertions(+), 37 deletions(-) create mode 100644 src/containers/Cluster/i18n/en.json create mode 100644 src/containers/Cluster/i18n/index.ts create mode 100644 src/containers/Cluster/i18n/ru.json create mode 100644 src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts create mode 100644 src/store/reducers/cluster/utils.ts diff --git a/src/components/ProgressViewer/ProgressViewer.tsx b/src/components/ProgressViewer/ProgressViewer.tsx index af433e748..f35ebf519 100644 --- a/src/components/ProgressViewer/ProgressViewer.tsx +++ b/src/components/ProgressViewer/ProgressViewer.tsx @@ -44,7 +44,7 @@ Props description: interface ProgressViewerProps { value?: number | string; capacity?: number | string; - formatValues?: (value?: number, capacity?: number) => (string | undefined)[]; + formatValues?: (value?: number, capacity?: number) => (string | number | undefined)[]; percents?: boolean; className?: string; size?: ProgressViewerSize; diff --git a/src/containers/Cluster/Cluster.tsx b/src/containers/Cluster/Cluster.tsx index 41c91e1b8..52e18bad0 100644 --- a/src/containers/Cluster/Cluster.tsx +++ b/src/containers/Cluster/Cluster.tsx @@ -68,6 +68,7 @@ function Cluster({ loading: clusterLoading, wasLoaded: clusterWasLoaded, error: clusterError, + groupsStats, } = useTypedSelector((state) => state.cluster); const { nodes, @@ -135,6 +136,7 @@ function Cluster({
{ + const {diskType, erasure, allocatedSize, availableSize} = stats; + + const sizeToConvert = getSizeWithSignificantDigits(Math.max(allocatedSize, availableSize), 2); + + const convertedAllocatedSize = formatBytes({value: allocatedSize, size: sizeToConvert}); + const convertedAvailableSize = formatBytes({value: availableSize, size: sizeToConvert}); + + const usage = Math.round((allocatedSize / (allocatedSize + availableSize)) * 100); + + const info = [ + { + label: i18n('disk-type'), + value: diskType, + }, + { + label: i18n('erasure'), + value: erasure, + }, + { + label: i18n('allocated'), + value: convertedAllocatedSize, + }, + { + label: i18n('available'), + value: convertedAvailableSize, + }, + { + label: i18n('usage'), + value: usage + '%', + }, + ]; + + return ( + + ); +}; + +interface DiskGroupsStatsProps { + stats: DiskGroupsStats; +} + +const DiskGroupsStatsBars = ({stats}: DiskGroupsStatsProps) => { + return ( +
+ {Object.values(stats).map((erasureStats) => ( + } + > + + + ))} +
+ ); +}; + +const getGroupsStatsFields = (groupsStats: ClusterGroupsStats) => { + return Object.keys(groupsStats).map((diskType) => { + return { + label: i18n('storage-groups', {diskType}), + value: , + }; + }); +}; + const getInfo = ( cluster: TClusterInfo, versionsValues: VersionValue[], + groupsStats: ClusterGroupsStats, additionalInfo: InfoViewerItem[], links: ClusterLink[], ) => { @@ -43,14 +127,14 @@ const getInfo = ( if (cluster.DataCenters) { info.push({ - label: 'DC', + label: i18n('dc'), value: , }); } if (cluster.SystemTablets) { info.push({ - label: 'Tablets', + label: i18n('tablets'), value: (
{cluster.SystemTablets.sort(compareTablets).map((tablet, tabletIndex) => ( @@ -63,46 +147,40 @@ const getInfo = ( if (cluster.Tenants) { info.push({ - label: 'Databases', + label: i18n('databases'), value: cluster.Tenants, }); } info.push( { - label: 'Nodes', - value: ( - - ), + label: i18n('nodes'), + value: , }, { - label: 'Load', - value: ( - - ), + label: i18n('load'), + value: , }, { - label: 'Storage', + label: i18n('storage-size'), value: ( ), }, + ); + + if (Object.keys(groupsStats).length) { + info.push(...getGroupsStatsFields(groupsStats)); + } + + info.push( ...additionalInfo, { - label: 'Links', + label: i18n('links'), value: (
{links.map(({title, url}) => ( @@ -112,7 +190,7 @@ const getInfo = ( ), }, { - label: 'Versions', + label: i18n('versions'), value: , }, ); @@ -123,6 +201,7 @@ const getInfo = ( interface ClusterInfoProps { cluster?: TClusterInfo; versionsValues?: VersionValue[]; + groupsStats?: ClusterGroupsStats; loading?: boolean; error?: IResponseError; additionalClusterProps?: AdditionalClusterProps; @@ -131,6 +210,7 @@ interface ClusterInfoProps { export const ClusterInfo = ({ cluster = {}, versionsValues = [], + groupsStats = {}, loading, error, additionalClusterProps = {}, @@ -151,7 +231,7 @@ export const ClusterInfo = ({ const {info = [], links = []} = additionalClusterProps; - const clusterInfo = getInfo(cluster, versionsValues, info, [ + const clusterInfo = getInfo(cluster, versionsValues, groupsStats, info, [ {title: DEVELOPER_UI_TITLE, url: internalLink}, ...links, ]); diff --git a/src/containers/Cluster/ClusterInfoSkeleton/ClusterInfoSkeleton.tsx b/src/containers/Cluster/ClusterInfoSkeleton/ClusterInfoSkeleton.tsx index c32c03462..3e5c28fdd 100644 --- a/src/containers/Cluster/ClusterInfoSkeleton/ClusterInfoSkeleton.tsx +++ b/src/containers/Cluster/ClusterInfoSkeleton/ClusterInfoSkeleton.tsx @@ -18,7 +18,7 @@ interface ClusterInfoSkeletonProps { rows?: number; } -export const ClusterInfoSkeleton = ({rows = 7, className}: ClusterInfoSkeletonProps) => ( +export const ClusterInfoSkeleton = ({rows = 8, className}: ClusterInfoSkeletonProps) => (
{[...new Array(rows)].map((_, index) => (
diff --git a/src/containers/Cluster/i18n/en.json b/src/containers/Cluster/i18n/en.json new file mode 100644 index 000000000..6ec02cd40 --- /dev/null +++ b/src/containers/Cluster/i18n/en.json @@ -0,0 +1,16 @@ +{ + "disk-type": "Disk Type", + "erasure": "Erasure", + "allocated": "Allocated", + "available": "Available", + "usage": "Usage", + "dc": "DC", + "tablets": "Tablets", + "databases": "Databases", + "nodes": "Nodes", + "load": "Load", + "storage-size": "Storage size", + "storage-groups": "Storage groups, {{diskType}}", + "links": "Links", + "versions": "Versions" +} diff --git a/src/containers/Cluster/i18n/index.ts b/src/containers/Cluster/i18n/index.ts new file mode 100644 index 000000000..7f1d6b5ae --- /dev/null +++ b/src/containers/Cluster/i18n/index.ts @@ -0,0 +1,11 @@ +import {i18n, Lang} from '../../../utils/i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'ydb-cluster'; + +i18n.registerKeyset(Lang.En, COMPONENT, en); +i18n.registerKeyset(Lang.Ru, COMPONENT, ru); + +export default i18n.keyset(COMPONENT); diff --git a/src/containers/Cluster/i18n/ru.json b/src/containers/Cluster/i18n/ru.json new file mode 100644 index 000000000..8e75e63ad --- /dev/null +++ b/src/containers/Cluster/i18n/ru.json @@ -0,0 +1,16 @@ +{ + "disk-type": "Тип диска", + "erasure": "Режим", + "allocated": "Использовано", + "available": "Доступно", + "usage": "Потребление", + "dc": "ДЦ", + "tablets": "Таблетки", + "databases": "Базы данных", + "nodes": "Узлы", + "load": "Нагрузка", + "storage-size": "Размер хранилища", + "storage-groups": "Группы хранения, {{diskType}}", + "links": "Ссылки", + "versions": "Версии" +} diff --git a/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts b/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts new file mode 100644 index 000000000..f2c8b18f1 --- /dev/null +++ b/src/store/reducers/cluster/__test__/parseGroupsStatsQueryResponse.test.ts @@ -0,0 +1,121 @@ +import {parseGroupsStatsQueryResponse} from '../utils'; + +describe('parseGroupsStatsQueryResponse', () => { + const columns = [ + { + name: 'PDiskFilter', + type: 'Utf8?', + }, + { + name: 'ErasureSpecies', + type: 'Utf8?', + }, + { + name: 'CurrentAvailableSize', + type: 'Uint64?', + }, + { + name: 'CurrentAllocatedSize', + type: 'Uint64?', + }, + { + name: 'CurrentGroupsCreated', + type: 'Uint32?', + }, + { + name: 'AvailableGroupsToCreate', + type: 'Uint32?', + }, + ]; + + // 2 disk types and 2 erasure types + const dataSet1 = { + columns, + result: [ + ['Type:SSD', 'block-4-2', '1000', '2000', 100, 50], + ['Type:ROT', 'block-4-2', '2000', '1000', 50, 0], + ['Type:ROT', 'mirror-3of4', '1000', '0', 15, 0], + ['Type:SSD', 'mirror-3of4', '1000', '0', 5, 50], + ['Type:ROT', 'mirror-3-dc', null, null, null, 0], + ['Type:SSD', 'mirror-3-dc', null, null, null, 0], + ], + }; + + // 2 disk types and 1 erasure types, but with additional disks params + const dataSet2 = { + columns, + result: [ + ['Type:ROT,SharedWithOs:0,ReadCentric:0,Kind:0', 'mirror-3-dc', '1000', '500', 16, 16], + ['Type:ROT,SharedWithOs:1,ReadCentric:0,Kind:0', 'mirror-3-dc', '2000', '1000', 8, 24], + ['Type:SSD', 'mirror-3-dc', '3000', '400', 2, 10], + ['Type:ROT', 'mirror-3-dc', null, null, null, 32], + ['Type:ROT', 'block-4-2', null, null, null, 20], + ['Type:SSD', 'block-4-2', null, null, null, 0], + ], + }; + const parsedDataSet1 = { + SSD: { + 'block-4-2': { + diskType: 'SSD', + erasure: 'block-4-2', + createdGroups: 100, + totalGroups: 150, + allocatedSize: 2000, + availableSize: 1000, + }, + 'mirror-3of4': { + diskType: 'SSD', + erasure: 'mirror-3of4', + createdGroups: 5, + totalGroups: 55, + allocatedSize: 0, + availableSize: 1000, + }, + }, + HDD: { + 'block-4-2': { + diskType: 'HDD', + erasure: 'block-4-2', + createdGroups: 50, + totalGroups: 50, + allocatedSize: 1000, + availableSize: 2000, + }, + 'mirror-3of4': { + diskType: 'HDD', + erasure: 'mirror-3of4', + createdGroups: 15, + totalGroups: 15, + allocatedSize: 0, + availableSize: 1000, + }, + }, + }; + + const parsedDataSet2 = { + HDD: { + 'mirror-3-dc': { + diskType: 'HDD', + erasure: 'mirror-3-dc', + createdGroups: 24, + totalGroups: 64, + allocatedSize: 1500, + availableSize: 3000, + }, + }, + SSD: { + 'mirror-3-dc': { + diskType: 'SSD', + erasure: 'mirror-3-dc', + createdGroups: 2, + totalGroups: 12, + allocatedSize: 400, + availableSize: 3000, + }, + }, + }; + it('should correctly parse data', () => { + expect(parseGroupsStatsQueryResponse(dataSet1)).toEqual(parsedDataSet1); + expect(parseGroupsStatsQueryResponse(dataSet2)).toEqual(parsedDataSet2); + }); +}); diff --git a/src/store/reducers/cluster/cluster.ts b/src/store/reducers/cluster/cluster.ts index 5f0e86f6a..60a5c4f57 100644 --- a/src/store/reducers/cluster/cluster.ts +++ b/src/store/reducers/cluster/cluster.ts @@ -3,6 +3,7 @@ import type {Reducer} from 'redux'; import '../../../services/api'; import {createRequestActionTypes, createApiRequest} from '../../utils'; import type {ClusterAction, ClusterState} from './types'; +import {createSelectClusterGroupsQuery, parseGroupsStatsQueryResponse} from './utils'; export const FETCH_CLUSTER = createRequestActionTypes('cluster', 'FETCH_CLUSTER'); @@ -17,9 +18,12 @@ const cluster: Reducer = (state = initialState, act }; } case FETCH_CLUSTER.SUCCESS: { + const {clusterData, groupsStats} = action.data; + return { ...state, - data: action.data, + data: clusterData, + groupsStats, loading: false, wasLoaded: true, error: undefined, @@ -42,8 +46,48 @@ const cluster: Reducer = (state = initialState, act }; export function getClusterInfo(clusterName?: string) { + async function requestClusterData() { + // Error here is handled by createApiRequest + const clusterData = await window.api.getClusterInfo(clusterName); + + try { + const clusterRoot = clusterData.Domain; + + // Without domain we cannot get stats from system tables + if (!clusterRoot) { + return { + clusterData, + }; + } + + const query = createSelectClusterGroupsQuery(clusterRoot); + + // Normally query request should be fulfilled within 300-400ms even on very big clusters + // Table with stats is supposed to be very small (less than 10 rows) + // So we batch this request with cluster request to prevent possible layout shifts, if data is missing + const groupsStatsResponse = await window.api.sendQuery({ + schema: 'modern', + query: query, + database: clusterRoot, + action: 'execute-scan', + }); + + return { + clusterData, + groupsStats: parseGroupsStatsQueryResponse(groupsStatsResponse), + }; + } catch { + // Doesn't return groups stats on error + // It could happen if user doesn't have access rights + // Or there are no system tables in cluster root + return { + clusterData, + }; + } + } + return createApiRequest({ - request: window.api.getClusterInfo(clusterName), + request: requestClusterData(), actions: FETCH_CLUSTER, }); } diff --git a/src/store/reducers/cluster/types.ts b/src/store/reducers/cluster/types.ts index f71d550e5..9f1ad4428 100644 --- a/src/store/reducers/cluster/types.ts +++ b/src/store/reducers/cluster/types.ts @@ -1,14 +1,39 @@ -import {FETCH_CLUSTER} from './cluster'; - import type {TClusterInfo} from '../../../types/api/cluster'; -import type {ApiRequestAction} from '../../utils'; import type {IResponseError} from '../../../types/api/error'; +import type {ApiRequestAction} from '../../utils'; + +import {FETCH_CLUSTER} from './cluster'; + +export interface DiskErasureGroupsStats { + diskType: string; + erasure: string; + createdGroups: number; + totalGroups: number; + allocatedSize: number; + availableSize: number; +} + +/** Keys - erasure types */ +export type DiskGroupsStats = Record; + +/** Keys - PDisks types */ +export type ClusterGroupsStats = Record; export interface ClusterState { loading: boolean; wasLoaded: boolean; data?: TClusterInfo; error?: IResponseError; + groupsStats?: ClusterGroupsStats; +} + +export interface HandledClusterResponse { + clusterData: TClusterInfo; + groupsStats: ClusterGroupsStats; } -export type ClusterAction = ApiRequestAction; +export type ClusterAction = ApiRequestAction< + typeof FETCH_CLUSTER, + HandledClusterResponse, + IResponseError +>; diff --git a/src/store/reducers/cluster/utils.ts b/src/store/reducers/cluster/utils.ts new file mode 100644 index 000000000..16c3f7d3f --- /dev/null +++ b/src/store/reducers/cluster/utils.ts @@ -0,0 +1,88 @@ +import type {ExecuteQueryResponse} from '../../../types/api/query'; +import {parseQueryAPIExecuteResponse} from '../../../utils/query'; + +import type {ClusterGroupsStats} from './types'; + +export const createSelectClusterGroupsQuery = (clusterRoot: string) => { + return ` +SELECT + PDiskFilter, + ErasureSpecies, + CurrentAvailableSize, + CurrentAllocatedSize, + CurrentGroupsCreated, + AvailableGroupsToCreate + FROM \`${clusterRoot}/.sys/ds_storage_stats\` + ORDER BY CurrentGroupsCreated DESC; +`; +}; + +const getDiskType = (rawTypeString: string) => { + // Check if value math regexp and put disk type in type group + const diskTypeRe = /^Type:(?[A-Za-z]+)/; + + const diskType = rawTypeString.match(diskTypeRe)?.groups?.['type']; + + if (diskType === 'ROT') { + // Display ROT as HDD + return 'HDD'; + } + + return diskType; +}; + +export const parseGroupsStatsQueryResponse = ( + data: ExecuteQueryResponse<'modern'>, +): ClusterGroupsStats => { + const parsedData = parseQueryAPIExecuteResponse(data).result; + const result: ClusterGroupsStats = {}; + + parsedData?.forEach((stats) => { + const { + PDiskFilter, + ErasureSpecies: erasure, + CurrentAvailableSize, + CurrentAllocatedSize, + CurrentGroupsCreated, + AvailableGroupsToCreate, + } = stats; + + const createdGroups = Number(CurrentGroupsCreated) || 0; + const availableGroupsToCreate = Number(AvailableGroupsToCreate) || 0; + const totalGroups = createdGroups + availableGroupsToCreate; + const allocatedSize = Number(CurrentAllocatedSize) || 0; + const availableSize = Number(CurrentAvailableSize) || 0; + const diskType = PDiskFilter && typeof PDiskFilter === 'string' && getDiskType(PDiskFilter); + + if (diskType && erasure && typeof erasure === 'string' && createdGroups) { + const preparedStats = { + diskType, + erasure, + createdGroups, + totalGroups, + allocatedSize, + availableSize, + }; + + if (result[diskType]) { + if (result[diskType][erasure]) { + const currentValue = {...result[diskType][erasure]}; + result[diskType][erasure] = { + diskType, + erasure, + createdGroups: currentValue.createdGroups + createdGroups, + totalGroups: currentValue.totalGroups + totalGroups, + allocatedSize: currentValue.allocatedSize + allocatedSize, + availableSize: currentValue.availableSize + availableSize, + }; + } else { + result[diskType][erasure] = preparedStats; + } + } else { + result[diskType] = {[erasure]: preparedStats}; + } + } + }); + + return result; +}; diff --git a/src/types/api/cluster.ts b/src/types/api/cluster.ts index 97370ea65..94cd1febe 100644 --- a/src/types/api/cluster.ts +++ b/src/types/api/cluster.ts @@ -32,6 +32,9 @@ export interface TClusterInfo { /** uint64 */ Tablets?: string; + /** Cluster root database */ + Domain?: string; + Balancer?: string; // additional Solomon?: string; // additional }