From 18b0558a5ba8e57f52f23454bddb018e17254e9d Mon Sep 17 00:00:00 2001 From: Thuan Vo Date: Wed, 26 Jul 2023 16:53:30 -0400 Subject: [PATCH] feat(match-expression): match expression should be evaluated on backend Signed-off-by: Thuan Vo --- src/app/Rules/CreateRule.tsx | 32 +++---- .../Credentials/CreateCredentialModal.tsx | 28 +++--- .../Credentials/CredentialTestTable.tsx | 86 ++++++++++++------- .../Credentials/StoreCredentials.tsx | 56 ++++-------- .../MatchExpressionVisualizer.tsx | 71 ++++++++++----- src/app/Shared/Services/Api.service.tsx | 21 +++-- src/app/Topology/GraphView/CustomNode.tsx | 21 +++-- .../Topology/ListView/TopologyListView.tsx | 17 +++- src/app/Topology/ListView/UtilsFactory.tsx | 27 +++--- src/app/Topology/Shared/utils.tsx | 13 --- src/app/utils/utils.ts | 5 -- 11 files changed, 200 insertions(+), 177 deletions(-) diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 8195f13e84..55ae4296f6 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -47,7 +47,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { Target } from '@app/Shared/Services/Target.service'; import { SearchExprService, SearchExprServiceContext } from '@app/Topology/Shared/utils'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { evaluateTargetWithExpr, portalRoot } from '@app/utils/utils'; +import { portalRoot } from '@app/utils/utils'; import { ActionGroup, Button, @@ -286,26 +286,20 @@ const CreateRuleForm: React.FC = ({ ...props }) => { }, [addSubscription, context.targets, setTargets]); React.useEffect(() => { - // Set validations - let validation: ValidatedOptions = ValidatedOptions.default; - let matches: Target[] = []; if (matchExpression !== '' && targets.length > 0) { - try { - matches = targets.filter((t) => { - const res = evaluateTargetWithExpr(t, matchExpression); - if (typeof res === 'boolean') { - return res; - } - throw new Error('The expression matching failed.'); - }); - validation = matches.length ? ValidatedOptions.success : ValidatedOptions.warning; - } catch (err) { - validation = ValidatedOptions.error; - } + addSubscription( + context.api.matchTargetsWithExpr(matchExpression, targets).subscribe({ + next: (ts) => { + setMatchExpressionValid(ts.length ? ValidatedOptions.success : ValidatedOptions.warning); + matchedTargets.next(ts); + }, + error: (_) => { + setMatchExpressionValid(ValidatedOptions.error); + }, + }) + ); } - setMatchExpressionValid(validation); - matchedTargets.next(matches); - }, [matchExpression, targets, matchedTargets, setMatchExpressionValid]); + }, [matchExpression, targets, matchedTargets, context.api, setMatchExpressionValid, addSubscription]); const createButtonLoadingProps = React.useMemo( () => diff --git a/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx b/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx index 01f581df51..18756a4717 100644 --- a/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx +++ b/src/app/SecurityPanel/Credentials/CreateCredentialModal.tsx @@ -42,7 +42,7 @@ import { ServiceContext } from '@app/Shared/Services/Services'; import { Target } from '@app/Shared/Services/Target.service'; import { SearchExprService, SearchExprServiceContext } from '@app/Topology/Shared/utils'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { evaluateTargetWithExpr, portalRoot, StreamOf } from '@app/utils/utils'; +import { portalRoot, StreamOf } from '@app/utils/utils'; import { Button, Card, @@ -170,23 +170,19 @@ export const AuthForm: React.FC = ({ onDismiss, onPropsSave, prog }, [addSubscription, context.targets, setTargets]); React.useEffect(() => { - let validation: ValidatedOptions = ValidatedOptions.default; if (matchExpression !== '' && targets.length > 0) { - try { - const atLeastOne = targets.some((t) => { - const res = evaluateTargetWithExpr(t, matchExpression); - if (typeof res === 'boolean') { - return res; - } - throw new Error('The expression matching failed.'); - }); - validation = atLeastOne ? ValidatedOptions.success : ValidatedOptions.warning; - } catch (err) { - validation = ValidatedOptions.error; - } + addSubscription( + context.api.matchTargetsWithExpr(matchExpression, targets).subscribe({ + next: (ts) => { + setMatchExpressionValid(ts.length ? ValidatedOptions.success : ValidatedOptions.warning); + }, + error: (_) => { + setMatchExpressionValid(ValidatedOptions.error); + }, + }) + ); } - setMatchExpressionValid(validation); - }, [matchExpression, targets, setMatchExpressionValid]); + }, [matchExpression, targets, context.api, setMatchExpressionValid, addSubscription]); React.useEffect(() => { progressChange && progressChange(saving); diff --git a/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx b/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx index 1af8577df3..5742478385 100644 --- a/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx +++ b/src/app/SecurityPanel/Credentials/CredentialTestTable.tsx @@ -37,11 +37,11 @@ */ import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; +import { Target, includesTarget } from '@app/Shared/Services/Target.service'; import { useSearchExpression } from '@app/Topology/Shared/utils'; import { useSort } from '@app/utils/useSort'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { TableColumn, evaluateTargetWithExpr, portalRoot, sortResources } from '@app/utils/utils'; +import { TableColumn, portalRoot, sortResources } from '@app/utils/utils'; import { Bullseye, Button, @@ -103,43 +103,64 @@ export const CredentialTestTable: React.FC = ({ ...pro const [matchExpression] = useSearchExpression(); const [sortBy, getSortParams] = useSort(); - const [targets, setTargets] = React.useState([]); + const [targets, setTargets] = React.useState<{ target: Target; matched: boolean }[]>([]); const [filters, setFilters] = React.useState([]); const [searchText, setSearchText] = React.useState(''); + const [loading, setLoading] = React.useState(false); + const [err, setErr] = React.useState(); React.useEffect(() => { - addSubscription(context.targets.targets().subscribe(setTargets)); + addSubscription( + context.targets.targets().subscribe((targets) => { + setTargets(targets.map((t) => ({ target: t, matched: false }))); + }) + ); }, [addSubscription, context.targets, setTargets]); - const matchedTargets = React.useMemo(() => { - try { - return targets.filter((t) => { - const res = evaluateTargetWithExpr(t, matchExpression); - if (typeof res === 'boolean') { - return res; - } - throw new Error('Invalid match expression'); - }); - } catch (err) { - return []; + React.useEffect(() => { + if (!targets.length) { + return; + } else if (!matchExpression) { + setTargets((ts) => ts.map((t) => ({ ...t, matched: false }))); + } else { + setLoading(true); + addSubscription( + context.api + .matchTargetsWithExpr( + matchExpression, + targets.map((t) => t.target) + ) + .subscribe({ + next: (ts) => { + setLoading(false); + const matched = targets.filter((t) => includesTarget(ts, t.target)).map((t) => ({ ...t, matched: true })); + // Matched targets are by default pushed to top + setTargets([...matched, ...targets.filter((t) => !includesTarget(ts, t.target))]); + }, + error: (err: Error) => { + setLoading(false); + setErr(err); + }, + }) + ); } - }, [targets, matchExpression]); + }, [targets, matchExpression, context.api, addSubscription]); - const rows = React.useMemo( - () => - sortResources( - { - index: sortBy.index ?? 0, - direction: sortBy.direction ?? SortByDirection.asc, - }, - matchedTargets, - tableColumns - ).map((t) => ), - [matchedTargets, filters, searchText, sortBy] - ); + const rows = React.useMemo(() => { + const matchedTargets = targets.filter((t) => t.matched).map((t) => t.target); + return sortResources( + { + index: sortBy.index ?? 0, + direction: sortBy.direction ?? SortByDirection.asc, + }, + matchedTargets, + tableColumns + ).map((t) => ); + }, [targets, filters, searchText, sortBy]); - const toolbar = React.useMemo( - () => ( + const toolbar = React.useMemo(() => { + const matchedTargets = targets.filter((t) => t.matched).map((t) => t.target); + return ( = ({ ...pro searchText={searchText} matchedTargets={matchedTargets} /> - ), - [setFilters, setSearchText, filters, searchText, matchedTargets] - ); + ); + }, [setFilters, setSearchText, filters, searchText, targets]); return rows.length ? ( diff --git a/src/app/SecurityPanel/Credentials/StoreCredentials.tsx b/src/app/SecurityPanel/Credentials/StoreCredentials.tsx index 68714c49ef..638adfef2c 100644 --- a/src/app/SecurityPanel/Credentials/StoreCredentials.tsx +++ b/src/app/SecurityPanel/Credentials/StoreCredentials.tsx @@ -41,10 +41,9 @@ import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; import { StoredCredential } from '@app/Shared/Services/Api.service'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; import { useSort } from '@app/utils/useSort'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { TableColumn, evaluateTargetWithExpr, sortResources } from '@app/utils/utils'; +import { TableColumn, sortResources } from '@app/utils/utils'; import { Badge, Button, @@ -66,14 +65,14 @@ import { ExpandableRowContent, TableComposable, Tbody, Td, Th, Thead, Tr } from import _ from 'lodash'; import * as React from 'react'; import { Link } from 'react-router-dom'; -import { concatMap, forkJoin, Observable, of } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { SecurityCard } from '../SecurityPanel'; import { CreateCredentialModal } from './CreateCredentialModal'; import { MatchedTargetsTable } from './MatchedTargetsTable'; const enum Actions { HANDLE_REFRESH, - HANDLE_TARGET_NOTIFICATION, + HANDLE_MATCHED_TARGET_NOTIFICATION, HANDLE_CREDENTIALS_STORED_NOTIFICATION, HANDLE_CREDENTIALS_DELETED_NOTIFICATION, HANDLE_ROW_CHECK, @@ -99,27 +98,15 @@ const reducer = (state: State, action) => { credentials: credentials, }; } - case Actions.HANDLE_TARGET_NOTIFICATION: { + case Actions.HANDLE_MATCHED_TARGET_NOTIFICATION: { return { ...state, credentials: state.credentials.map((credential) => { - let matched = false; - try { - const res = evaluateTargetWithExpr(action.payload.target, credential.matchExpression); - if (typeof res === 'boolean') { - matched = res; - } - } catch (_error) { - matched = false; - } - if (matched) { - const delta = action.payload.kind === 'FOUND' ? 1 : -1; - return { - ...credential, - numMatchingTargets: credential.numMatchingTargets + delta, - }; - } - return credential; + const delta = action.payload.kind === 'FOUND' ? 1 : -1; + return { + ...credential, + numMatchingTargets: credential.numMatchingTargets + delta, + }; }), }; } @@ -268,31 +255,26 @@ export const StoreCredentials = () => { addSubscription( context.notificationChannel .messages(NotificationCategory.CredentialsDeleted) - .pipe( - concatMap((v) => - of(dispatch({ type: Actions.HANDLE_CREDENTIALS_DELETED_NOTIFICATION, payload: { credential: v.message } })) - ) + .subscribe((v) => + dispatch({ type: Actions.HANDLE_CREDENTIALS_DELETED_NOTIFICATION, payload: { credential: v.message } }) ) - .subscribe(() => undefined /* do nothing - dispatch will have already handled updating state */) ); }, [addSubscription, context, context.notificationChannel]); - const handleTargetNotification = (evt: TargetDiscoveryEvent) => { - dispatch({ type: Actions.HANDLE_TARGET_NOTIFICATION, payload: { target: evt.serviceRef, kind: evt.kind } }); - }; - React.useEffect(() => { addSubscription( context.notificationChannel .messages(NotificationCategory.TargetJvmDiscovery) - .pipe(concatMap((v) => of(handleTargetNotification(v.message.event)))) - .subscribe(() => undefined /* do nothing - dispatch will have already handled updating state */) + .subscribe((_) => refreshStoredCredentialsAndCounts()) ); - }, [addSubscription, context, context.notificationChannel]); + }, [addSubscription, refreshStoredCredentialsAndCounts, context, context.notificationChannel, context.api]); - const handleHeaderCheck = React.useCallback((checked: boolean) => { - dispatch({ type: Actions.HANDLE_HEADER_CHECK, payload: { checked: checked } }); - }, []); + const handleHeaderCheck = React.useCallback( + (checked: boolean) => { + dispatch({ type: Actions.HANDLE_HEADER_CHECK, payload: { checked: checked } }); + }, + [dispatch] + ); const handleDeleteCredentials = React.useCallback(() => { const tasks: Observable[] = []; diff --git a/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx b/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx index ed834f9384..32542c95ab 100644 --- a/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx +++ b/src/app/Shared/MatchExpression/MatchExpressionVisualizer.tsx @@ -35,6 +35,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { LoadingView } from '@app/LoadingView/LoadingView'; import { TopologyControlBar } from '@app/Topology/GraphView/TopologyControlBar'; import { SavedGraphPosition, SavedNodePosition } from '@app/Topology/GraphView/TopologyGraphView'; import { getNodeById } from '@app/Topology/GraphView/UtilsFactory'; @@ -44,7 +46,7 @@ import { TopologySideBar } from '@app/Topology/SideBar/TopologySideBar'; import { NodeType } from '@app/Topology/typings'; import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { evaluateTargetWithExpr, hashCode } from '@app/utils/utils'; +import { hashCode } from '@app/utils/utils'; import { Bullseye, DataList, @@ -84,7 +86,7 @@ import { import _ from 'lodash'; import * as React from 'react'; import { ServiceContext } from '../Services/Services'; -import { Target } from '../Services/Target.service'; +import { Target, includesTarget } from '../Services/Target.service'; import { componentFactory, createTargetNode, layoutFactory, transformData } from './utils'; export interface MatchExpressionVisualizerProps { @@ -317,12 +319,17 @@ const ListView: React.FC<{ alertOptions?: AlertOptions }> = ({ alertOptions, ... const addSubscription = useSubscriptions(); const context = React.useContext(ServiceContext); const [matchExpression] = useSearchExpression(); - const [targets, setTargets] = React.useState([]); - + const [targets, setTargets] = React.useState<{ target: Target; matched: boolean }[]>([]); const [expanded, setExpanded] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [err, setErr] = React.useState(); React.useEffect(() => { - addSubscription(context.targets.targets().subscribe(setTargets)); + addSubscription( + context.targets.targets().subscribe((targets) => { + setTargets(targets.map((t) => ({ target: t, matched: false }))); + }) + ); }, [addSubscription, context.targets, setTargets]); const toggleExpand = React.useCallback( @@ -337,25 +344,43 @@ const ListView: React.FC<{ alertOptions?: AlertOptions }> = ({ alertOptions, ... [setExpanded] ); - const targetNodes = React.useMemo(() => targets.map(createTargetNode), [targets]); - - const filtered = React.useMemo( - () => - targetNodes.filter(({ target }) => { - try { - const res = evaluateTargetWithExpr(target, matchExpression); - if (typeof res === 'boolean') { - return res; - } - return false; - } catch (err) { - return false; - } - }), - [targetNodes, matchExpression] - ); + React.useEffect(() => { + if (!targets.length) { + return; + } else if (!matchExpression) { + setTargets((ts) => ts.map((t) => ({ ...t, matched: false }))); + } else { + setLoading(true); + addSubscription( + context.api + .matchTargetsWithExpr( + matchExpression, + targets.map((t) => t.target) + ) + .subscribe({ + next: (ts) => { + setLoading(false); + const matched = targets.filter((t) => includesTarget(ts, t.target)).map((t) => ({ ...t, matched: true })); + // Matched targets are by default pushed to top + setTargets([...matched, ...targets.filter((t) => !includesTarget(ts, t.target))]); + }, + error: (err: Error) => { + setLoading(false); + setErr(err); + }, + }) + ); + } + }, [targets, matchExpression, context.api, addSubscription]); const content = React.useMemo(() => { + if (loading) { + return ; + } + if (err) { + return ; + } + const filtered = targets.filter((t) => t.matched); if (!filtered || !filtered.length) { return ( @@ -412,7 +437,7 @@ const ListView: React.FC<{ alertOptions?: AlertOptions }> = ({ alertOptions, ... ); }); - }, [filtered, expanded, matchExpression, toggleExpand, props, alertOptions]); + }, [targets, loading, err, expanded, matchExpression, toggleExpand, props, alertOptions]); return ( diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index ad67eacdb6..d620813a00 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -50,7 +50,7 @@ import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, filter, first, map, mergeMap, tap } from 'rxjs/operators'; import { AuthMethod, LoginService, SessionState } from './Login.service'; import { NotificationCategory } from './NotificationChannel.service'; -import { includesTarget, NO_TARGET, Target, TargetService } from './Target.service'; +import { NO_TARGET, Target, TargetService, includesTarget } from './Target.service'; type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'v2.2' | 'beta'; @@ -1094,10 +1094,11 @@ export class ApiService { ); } - isTargetMatched(matchExpression: string, target: Target): Observable { + // Filter targets that the expression matches + matchTargetsWithExpr(matchExpression: string, targets: Target[]): Observable { const body = new window.FormData(); body.append('matchExpression', matchExpression); - body.append('targets', JSON.stringify([target])); + body.append('targets', JSON.stringify(targets)); return this.sendRequest( 'beta', @@ -1110,12 +1111,16 @@ export class ApiService { true, true ).pipe( + first(), concatMap((resp: Response) => resp.json()), - map((body) => { - const matchedTargets: Target[] = body.data.result.targets || []; - return includesTarget(matchedTargets, target); - }), - first() + map((body): Target[] => body.data.result.targets || []) + ); + } + + isTargetMatched(matchExpression: string, target: Target): Observable { + return this.matchTargetsWithExpr(matchExpression, [target]).pipe( + first(), + map((ts) => includesTarget(ts, target)) ); } diff --git a/src/app/Topology/GraphView/CustomNode.tsx b/src/app/Topology/GraphView/CustomNode.tsx index 2bedc99af0..8d75312810 100644 --- a/src/app/Topology/GraphView/CustomNode.tsx +++ b/src/app/Topology/GraphView/CustomNode.tsx @@ -39,6 +39,8 @@ import cryostatSvg from '@app/assets/cryostat_icon_rgb_default.svg'; import openjdkSvg from '@app/assets/openjdk.svg'; import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; import { ContainerNodeIcon } from '@patternfly/react-icons'; import { css } from '@patternfly/react-styles'; import { @@ -58,7 +60,7 @@ import { } from '@patternfly/react-topology'; import * as React from 'react'; import { useSelector } from 'react-redux'; -import { getStatusTargetNode, isTargetMatched, nodeTypeToAbbr, useSearchExpression } from '../Shared/utils'; +import { getStatusTargetNode, nodeTypeToAbbr, useSearchExpression } from '../Shared/utils'; import { TargetNode } from '../typings'; import { getNodeDecorators } from './NodeDecorator'; import { TOPOLOGY_GRAPH_ID } from './TopologyGraphView'; @@ -106,6 +108,9 @@ const CustomNode: React.FC = ({ useAnchor(EllipseAnchor); // For edges const [hover, hoverRef] = useHover(200, 200); const [expression] = useSearchExpression(); + const [matched, setMatched] = React.useState(true); + const addSubscription = useSubscriptions(); + const svcContext = React.useContext(ServiceContext); const displayOptions = useSelector((state: RootState) => state.topologyConfigs.displayOptions); const { badge: showBadge, connectionUrl: showConnectUrl, icon: showIcon, status: showStatus } = displayOptions.show; @@ -118,14 +123,20 @@ const CustomNode: React.FC = ({ const classNames = React.useMemo(() => { const graphId = element.getGraph().getId(); - const matchExprForSearch = graphId === TOPOLOGY_GRAPH_ID; - const additional = - (matchExprForSearch && expression === '') || isTargetMatched(data, expression) ? '' : 'search-inactive'; + const additional = (graphId === TOPOLOGY_GRAPH_ID && expression === '') || matched ? '' : 'search-inactive'; return css('topology__target-node', additional); - }, [data, expression, element]); + }, [expression, matched, element]); const nodeDecorators = React.useMemo(() => (showStatus ? getNodeDecorators(element) : null), [element, showStatus]); + React.useEffect(() => { + if (!expression) { + setMatched(element.getGraph().getId() === TOPOLOGY_GRAPH_ID); + return; + } + addSubscription(svcContext.api.isTargetMatched(expression, data.target).subscribe(setMatched)); + }, [element, data.target, expression, svcContext.api, addSubscription, setMatched]); + return ( }> diff --git a/src/app/Topology/ListView/TopologyListView.tsx b/src/app/Topology/ListView/TopologyListView.tsx index 924f5655f7..e6f72f15e0 100644 --- a/src/app/Topology/ListView/TopologyListView.tsx +++ b/src/app/Topology/ListView/TopologyListView.tsx @@ -36,11 +36,14 @@ * SOFTWARE. */ import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { Target } from '@app/Shared/Services/Target.service'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Divider, Stack, StackItem, TreeView, TreeViewDataItem } from '@patternfly/react-core'; import * as React from 'react'; import { useSelector } from 'react-redux'; import { TopologyEmptyState } from '../Shared/TopologyEmptyState'; -import { DiscoveryTreeContext, TransformConfig, useSearchExpression } from '../Shared/utils'; +import { DiscoveryTreeContext, TransformConfig, getAllLeaves, useSearchExpression } from '../Shared/utils'; import { TopologyToolbar, TopologyToolbarVariant } from '../Toolbar/TopologyToolbar'; import { transformData } from './UtilsFactory'; @@ -50,18 +53,26 @@ export interface TopologyListViewProps { export const TopologyListView: React.FC = ({ transformConfig, ...props }) => { const discoveryTree = React.useContext(DiscoveryTreeContext); + const svcContext = React.useContext(ServiceContext); + const addSubscription = useSubscriptions(); const filters = useSelector((state: RootState) => state.topologyFilters); const [expression] = useSearchExpression(100); + const [matchedTargets, setMatchedTargets] = React.useState([]); const _treeViewData: TreeViewDataItem[] = React.useMemo( - () => transformData(discoveryTree, transformConfig, filters, expression), - [discoveryTree, transformConfig, filters, expression] + () => transformData(discoveryTree, transformConfig, filters, matchedTargets), + [discoveryTree, transformConfig, filters, matchedTargets] ); const isEmptyList = React.useMemo(() => !_treeViewData.length, [_treeViewData]); + React.useEffect(() => { + const allTargets = getAllLeaves(discoveryTree).map((tn) => tn.target); + addSubscription(svcContext.api.matchTargetsWithExpr(expression, allTargets).subscribe(setMatchedTargets)); + }, [svcContext.api, expression, discoveryTree, addSubscription, setMatchedTargets]); + return ( diff --git a/src/app/Topology/ListView/UtilsFactory.tsx b/src/app/Topology/ListView/UtilsFactory.tsx index 6cb7a1964e..bea74bb0a9 100644 --- a/src/app/Topology/ListView/UtilsFactory.tsx +++ b/src/app/Topology/ListView/UtilsFactory.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; +import { Target, includesTarget } from '@app/Shared/Services/Target.service'; import { Badge, Flex, FlexItem, Label, LabelGroup, TreeViewDataItem } from '@patternfly/react-core'; import * as React from 'react'; import { ActionDropdown } from '../Actions/NodeActions'; @@ -47,7 +48,6 @@ import { getUniqueGroupId, getUniqueTargetId, isGroupNodeFiltered, - isTargetMatched, isTargetNodeFiltered, TransformConfig, } from '../Shared/utils'; @@ -56,7 +56,7 @@ import { EnvironmentNode, isTargetNode, NodeType, TargetNode } from '../typings' const _transformDataGroupedByTopLevel = ( universe: EnvironmentNode, filters?: TopologyFilters, - searchExpression = '' + includeOnlyTargets: Target[] = [] ): TreeViewDataItem[] => { return universe.children .filter((realm: EnvironmentNode) => isGroupNodeFiltered(realm, filters?.groupFilters.filters)) @@ -78,7 +78,7 @@ const _transformDataGroupedByTopLevel = ( .filter( (child: TargetNode) => isTargetNodeFiltered(child, filters?.targetFilters.filters) && - (searchExpression === '' || isTargetMatched(child, searchExpression)) + (!includeOnlyTargets.length || includesTarget(includeOnlyTargets, child.target)) ) .map((child: TargetNode) => ({ id: `${child.name}-wrapper`, @@ -131,12 +131,12 @@ const _buildFullData = ( node: EnvironmentNode | TargetNode, expandMode = true, filters?: TopologyFilters, - searchExpression = '' + includeOnlyTargets: Target[] = [] ): TreeViewDataItem[] => { if (isTargetNode(node)) { if ( - !isTargetNodeFiltered(node, filters?.targetFilters.filters) || - (searchExpression !== '' && !isTargetMatched(node, searchExpression)) + !isTargetNodeFiltered(node, filters?.targetFilters.filters) && + (!includeOnlyTargets.length || includesTarget(includeOnlyTargets, node.target)) ) { return []; } @@ -169,10 +169,7 @@ const _buildFullData = ( } const INIT: TreeViewDataItem[] = []; - const children = node.children.reduce( - (prev, curr) => prev.concat(_buildFullData(curr, expandMode, filters, searchExpression)), - INIT - ); + const children = node.children.reduce((prev, curr) => prev.concat(_buildFullData(curr, expandMode, filters)), INIT); // Do show empty or filtered-out groups if ( @@ -226,9 +223,9 @@ const _transformDataFull = ( root: EnvironmentNode, expandMode = true, filters?: TopologyFilters, - searchExpression = '' + includeOnlyTargets: Target[] = [] ): TreeViewDataItem[] => { - const _transformedRoot = _buildFullData(root, expandMode, filters, searchExpression)[0]; + const _transformedRoot = _buildFullData(root, expandMode, filters, includeOnlyTargets)[0]; return _transformedRoot && _transformedRoot.children ? _transformedRoot.children : []; }; @@ -236,9 +233,9 @@ export const transformData = ( universe: EnvironmentNode, { showOnlyTopGroup = false, expandMode = true }: TransformConfig = {}, filters?: TopologyFilters, - searchExpression = '' + includeOnlyTargets: Target[] = [] ): TreeViewDataItem[] => { return showOnlyTopGroup - ? _transformDataGroupedByTopLevel(universe, filters, searchExpression) - : _transformDataFull(universe, expandMode, filters, searchExpression); + ? _transformDataGroupedByTopLevel(universe, filters, includeOnlyTargets) + : _transformDataFull(universe, expandMode, filters, includeOnlyTargets); }; diff --git a/src/app/Topology/Shared/utils.tsx b/src/app/Topology/Shared/utils.tsx index 6fd5246c22..16ac55a2d9 100644 --- a/src/app/Topology/Shared/utils.tsx +++ b/src/app/Topology/Shared/utils.tsx @@ -36,7 +36,6 @@ * SOFTWARE. */ import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; -import { evaluateTargetWithExpr } from '@app/utils/utils'; import { Button, Text, TextVariants } from '@patternfly/react-core'; import { ContextMenuSeparator, GraphElement, NodeStatus } from '@patternfly/react-topology'; import * as React from 'react'; @@ -259,15 +258,3 @@ export const useSearchExpression = (debounceMs = 0): [string, (expr: string) => ); return [expr, handleChange]; }; - -export const isTargetMatched = ({ target }: TargetNode, matchExpression: string): boolean => { - try { - const res = evaluateTargetWithExpr(target, matchExpression); - if (typeof res === 'boolean') { - return res; - } - return false; - } catch (err) { - return false; - } -}; diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index dc3c580859..040dc3a081 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -170,11 +170,6 @@ export const getDisplayFieldName = (fieldName: string) => { .join(' '); }; -export const evaluateTargetWithExpr = (target: unknown, matchExpression: string) => { - const f = new Function('target', `return ${matchExpression}`); - return f(_.cloneDeep(target)); -}; - export const portalRoot = document.getElementById('portal-root') || document.body; export const cleanDataId = (key: string): string => {