Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/app/percona/inventory/Inventory.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions public/app/percona/inventory/Inventory.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export interface Node {
services?: ServiceNodeList[];
properties?: Record<string, string>;
agentsStatus?: string;
isPmmServerNode: boolean;
}

export interface NodeDB {
Expand All @@ -160,6 +161,7 @@ export interface NodeDB {
updated_at: string;
status: ServiceStatus;
services?: ServiceNodeListDB[];
is_pmm_server_node: boolean;
}

export interface NodeListDBPayload {
Expand Down
60 changes: 51 additions & 9 deletions public/app/percona/inventory/Tabs/Nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +16,7 @@ import { useCancelToken } from 'app/percona/shared/components/hooks/cancelToken.
import { usePerconaNavModel } from 'app/percona/shared/components/hooks/perconaNavModel';
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';
Expand All @@ -26,20 +26,27 @@ 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 { getHaRoleBadgeText, getServiceLink, mapNodesToInventoryNodes } from './Nodes.utils';
import {
getBadgeColorForServiceStatus,
getBadgeIconForServiceStatus,
getBadgeTextForServiceStatus,
getTagsFromLabels,
} from './Services.utils';
import { getStyles } from './Tabs.styles';
import { TRUE_VALUE } from 'app/percona/shared/components/Elements/Table/Filter/Filter.constants';
import { fetchHighAvailabilityNodes } from 'app/percona/shared/core/reducers/highAvailability/highAvailability';
import { InventoryNode } from './Nodes.types';

export const NodesTab = () => {
const { isLoading, nodes } = useSelector(getNodes);
Expand All @@ -50,10 +57,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(
Expand All @@ -80,7 +91,7 @@ export const NodesTab = () => {
}, []);

const columns = useMemo(
(): Array<ExtendedColumn<Node>> => [
(): Array<ExtendedColumn<InventoryNode>> => [
{
Header: Messages.services.columns.nodeId,
id: 'nodeId',
Expand All @@ -102,6 +113,15 @@ export const NodesTab = () => {
{
Header: Messages.nodes.columns.nodeName,
accessor: 'nodeName',
Cell: ({ value, row }) =>
isHighAvailabilityEnabled ? (
<Stack>
<span>{value}</span>
{row.original.haRole && <Badge text={getHaRoleBadgeText(row.original.haRole)} color="darkgrey" />}
</Stack>
) : (
value
),
type: FilterFieldTypes.TEXT,
},
{
Expand Down Expand Up @@ -178,14 +198,36 @@ export const NodesTab = () => {
return <div>{Messages.nodes.servicesCount(value.length)}</div>;
},
},
...((isHighAvailabilityEnabled
? [
{
Header: 'Node Filter',
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header 'Node Filter' is not user-facing since this column is hidden, but it's unclear what filtering is being applied. Consider renaming to something more descriptive like 'PMM Server Node Filter' to better indicate its purpose.

Suggested change
Header: 'Node Filter',
Header: 'PMM Server Node Filter',

Copilot uses AI. Check for mistakes.
id: 'isPmmServerNode',
accessor: 'isPmmServerNode',
hidden: true,
options: [
{
label: 'PMM Server only',
value: TRUE_VALUE,
},
],
type: FilterFieldTypes.RADIO_BUTTON,
},
]
: []) as ExtendedColumn<InventoryNode>[]),
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;
Expand Down
7 changes: 7 additions & 0 deletions public/app/percona/inventory/Tabs/Nodes.types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions public/app/percona/inventory/Tabs/Nodes.utils.ts
Original file line number Diff line number Diff line change
@@ -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';
}
};
3 changes: 3 additions & 0 deletions public/app/percona/inventory/__mocks__/Inventory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { RadioButtonField } from './components/fields/RadioButtonField';
import { SearchTextField } from './components/fields/SearchTextField';
import { SelectColumnField } from './components/fields/SelectColumnField';
import { SelectDropdownField } from './components/fields/SelectDropdownField';
import { cx } from '@emotion/css';

export const Filter = <T,>({
columns,
Expand Down Expand Up @@ -171,7 +172,7 @@ export const Filter = <T,>({
</div>
</div>
{showAdvanceFilter && openCollapse && (
<div className={styles.advanceFilter}>
<div className={cx(styles.advanceFilter, columns.length % 3 === 0 && styles.advancedFilter3Columns)}>
{columns.map(
(column) =>
(column.type === FilterFieldTypes.DROPDOWN && <SelectDropdownField column={column} />) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -102,6 +103,7 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => {
}

await getUserDetails();
await dispatch(fetchHighAvailabilityStatus());
await dispatch(fetchServerInfoAction());
await dispatch(fetchServerSaasHostAction());
onReady();
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isLoading state is not reset to false when fetchHighAvailabilityNodes.fulfilled completes. This will leave the loading indicator active indefinitely. Add isLoading: false to the returned state.

Suggested change
nodes: action.payload.nodes,
nodes: action.payload.nodes,
isLoading: false,

Copilot uses AI. Check for mistakes.
}));
},
});

export const fetchHighAvailabilityStatus = createAsyncThunk<HighAvailabilityStatusResponse>(
'percona/fetchHighAvailabilityStatus',
async () => {
return await HighAvailabilityService.getStatus();
}
);

export const fetchHighAvailabilityNodes = createAsyncThunk<HighAvailabilityNodesResponse, { token?: CancelToken }>(
'percona/fetchHighAvailabilityNodes',
async (params = {}) => {
return await HighAvailabilityService.getNodes(params.token);
}
);

export default highAvailabilitySlice.reducer;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { HighAvailabilityNode } from 'app/percona/shared/services/highAvailability/HighAvailability.types';

export interface HighAvailabilityState {
isLoading: boolean;
isEnabled: boolean;
nodes: HighAvailabilityNode[];
}
2 changes: 2 additions & 0 deletions public/app/percona/shared/core/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -224,5 +225,6 @@ export default {
advisors: advisorsReducers,
pmmDumps: pmmDumpsReducers,
updates: updatesReducers,
highAvailability: highAvailabilityReducer,
}),
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions public/app/percona/shared/core/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
@@ -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<HighAvailabilityStatusResponse, void>(`${BASE_URL}/status`, true, { cancelToken: token });
},
getNodes: async (token?: CancelToken): Promise<HighAvailabilityNodesResponse> => {
return api.get<HighAvailabilityNodesResponse, void>(`${BASE_URL}/nodes`, true, { cancelToken: token });
},
};
Loading
Loading