diff --git a/public/app/percona/inventory/Inventory.constants.ts b/public/app/percona/inventory/Inventory.constants.ts index f17755c294f47..d7fe66068defb 100644 --- a/public/app/percona/inventory/Inventory.constants.ts +++ b/public/app/percona/inventory/Inventory.constants.ts @@ -28,5 +28,6 @@ export const inventoryTypes = { export const GET_SERVICES_CANCEL_TOKEN = 'getServices'; export const GET_NODES_CANCEL_TOKEN = 'getNodes'; +export const GET_HIGH_AVAILABILITY_NODES_CANCEL_TOKEN = 'getHighAvailabilityNodes'; export const GET_AGENTS_CANCEL_TOKEN = 'getAgents'; export const CLUSTERS_SWITCH_KEY = 'pmm-organize-by-clusters'; diff --git a/public/app/percona/inventory/Inventory.messages.ts b/public/app/percona/inventory/Inventory.messages.ts index 47f413c79c8b0..c60d615e16734 100644 --- a/public/app/percona/inventory/Inventory.messages.ts +++ b/public/app/percona/inventory/Inventory.messages.ts @@ -70,6 +70,7 @@ export const Messages = { nodeType: 'Node Type', address: 'Address', services: 'Services', + isPmmServerNode: 'List PMM Server only nodes', }, deleteConfirmation: (nrItems: number) => `Are you sure that you want to permanently delete ${nrItems} node${nrItems ? 's' : ''}`, diff --git a/public/app/percona/inventory/Inventory.types.ts b/public/app/percona/inventory/Inventory.types.ts index 51f4a8b0d8631..eff92f4a7eaba 100644 --- a/public/app/percona/inventory/Inventory.types.ts +++ b/public/app/percona/inventory/Inventory.types.ts @@ -140,6 +140,7 @@ export interface Node { services?: ServiceNodeList[]; properties?: Record; agentsStatus?: string; + isPmmServerNode: boolean; } export interface NodeDB { @@ -160,6 +161,7 @@ export interface NodeDB { updated_at: string; status: ServiceStatus; services?: ServiceNodeListDB[]; + is_pmm_server_node: boolean; } export interface NodeListDBPayload { diff --git a/public/app/percona/inventory/Tabs/Nodes.tsx b/public/app/percona/inventory/Tabs/Nodes.tsx index 13b99dc65a604..4ec9f3b3d4710 100644 --- a/public/app/percona/inventory/Tabs/Nodes.tsx +++ b/public/app/percona/inventory/Tabs/Nodes.tsx @@ -4,7 +4,7 @@ import { Form } from 'react-final-form'; import { Row } from 'react-table'; import { AppEvents } from '@grafana/data'; -import { Badge, Button, HorizontalGroup, Icon, Link, Modal, TagList, useStyles2 } from '@grafana/ui'; +import { Badge, Button, HorizontalGroup, Icon, Link, Modal, Stack, TagList, useStyles2 } from '@grafana/ui'; import { CheckboxField } from 'app/percona/shared/components/Elements/Checkbox'; import { DetailsRow } from 'app/percona/shared/components/Elements/DetailsRow/DetailsRow'; import { FeatureLoader } from 'app/percona/shared/components/Elements/FeatureLoader'; @@ -14,9 +14,10 @@ import { FormElement } from 'app/percona/shared/components/Form'; import { TabbedPage, TabbedPageContents } from 'app/percona/shared/components/TabbedPage'; import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.hook'; import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel'; +import { fetchHighAvailabilityNodes } from 'app/percona/shared/core/reducers/highAvailability/highAvailability'; import { nodeFromDbMapper, RemoveNodeParams } from 'app/percona/shared/core/reducers/nodes'; import { fetchNodesAction, removeNodesAction } from 'app/percona/shared/core/reducers/nodes/nodes'; -import { getNodes } from 'app/percona/shared/core/selectors'; +import { getHighAvailability, getNodes } from 'app/percona/shared/core/selectors'; import { isApiCancelError } from 'app/percona/shared/helpers/api'; import { getExpandAndActionsCol } from 'app/percona/shared/helpers/getExpandAndActionsCol'; import { logger } from 'app/percona/shared/helpers/logger'; @@ -26,13 +27,18 @@ import { useAppDispatch } from 'app/store/store'; import { useSelector } from 'app/types'; import { appEvents } from '../../../core/app_events'; -import { CLUSTERS_SWITCH_KEY, GET_NODES_CANCEL_TOKEN } from '../Inventory.constants'; +import { + CLUSTERS_SWITCH_KEY, + GET_HIGH_AVAILABILITY_NODES_CANCEL_TOKEN, + GET_NODES_CANCEL_TOKEN, +} from '../Inventory.constants'; import { Messages } from '../Inventory.messages'; import { FlattenNode, MonitoringStatus, Node } from '../Inventory.types'; import { StatusBadge } from '../components/StatusBadge/StatusBadge'; import { StatusLink } from '../components/StatusLink/StatusLink'; -import { getServiceLink } from './Nodes.utils'; +import { InventoryNode } from './Nodes.types'; +import { getHaRoleBadgeText, getServiceLink, mapNodesToInventoryNodes } from './Nodes.utils'; import { getBadgeColorForServiceStatus, getBadgeIconForServiceStatus, @@ -50,10 +56,14 @@ export const NodesTab = () => { const [generateToken] = useCancelToken(); const styles = useStyles2(getStyles); const dispatch = useAppDispatch(); + const { nodes: highAvailabilityNodes, isEnabled: isHighAvailabilityEnabled } = useSelector(getHighAvailability); const mappedNodes = useMemo( - () => nodeFromDbMapper(nodes).sort((a, b) => a.nodeName.localeCompare(b.nodeName)), - [nodes] + () => + mapNodesToInventoryNodes(nodeFromDbMapper(nodes), highAvailabilityNodes).sort((a, b) => + a.nodeName.localeCompare(b.nodeName) + ), + [nodes, highAvailabilityNodes] ); const getActions = useCallback( @@ -80,7 +90,7 @@ export const NodesTab = () => { }, []); const columns = useMemo( - (): Array> => [ + (): Array> => [ { Header: Messages.services.columns.nodeId, id: 'nodeId', @@ -102,6 +112,15 @@ export const NodesTab = () => { { Header: Messages.nodes.columns.nodeName, accessor: 'nodeName', + Cell: ({ value, row }) => + isHighAvailabilityEnabled ? ( + + {value} + {row.original.haRole && } + + ) : ( + value + ), type: FilterFieldTypes.TEXT, }, { @@ -178,14 +197,30 @@ export const NodesTab = () => { return
{Messages.nodes.servicesCount(value.length)}
; }, }, + ...((isHighAvailabilityEnabled + ? [ + { + Header: Messages.nodes.columns.isPmmServerNode, + id: 'isPmmServerNode', + accessor: 'isPmmServerNode', + hidden: true, + type: FilterFieldTypes.BOOLEAN, + }, + ] + : []) as Array>), getExpandAndActionsCol(getActions), ], - [styles, getActions, clearClusterToggle] + [styles, getActions, clearClusterToggle, isHighAvailabilityEnabled] ); const loadData = useCallback(async () => { try { - await dispatch(fetchNodesAction({ token: generateToken(GET_NODES_CANCEL_TOKEN) })).unwrap(); + await Promise.all([ + dispatch( + fetchHighAvailabilityNodes({ token: generateToken(GET_HIGH_AVAILABILITY_NODES_CANCEL_TOKEN) }) + ).unwrap(), + dispatch(fetchNodesAction({ token: generateToken(GET_NODES_CANCEL_TOKEN) })).unwrap(), + ]); } catch (e) { if (isApiCancelError(e)) { return; diff --git a/public/app/percona/inventory/Tabs/Nodes.types.ts b/public/app/percona/inventory/Tabs/Nodes.types.ts new file mode 100644 index 0000000000000..ee6de72dc8933 --- /dev/null +++ b/public/app/percona/inventory/Tabs/Nodes.types.ts @@ -0,0 +1,7 @@ +import { Node } from 'app/percona/inventory/Inventory.types'; +import { NodeRole, NodeStatus } from 'app/percona/shared/services/highAvailability/HighAvailability.types'; + +export interface InventoryNode extends Node { + haRole?: NodeRole; + haStatus?: NodeStatus; +} diff --git a/public/app/percona/inventory/Tabs/Nodes.utils.ts b/public/app/percona/inventory/Tabs/Nodes.utils.ts index f5aa1ef07a668..97b8d6a8e30da 100644 --- a/public/app/percona/inventory/Tabs/Nodes.utils.ts +++ b/public/app/percona/inventory/Tabs/Nodes.utils.ts @@ -1,3 +1,29 @@ +import { HighAvailabilityNode, NodeRole } from 'app/percona/shared/services/highAvailability/HighAvailability.types'; +import { Node } from 'app/percona/inventory/Inventory.types'; +import { InventoryNode } from './Nodes.types'; + export const getServiceLink = (serviceId: string) => { return `/inventory/services?search-text-input=${serviceId}&search-select=serviceId`; }; + +export const mapNodesToInventoryNodes = (nodes: Node[], haNodes: HighAvailabilityNode[]): InventoryNode[] => + nodes.map((node) => { + const haNode = haNodes.find((haNode) => haNode.node_name === node.nodeName); + + return { + ...node, + haRole: haNode?.role, + haStatus: haNode?.status, + }; + }); + +export const getHaRoleBadgeText = (role: NodeRole) => { + switch (role) { + case NodeRole.leader: + return 'Leader'; + case NodeRole.follower: + return 'Follower'; + default: + return 'Unspecified'; + } +}; diff --git a/public/app/percona/inventory/__mocks__/Inventory.service.ts b/public/app/percona/inventory/__mocks__/Inventory.service.ts index 26879e6ee3c13..51a8f5f1ad4fb 100644 --- a/public/app/percona/inventory/__mocks__/Inventory.service.ts +++ b/public/app/percona/inventory/__mocks__/Inventory.service.ts @@ -35,6 +35,7 @@ export const nodesMock = [ node_id: 'pmm-server', node_type: 'generic', node_name: 'pmm-server', + is_pmm_server_node: true, machine_id: '', distro: '', node_model: '', @@ -76,6 +77,7 @@ export const nodesMockMultipleAgentsNoPMMServer = [ node_id: '324234234', node_type: 'generic', node_name: 'node1', + is_pmm_server_node: false, custom_labels: {}, machine_id: '', distro: '', @@ -123,6 +125,7 @@ export const nodesMockOneAgentNoPMMServer = [ node_id: '324234234', node_type: 'generic', node_name: 'node2', + is_pmm_server_node: false, custom_labels: {}, machine_id: '', distro: '', diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.constants.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.constants.ts index a9c03c0e0c5f9..be526db3dc981 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.constants.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.constants.ts @@ -4,4 +4,9 @@ export const SEARCH_INPUT_FIELD_NAME = 'search-text-input'; export const ALL_LABEL = 'All'; export const ALL_VALUE = 'All'; +export const TRUE_LABEL = 'Yes'; +export const TRUE_VALUE = 'true'; +export const FALSE_LABEL = 'No'; +export const FALSE_VALUE = 'false'; + export const DEBOUNCE_DELAY = 600; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.styles.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.styles.ts index bc17656350f55..4569fc4983dd6 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.styles.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.styles.ts @@ -36,6 +36,9 @@ export const getStyles = ({ v1: { colors, spacing, typography } }: GrafanaTheme2 grid-template-columns: 1fr 1fr; gap: ${spacing.md}; `, + advancedFilter3Columns: css` + grid-template-columns: 1fr 1fr 1fr; + `, searchFields: css` display: flex; gap: ${spacing.xs}; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx index 58565df0cab04..68b4f19faf815 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unnecessary-type-assertion */ +import { cx } from '@emotion/css'; import { FormApi } from 'final-form'; import { debounce } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; @@ -21,6 +22,7 @@ import { getQueryParams, isOtherThanTextType, } from './Filter.utils'; +import BooleanField from './components/fields/BooleanField'; import { RadioButtonField } from './components/fields/RadioButtonField'; import { SearchTextField } from './components/fields/SearchTextField'; import { SelectColumnField } from './components/fields/SelectColumnField'; @@ -171,10 +173,11 @@ export const Filter = ({ {showAdvanceFilter && openCollapse && ( -
+
{columns.map( (column) => (column.type === FilterFieldTypes.DROPDOWN && ) || + (column.type === FilterFieldTypes.BOOLEAN && ) || (column.type === FilterFieldTypes.RADIO_BUTTON && ) )}
diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts index 468177463a43b..2160591f887ae 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts @@ -41,7 +41,9 @@ export const buildObjForQueryParams = ( const accessor = column.accessor as string; const value = values[accessor]?.value ?? values[accessor]; - if (value) { + if (column.type === FilterFieldTypes.BOOLEAN) { + obj[accessor] = value ? 'true' : 'false'; + } else if (value) { if (column.type === FilterFieldTypes.RADIO_BUTTON || column.type === FilterFieldTypes.DROPDOWN) { obj[accessor] = value === ALL_VALUE ? undefined : value.toString(); } @@ -86,6 +88,8 @@ export const buildEmptyValues = (columns: Array { if (column.type === FilterFieldTypes.DROPDOWN || column.type === FilterFieldTypes.RADIO_BUTTON) { obj = { ...obj, [column.accessor as string]: ALL_VALUE }; + } else if (column.type === FilterFieldTypes.BOOLEAN) { + obj = { ...obj, [column.accessor as string]: false }; } }); return obj; @@ -146,6 +150,36 @@ export const isInOptions = ( return result.every((value) => value); }; +export const isBooleanMatch = ( + columns: Array>, + filterValue: T, + queryParamsObj: Record +) => { + const result: boolean[] = []; + + columns.forEach((column) => { + const accessor = column.accessor; + const queryParamValueAccessor = queryParamsObj[accessor as string]; + const filterValueAccessor = filterValue[accessor as keyof T]; + + if (column.type === FilterFieldTypes.BOOLEAN) { + if (queryParamValueAccessor) { + const filterBoolean = queryParamValueAccessor === 'true'; + const rowValue = filterValueAccessor; + + if (filterBoolean) { + result.push(rowValue === true); + } else { + result.push(!rowValue); + } + } else { + result.push(true); + } + } + }); + return result.every((value) => value); +}; + export const isOtherThanTextType = (columns: Array>): boolean => columns.some((column) => column.type !== undefined && column.type !== FilterFieldTypes.TEXT); @@ -163,5 +197,6 @@ export const getFilteredData = ( (filterValue) => isValueInTextColumn(columns, filterValue, queryParamsObj) && isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.DROPDOWN) && - isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) + isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) && + isBooleanMatch(columns, filterValue, queryParamsObj) ); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.styles.ts b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.styles.ts new file mode 100644 index 0000000000000..cf1e2f3daaa8f --- /dev/null +++ b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.styles.ts @@ -0,0 +1,12 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2) => ({ + booleanField: css({ + display: 'flex', + alignItems: 'center', + marginLeft: theme.spacing(2), + marginTop: theme.spacing(2), + }), +}); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.tsx b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.tsx new file mode 100644 index 0000000000000..278e21fc6d40c --- /dev/null +++ b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/BooleanField.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; + +import { useStyles2 } from '@grafana/ui'; + +import { CheckboxField } from '../../../../Checkbox'; +import { ExtendedColumn } from '../../../Table.types'; + +import { getStyles } from './BooleanField.styles'; + +const BooleanField = ({ column }: { column: ExtendedColumn }) => { + const styles = useStyles2(getStyles); + + return ( +
+ (typeof value === 'boolean' ? value : value === 'true')} + /> +
+ ); +}; + +export default BooleanField; diff --git a/public/app/percona/shared/components/Elements/Table/Table.types.ts b/public/app/percona/shared/components/Elements/Table/Table.types.ts index 8242729b5a7df..caf4ee82286c9 100644 --- a/public/app/percona/shared/components/Elements/Table/Table.types.ts +++ b/public/app/percona/shared/components/Elements/Table/Table.types.ts @@ -41,6 +41,7 @@ export enum FilterFieldTypes { TEXT, RADIO_BUTTON, DROPDOWN, + BOOLEAN, } export interface TableProps { diff --git a/public/app/percona/shared/components/Form/Switch/Switch.tsx b/public/app/percona/shared/components/Form/Switch/Switch.tsx index d65b14b7941fc..2745ab43fbbcc 100644 --- a/public/app/percona/shared/components/Form/Switch/Switch.tsx +++ b/public/app/percona/shared/components/Form/Switch/Switch.tsx @@ -44,7 +44,7 @@ export const SwitchField: FC = ({ tooltipLinkTarget={tooltipLinkTarget} tooltipIcon={tooltipIcon} /> - +
{meta.touched && meta.error}
diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx index 6c55315932654..c785a7d5220d1 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx @@ -31,6 +31,7 @@ import PerconaNavigation from './PerconaNavigation/PerconaNavigation'; import PerconaTourBootstrapper from './PerconaTour'; import PerconaUpdateVersion from './PerconaUpdateVersion/PerconaUpdateVersion'; import { isPmmNavEnabled } from '../../helpers/plugin'; +import { fetchHighAvailabilityStatus } from '../../core/reducers/highAvailability/highAvailability'; // This component is only responsible for populating the store with Percona's settings initially export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { @@ -102,6 +103,7 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { } await getUserDetails(); + await dispatch(fetchHighAvailabilityStatus()); await dispatch(fetchServerInfoAction()); await dispatch(fetchServerSaasHostAction()); onReady(); diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts index bf836ebd3a857..305e63ff05415 100644 --- a/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts @@ -8,7 +8,12 @@ import { buildParamsFromKey } from '../Elements/Table/Filter/Filter.utils'; import { QueryParamsValues } from './SearchFilter.types'; export const getFilterColumns = (columns: Array>): Array> => - columns.filter((col) => col.type === FilterFieldTypes.DROPDOWN || col.type === FilterFieldTypes.RADIO_BUTTON); + columns.filter( + (col) => + col.type === FilterFieldTypes.DROPDOWN || + col.type === FilterFieldTypes.RADIO_BUTTON || + col.type === FilterFieldTypes.BOOLEAN + ); export const useQueryParamsByKey = (tableKey?: string) => { const [queryParams, setQueryParams] = useQueryParams(); diff --git a/public/app/percona/shared/core/reducers/highAvailability/highAvailability.ts b/public/app/percona/shared/core/reducers/highAvailability/highAvailability.ts new file mode 100644 index 0000000000000..79b833a944989 --- /dev/null +++ b/public/app/percona/shared/core/reducers/highAvailability/highAvailability.ts @@ -0,0 +1,57 @@ +import { CancelToken } from 'axios'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import { HighAvailabilityState } from './highAvailability.types'; + +import { HighAvailabilityService } from 'app/percona/shared/services/highAvailability/HighAvailability.service'; +import { + HighAvailabilityNodesResponse, + HighAvailabilityStatusResponse, +} from 'app/percona/shared/services/highAvailability/HighAvailability.types'; + +const initialState: HighAvailabilityState = { + isLoading: false, + isEnabled: false, + nodes: [], +}; + +const highAvailabilitySlice = createSlice({ + name: 'highAvailability', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(fetchHighAvailabilityStatus.pending, (state) => ({ + ...state, + isLoading: true, + })); + builder.addCase(fetchHighAvailabilityStatus.rejected, (state) => ({ + ...state, + isEnabled: false, + isLoading: false, + })); + builder.addCase(fetchHighAvailabilityStatus.fulfilled, (state, action) => ({ + ...state, + isEnabled: action.payload.status === 'Enabled', + })); + builder.addCase(fetchHighAvailabilityNodes.fulfilled, (state, action) => ({ + ...state, + nodes: action.payload.nodes, + })); + }, +}); + +export const fetchHighAvailabilityStatus = createAsyncThunk( + 'percona/fetchHighAvailabilityStatus', + async () => { + return await HighAvailabilityService.getStatus(); + } +); + +export const fetchHighAvailabilityNodes = createAsyncThunk( + 'percona/fetchHighAvailabilityNodes', + async (params = {}) => { + return await HighAvailabilityService.getNodes(params.token); + } +); + +export default highAvailabilitySlice.reducer; diff --git a/public/app/percona/shared/core/reducers/highAvailability/highAvailability.types.ts b/public/app/percona/shared/core/reducers/highAvailability/highAvailability.types.ts new file mode 100644 index 0000000000000..25c5223b366b1 --- /dev/null +++ b/public/app/percona/shared/core/reducers/highAvailability/highAvailability.types.ts @@ -0,0 +1,7 @@ +import { HighAvailabilityNode } from 'app/percona/shared/services/highAvailability/HighAvailability.types'; + +export interface HighAvailabilityState { + isLoading: boolean; + isEnabled: boolean; + nodes: HighAvailabilityNode[]; +} diff --git a/public/app/percona/shared/core/reducers/index.ts b/public/app/percona/shared/core/reducers/index.ts index cb3673500648f..ff623f3bbc463 100644 --- a/public/app/percona/shared/core/reducers/index.ts +++ b/public/app/percona/shared/core/reducers/index.ts @@ -17,6 +17,7 @@ import { ServerInfo } from '../types'; import advisorsReducers from './advisors/advisors'; import perconaBackupLocations from './backups/backupLocations'; +import highAvailabilityReducer from './highAvailability/highAvailability'; import navigationReducer from './navigation'; import nodesReducer from './nodes'; import pmmDumpsReducers from './pmmDump/pmmDump'; @@ -224,5 +225,6 @@ export default { advisors: advisorsReducers, pmmDumps: pmmDumpsReducers, updates: updatesReducers, + highAvailability: highAvailabilityReducer, }), }; diff --git a/public/app/percona/shared/core/reducers/nodes/nodes.utils.ts b/public/app/percona/shared/core/reducers/nodes/nodes.utils.ts index 43d728add1e49..ec0f579c3a597 100644 --- a/public/app/percona/shared/core/reducers/nodes/nodes.utils.ts +++ b/public/app/percona/shared/core/reducers/nodes/nodes.utils.ts @@ -40,6 +40,7 @@ export const nodeFromDbMapper = (nodeFromDb: NodeDB[]): Node[] => { containerId: node.container_id, containerName: node.container_name, customLabels: node.custom_labels, + isPmmServerNode: node.is_pmm_server_node, agents: agents, createdAt: node.created_at, updatedAt: node.updated_at, diff --git a/public/app/percona/shared/core/selectors.ts b/public/app/percona/shared/core/selectors.ts index e74d1bd502b51..023eb35826b48 100644 --- a/public/app/percona/shared/core/selectors.ts +++ b/public/app/percona/shared/core/selectors.ts @@ -26,3 +26,4 @@ export const getCategorizedAdvisors = createSelector([getAdvisors], (advisors) = ); export const getDumps = (state: StoreState) => state.percona.pmmDumps; export const getUpdatesInfo = (state: StoreState) => state.percona.updates; +export const getHighAvailability = (state: StoreState) => state.percona.highAvailability; diff --git a/public/app/percona/shared/services/highAvailability/HighAvailability.service.ts b/public/app/percona/shared/services/highAvailability/HighAvailability.service.ts new file mode 100644 index 0000000000000..3d64a0cdae1a4 --- /dev/null +++ b/public/app/percona/shared/services/highAvailability/HighAvailability.service.ts @@ -0,0 +1,16 @@ +import { CancelToken } from 'axios'; + +import { api } from 'app/percona/shared/helpers/api'; + +import { HighAvailabilityNodesResponse, HighAvailabilityStatusResponse } from './HighAvailability.types'; + +const BASE_URL = '/v1/ha'; + +export const HighAvailabilityService = { + getStatus: async (token?: CancelToken) => { + return api.get(`${BASE_URL}/status`, true, { cancelToken: token }); + }, + getNodes: async (token?: CancelToken): Promise => { + return api.get(`${BASE_URL}/nodes`, true, { cancelToken: token }); + }, +}; diff --git a/public/app/percona/shared/services/highAvailability/HighAvailability.types.ts b/public/app/percona/shared/services/highAvailability/HighAvailability.types.ts new file mode 100644 index 0000000000000..54ad06c2a0ae8 --- /dev/null +++ b/public/app/percona/shared/services/highAvailability/HighAvailability.types.ts @@ -0,0 +1,23 @@ +export type HAStatus = 'Enabled' | 'Disabled'; + +export enum NodeRole { + leader = 'NODE_ROLE_LEADER', + follower = 'NODE_ROLE_FOLLOWER', + unspecified = 'NODE_ROLE_UNSPECIFIED', +} + +export interface HighAvailabilityStatusResponse { + status: HAStatus; +} + +export interface HighAvailabilityNodesResponse { + nodes: HighAvailabilityNode[]; +} + +export type NodeStatus = 'alive' | 'suspect' | 'dead' | 'left' | 'unknown'; + +export interface HighAvailabilityNode { + node_name: string; + role: NodeRole; + status: NodeStatus; +} diff --git a/public/app/percona/shared/services/highAvailability/__mocks__/HighAvailability.service.ts b/public/app/percona/shared/services/highAvailability/__mocks__/HighAvailability.service.ts new file mode 100644 index 0000000000000..bf499c5487482 --- /dev/null +++ b/public/app/percona/shared/services/highAvailability/__mocks__/HighAvailability.service.ts @@ -0,0 +1,95 @@ +import { HighAvailabilityService } from '../HighAvailability.service'; +import { HighAvailabilityNodesResponse, HighAvailabilityStatusResponse, NodeRole } from '../HighAvailability.types'; + +export const HA_STATUS_MOCK: HighAvailabilityStatusResponse = { + status: 'Enabled', +}; + +export const HA_NODES_MOCK_HEALTHY: HighAvailabilityNodesResponse = { + nodes: [ + { + node_name: 'pmm-server', + role: NodeRole.follower, + status: 'alive', + }, + { + node_name: 'mysql', + role: NodeRole.leader, + status: 'alive', + }, + { + node_name: 'mongodb', + role: NodeRole.follower, + status: 'alive', + }, + ], +}; + +export const HA_NODES_MOCK_DEGRADED: HighAvailabilityNodesResponse = { + nodes: [ + { + node_name: 'pmm-ha-0', + role: NodeRole.follower, + status: 'alive', + }, + { + node_name: 'pmm-ha-1', + role: NodeRole.leader, + status: 'alive', + }, + { + node_name: 'pmm-ha-2', + role: NodeRole.follower, + status: 'dead', + }, + ], +}; + +export const HA_NODES_MOCK_CRITICAL: HighAvailabilityNodesResponse = { + nodes: [ + { + node_name: 'pmm-ha-0', + role: NodeRole.follower, + status: 'dead', + }, + { + node_name: 'pmm-ha-1', + role: NodeRole.leader, + status: 'alive', + }, + { + node_name: 'pmm-ha-2', + role: NodeRole.follower, + status: 'dead', + }, + ], +}; + +export const HA_NODES_MOCK_DOWN: HighAvailabilityNodesResponse = { + nodes: [ + { + node_name: 'pmm-ha-0', + role: NodeRole.follower, + status: 'suspect', + }, + { + node_name: 'pmm-ha-1', + role: NodeRole.leader, + status: 'suspect', + }, + { + node_name: 'pmm-ha-2', + role: NodeRole.follower, + status: 'suspect', + }, + ], +}; + +export const HighAvailabilityServiceMock = + jest.genMockFromModule('../HighAvailability.service'); + +HighAvailabilityServiceMock.getStatus = () => Promise.resolve(HA_STATUS_MOCK); + +HighAvailabilityServiceMock.getNodes = () => Promise.resolve(HA_NODES_MOCK_HEALTHY); + +export default HighAvailabilityServiceMock; diff --git a/public/app/percona/shared/services/nodes/Nodes.types.ts b/public/app/percona/shared/services/nodes/Nodes.types.ts index 93754315146da..43b8ce752e59f 100644 --- a/public/app/percona/shared/services/nodes/Nodes.types.ts +++ b/public/app/percona/shared/services/nodes/Nodes.types.ts @@ -8,6 +8,7 @@ export interface NodePayload { region?: string; az?: string; custom_labels?: Record; + is_pmm_server_node: boolean; agents?: DbAgent[]; } export interface GenericNodePayload extends NodePayload {