Skip to content
Open
Show file tree
Hide file tree
Changes from all 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';
1 change: 1 addition & 0 deletions public/app/percona/inventory/Inventory.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' : ''}`,
Expand Down
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
53 changes: 44 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 @@ -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';
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -80,7 +90,7 @@ export const NodesTab = () => {
}, []);

const columns = useMemo(
(): Array<ExtendedColumn<Node>> => [
(): Array<ExtendedColumn<InventoryNode>> => [
{
Header: Messages.services.columns.nodeId,
id: 'nodeId',
Expand All @@ -102,6 +112,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 +197,30 @@ export const NodesTab = () => {
return <div>{Messages.nodes.servicesCount(value.length)}</div>;
},
},
...((isHighAvailabilityEnabled
? [
{
Header: Messages.nodes.columns.isPmmServerNode,
id: 'isPmmServerNode',
accessor: 'isPmmServerNode',
hidden: true,
type: FilterFieldTypes.BOOLEAN,
},
]
: []) as Array<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
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -171,10 +173,11 @@ 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} />) ||
(column.type === FilterFieldTypes.BOOLEAN && <BooleanField column={column} />) ||
(column.type === FilterFieldTypes.RADIO_BUTTON && <RadioButtonField column={column} />)
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ export const buildObjForQueryParams = <T extends object>(
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();
}
Expand Down Expand Up @@ -86,6 +88,8 @@ export const buildEmptyValues = <T extends object>(columns: Array<ExtendedColumn
columns.map((column) => {
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;
Expand Down Expand Up @@ -146,6 +150,36 @@ export const isInOptions = <T extends object>(
return result.every((value) => value);
};

export const isBooleanMatch = <T extends object>(
columns: Array<ExtendedColumn<T>>,
filterValue: T,
queryParamsObj: Record<string, string>
) => {
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 = <T extends object>(columns: Array<ExtendedColumn<T>>): boolean =>
columns.some((column) => column.type !== undefined && column.type !== FilterFieldTypes.TEXT);

Expand All @@ -163,5 +197,6 @@ export const getFilteredData = <T extends object>(
(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)
);
Original file line number Diff line number Diff line change
@@ -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),
}),
});
Original file line number Diff line number Diff line change
@@ -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 = <T extends object>({ column }: { column: ExtendedColumn<T> }) => {
const styles = useStyles2(getStyles);

return (
<div className={styles.booleanField}>
<CheckboxField
name={String(column.accessor)}
label={column.label ?? (column.Header as ReactNode)}
data-testid={`${String(column.accessor)}-filter-checkbox`}
format={(value) => (typeof value === 'boolean' ? value : value === 'true')}
/>
</div>
);
};

export default BooleanField;
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export enum FilterFieldTypes {
TEXT,
RADIO_BUTTON,
DROPDOWN,
BOOLEAN,
}

export interface TableProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const SwitchField: FC<SwitchFieldProps> = ({
tooltipLinkTarget={tooltipLinkTarget}
tooltipIcon={tooltipIcon}
/>
<Switch {...input} value={input.checked} disabled={disabled} data-testid={`${name}-switch`} />
<Switch {...input} disabled={disabled} data-testid={`${name}-switch`} />
<div data-testid={`${name}-field-error-message`} className={styles.errorMessage}>
{meta.touched && meta.error}
</div>
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
Loading
Loading