From 43f5034405fcf58bd045406551a97f52e7a4a3ed Mon Sep 17 00:00:00 2001 From: Viktor Rozaev Date: Wed, 15 May 2024 16:31:59 +0000 Subject: [PATCH] feat(System): introduce new switch leader button --- packages/ui/src/shared/constants/yt-api-id.ts | 1 + .../ui/components/Icon/importGravityIcons.ts | 3 +- .../ui/src/ui/constants/accounts/accounts.ts | 3 +- .../SwitchLeaderShortInfo.scss | 13 ++ .../SwitchLeaderShortInfo.tsx | 89 +++++++++++ .../ui/pages/system/Masters/MasterGroup.js | 20 ++- .../ui/pages/system/Masters/SwitchLeader.tsx | 147 ++++++++++++++++++ packages/ui/src/ui/rum/rum-wrap-api.ts | 4 + .../ui/src/ui/store/actions/system/masters.ts | 29 ++++ .../src/ui/store/reducers/system/masters.ts | 6 + 10 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.scss create mode 100644 packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.tsx create mode 100644 packages/ui/src/ui/pages/system/Masters/SwitchLeader.tsx diff --git a/packages/ui/src/shared/constants/yt-api-id.ts b/packages/ui/src/shared/constants/yt-api-id.ts index 3a5ce4d34..67c39463f 100644 --- a/packages/ui/src/shared/constants/yt-api-id.ts +++ b/packages/ui/src/shared/constants/yt-api-id.ts @@ -166,4 +166,5 @@ export enum YTApiId { removeMaintenance, maintenanceRequests, getQueryTrackerInfo, + switchLeader, } diff --git a/packages/ui/src/ui/components/Icon/importGravityIcons.ts b/packages/ui/src/ui/components/Icon/importGravityIcons.ts index 5676028e1..264686d0c 100644 --- a/packages/ui/src/ui/components/Icon/importGravityIcons.ts +++ b/packages/ui/src/ui/components/Icon/importGravityIcons.ts @@ -4,7 +4,7 @@ import CircleXmark from '@gravity-ui/icons/svgs/circle-xmark.svg'; import FolderArrowDown from '@gravity-ui/icons/svgs/folder-arrow-down.svg'; import FolderOpen from '@gravity-ui/icons/svgs/folder-open.svg'; import LayoutSideContent from '@gravity-ui/icons/svgs/layout-side-content.svg'; -import {AbbrSql} from '@gravity-ui/icons'; +import {AbbrSql, CrownDiamond} from '@gravity-ui/icons'; export const iconNames = { ['lock']: Lock, @@ -14,4 +14,5 @@ export const iconNames = { ['folder-open']: FolderOpen, ['layout-side-content']: LayoutSideContent, ['sql']: AbbrSql, + ['crowndiamond']: CrownDiamond, }; diff --git a/packages/ui/src/ui/constants/accounts/accounts.ts b/packages/ui/src/ui/constants/accounts/accounts.ts index c77b0291c..b1913829d 100644 --- a/packages/ui/src/ui/constants/accounts/accounts.ts +++ b/packages/ui/src/ui/constants/accounts/accounts.ts @@ -1,5 +1,4 @@ -import {createPrefix} from './../utils'; -import createActionTypes from '../../constants/utils'; +import createActionTypes, {createPrefix} from './../utils'; import type {ValueOf} from '../../types'; const ACCOUNTS_PREFIX = createPrefix('ACCOUNTS'); diff --git a/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.scss b/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.scss new file mode 100644 index 000000000..bdfe509dd --- /dev/null +++ b/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.scss @@ -0,0 +1,13 @@ +.switch-leader-short-info { + &__state { + text-transform: capitalize; + + &_state_complete { + color: var(--success-color); + } + } + + &__value { + text-transform: capitalize; + } +} diff --git a/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.tsx b/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.tsx new file mode 100644 index 000000000..81883a5d5 --- /dev/null +++ b/packages/ui/src/ui/pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo.tsx @@ -0,0 +1,89 @@ +import React, {useEffect, useRef, useState} from 'react'; +import cn from 'bem-cn-lite'; + +import MetaTable from '../../../components/MetaTable/MetaTable'; +// @ts-ignore +import format from '../../../common/hammer/format'; +import {getStateForHost, loadMasters} from '../../../store/actions/system/masters'; +import {useDispatch} from 'react-redux'; +import moment from 'moment'; +import './SwitchLeaderShortInfo.scss'; + +const block = cn('switch-leader-short-info'); + +interface Props { + newLeaderAddress: string; +} + +export function SwitchLeaderShortInfo(props: Props) { + const startTime = useRef(moment.now()); + const [currentTime, setCurrentTime] = useState(moment()); + const [finishTime, setFinishTime] = useState(); + const dispatch = useDispatch(); + + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentTime(moment()); + + if (finishTime) { + clearInterval(intervalId); + } + }, 1 * 1000); + + return () => { + clearInterval(intervalId); + }; + }, []); + + useEffect(() => { + let stillMounted = true; + + const waitForState = async () => { + try { + const hostState = await getStateForHost(props.newLeaderAddress); + + if (hostState === 'leading') { + setFinishTime(moment()); + dispatch(loadMasters()); + } + } catch { + if (stillMounted) { + waitForState(); + } + } + }; + + waitForState(); + + return () => { + stillMounted = false; + }; + }, [props.newLeaderAddress]); + + return ( +
+ + ), + }, + ]} + /> +
+ ); +} + +function SwitchLeaderShortInfoStatus({state}: {state: string}) { + return {state}; +} diff --git a/packages/ui/src/ui/pages/system/Masters/MasterGroup.js b/packages/ui/src/ui/pages/system/Masters/MasterGroup.js index c3d55c673..a51c91fe8 100644 --- a/packages/ui/src/ui/pages/system/Masters/MasterGroup.js +++ b/packages/ui/src/ui/pages/system/Masters/MasterGroup.js @@ -1,7 +1,6 @@ import React, {Component, Fragment} from 'react'; import {connect} from 'react-redux'; import PropTypes from 'prop-types'; -import _ from 'lodash'; import block from 'bem-cn-lite'; import {Text} from '@gravity-ui/uikit'; @@ -14,6 +13,8 @@ import ypath from '../../../common/thor/ypath'; import Icon from '../../../components/Icon/Icon'; import {Tooltip} from '../../../components/Tooltip/Tooltip'; import NodeQuad from '../NodeQuad/NodeQuad'; +import {SwitchLeaderButton} from './SwitchLeader'; +import _ from 'lodash'; import './MasterGroup.scss'; @@ -134,7 +135,7 @@ class MasterGroup extends Component { }; renderQuorum() { - const {quorum, cellTag} = this.props; + const {quorum, cellTag, cellId, instances} = this.props; const status = quorum ? quorum.status : 'unknown'; const quorumTitle = quorum && `Leader committed version: ${quorum.leaderCommitedVersion}`; const cellTitle = `Cell tag: ${cellTag}`; @@ -150,6 +151,14 @@ class MasterGroup extends Component { 'no-quorum': 'missing', unknown: 'unknown', }; + let leadingHost = ''; + const hosts = instances.map(({$address, state}) => { + if (state === 'leading') { + leadingHost = $address; + } + + return $address; + }); return ( @@ -181,6 +190,13 @@ class MasterGroup extends Component {
{cellTag && } {hammer.format['Hex'](cellTag)} + {cellId && ( + + )}
diff --git a/packages/ui/src/ui/pages/system/Masters/SwitchLeader.tsx b/packages/ui/src/ui/pages/system/Masters/SwitchLeader.tsx new file mode 100644 index 000000000..994aea147 --- /dev/null +++ b/packages/ui/src/ui/pages/system/Masters/SwitchLeader.tsx @@ -0,0 +1,147 @@ +import React, {useState} from 'react'; +import Icon from '../../../components/Icon/Icon'; +import Button from '../../../components/Button/Button'; +import {YTApiId, ytApiV4Id} from '../../../rum/rum-wrap-api'; +import {wrapApiPromiseByToaster} from '../../../utils/utils'; +import {YTDFDialog, makeErrorFields} from '../../../components/Dialog/Dialog'; +import {SwitchLeaderShortInfo} from '../../../pages/components/SwitchLeaderShortInfo/SwitchLeaderShortInfo'; +import {AppStoreProvider} from '../../../containers/App/AppStoreProvider'; + +type SwitchLeaderDialogProps = { + cancel: () => void; + confirm: (newLeader: string) => Promise; + visible: boolean; + cellId: string; + hosts: string[]; + leadingHost: string; +}; + +type FormValues = { + leading_primary_master: string[]; +}; + +const SwitchLeaderDialog = (props: SwitchLeaderDialogProps) => { + const [error, setError] = useState(undefined); + + const selectLeadingHostOptions = props.hosts.map((host) => { + return { + value: host, + content: host, + }; + }); + + return ( + + visible={props.visible} + headerProps={{ + title: `Switch leader for ${props.cellId}`, + }} + initialValues={{ + leading_primary_master: [props.leadingHost], + }} + fields={[ + { + type: 'select', + caption: ' Leading primary master', + name: 'leading_primary_master', + required: true, + extras: { + options: selectLeadingHostOptions, + placeholder: 'New leading primary master', + width: 'max', + filterable: true, + }, + }, + ...makeErrorFields([error]), + ]} + footerProps={{ + textApply: 'Switch leader', + }} + onAdd={(form) => { + const {leading_primary_master} = form.getState().values; + + return props + .confirm(leading_primary_master[0]) + .then(() => { + setError(undefined); + }) + .catch((e) => { + setError(e); + throw e; + }); + }} + onClose={props.cancel} + pristineSubmittable={true} + /> + ); +}; + +type SwitchLeaderButtonProps = { + className: string; + cellId: string; + hosts: string[]; + leadingHost: string; +}; + +export const SwitchLeaderButton = ({ + cellId, + hosts, + leadingHost, + className, +}: SwitchLeaderButtonProps) => { + const [visible, setVisible] = useState(false); + + const handleClick = () => { + setVisible(true); + }; + + const handleConfirm = async (newLeader: string) => { + const switchLeader = () => { + return ytApiV4Id.switchLeader(YTApiId.switchLeader, { + cell_id: cellId, + new_leader_address: newLeader, + }); + }; + + wrapApiPromiseByToaster(switchLeader(), { + toasterName: 'switch-leader', + successContent() { + return ( + + + + ); + }, + successTitle: 'Leader switch initiated', + autoHide: false, + }); + + setVisible(false); + }; + + const handleCancel = () => { + setVisible(false); + }; + + return ( + + + + + ); +}; diff --git a/packages/ui/src/ui/rum/rum-wrap-api.ts b/packages/ui/src/ui/rum/rum-wrap-api.ts index b2c6c9441..58719e145 100644 --- a/packages/ui/src/ui/rum/rum-wrap-api.ts +++ b/packages/ui/src/ui/rum/rum-wrap-api.ts @@ -81,6 +81,10 @@ type YTApiV4WithId = { access_control_objects: string[]; supported_features: {access_control: boolean}; }>; + switchLeader( + id: YTApiId, + ...args: ApiMethodParameters<{cell_id: string; new_leader_address: string}> + ): Promise; [method: string]: (id: YTApiId, ...args: ApiMethodParameters) => Promise; }; diff --git a/packages/ui/src/ui/store/actions/system/masters.ts b/packages/ui/src/ui/store/actions/system/masters.ts index d23c9ee7b..55fc37fc3 100644 --- a/packages/ui/src/ui/store/actions/system/masters.ts +++ b/packages/ui/src/ui/store/actions/system/masters.ts @@ -150,6 +150,7 @@ async function loadMastersConfig(): Promise<[MastersConfigResponse, MasterAlert[ }; }, ), + cellId: ypath.getValue(timestampProviderCellTag.output)?.cell_id, cellTag: getCellIdTag(ypath.getValue(timestampProviderCellTag.output)?.cell_id), }; @@ -157,12 +158,14 @@ async function loadMastersConfig(): Promise<[MastersConfigResponse, MasterAlert[ primaryMaster: { addresses: _.map(_.keys(primaryMaster), (address) => { const value = primaryMaster[address]; + return { host: address, physicalHost: ypath.getValue(value, '/@annotations/physical_host'), attributes: ypath.getValue(value, '/@'), }; }), + cellId: primaryMasterCellTag.output, cellTag: getCellIdTag(primaryMasterCellTag.output), }, secondaryMasters: _.map(secondaryMasters, (addresses, cellTag) => { @@ -306,6 +309,32 @@ function loadHydra( ); } +export const getStateForHost = async ( + host: string, +): Promise<'leading' | 'following' | undefined> => { + const cypressPath = '//sys/primary_masters'; + const hydraPath = '/orchid/monitoring/hydra'; + + const masterDataRequests: BatchSubRequest[] = [ + { + command: 'get' as const, + parameters: { + path: cypressPath + '/' + host + hydraPath, + ...USE_SUPRESS_SYNC, + }, + }, + ]; + + const [result] = await ytApiV3Id.executeBatch<{state: 'leading' | 'following'}>( + YTApiId.systemMasters, + { + requests: masterDataRequests, + }, + ); + + return result.output?.state; +}; + export function loadMasters() { return async (dispatch: Dispatch): Promise => { dispatch({type: FETCH_MASTER_CONFIG.REQUEST}); diff --git a/packages/ui/src/ui/store/reducers/system/masters.ts b/packages/ui/src/ui/store/reducers/system/masters.ts index f8e9c449f..4a76dec5b 100644 --- a/packages/ui/src/ui/store/reducers/system/masters.ts +++ b/packages/ui/src/ui/store/reducers/system/masters.ts @@ -164,6 +164,7 @@ export interface MasterGroupData { leaderCommitedVersion?: string; }; cellTag?: number; + cellId?: string; } const initialState: MastersState = { @@ -181,6 +182,7 @@ const initialState: MastersState = { leader: undefined, quorum: undefined, cellTag: undefined, + cellId: undefined, }, secondary: [], providers: { @@ -212,6 +214,7 @@ export interface ResponseItem { export interface ResponseItemsGroup { addresses?: Array; cellTag?: number; + cellId?: string; } export interface MastersConfigResponse { @@ -238,6 +241,7 @@ function processMastersConfig( return new MasterInstance(address, 'primary', primaryMaster.cellTag); }), cellTag: primaryMaster.cellTag, + cellId: primaryMaster.cellId, }, secondary: _.map(secondaryMasters, (master) => { return { @@ -311,6 +315,7 @@ function processMastersData( const primary: Required = { instances: _.orderBy(primaryInstances, (instance) => instance.$address), cellTag: state.primary.cellTag!, + cellId: state.primary.cellId!, quorum: getQuorum(primaryInstances), leader: getLeader(primaryInstances), }; @@ -324,6 +329,7 @@ function processMastersData( const res: Required = { instances: _.orderBy(instances, (instance) => instance.$address), cellTag: master.cellTag!, + cellId: master.cellId!, quorum: getQuorum(instances), leader: getLeader(instances), };