diff --git a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx index bf74a94bb..44b07769b 100644 --- a/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx +++ b/src/app/Archives/AllTargetsArchivedRecordingsTable.tsx @@ -45,7 +45,7 @@ import { TableComposable, Th, Thead, Tbody, Tr, Td, ExpandableRowContent } from import SearchIcon from '@patternfly/react-icons/dist/esm/icons/search-icon'; import { ArchivedRecordingsTable } from '@app/Recordings/ArchivedRecordingsTable'; import { of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { concatMap, map } from 'rxjs/operators'; import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; import { LoadingView } from '@app/LoadingView/LoadingView'; import _ from 'lodash'; @@ -72,17 +72,15 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent { - let idx = 0; - for (const t of targets) { - if (t.connectUrl === connectUrl) { + for(let i = 0; i < targets.length; i++) { + if(targets[i].connectUrl === connectUrl) { setCounts(old => { let updated = [...old]; - updated[idx] += delta; + updated[i] += delta; return updated; }); break; } - idx++; } }, [targets, setCounts]); @@ -146,14 +144,43 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent { - searchedTargetsRef.current = searchedTargets; - }); + const handleLostTarget = React.useCallback((target: Target) => { + let idx; + setTargets(old => { + for (idx = 0; idx < old.length; idx++) { + if (_.isEqual(target, old[idx])) break; + } + return old.filter(o => !_.isEqual(o, target)); + }); + setExpandedTargets(old => old.filter(o => !_.isEqual(o, target))); + setCounts(old => { + let updated = [...old]; + updated.splice(idx, 1); + return updated; + }); + }, [setTargets, setExpandedTargets, setCounts]); + + const handleTargetNotification = React.useCallback((evt: TargetDiscoveryEvent) => { + const target: Target = { + connectUrl: evt.serviceRef.connectUrl, + alias: evt.serviceRef.alias, + } + if (evt.kind === 'FOUND') { + setTargets(old => old.concat(target)); + getCountForNewTarget(target); + } else if (evt.kind === 'LOST') { + handleLostTarget(target); + } + }, [setTargets, getCountForNewTarget, handleLostTarget]); React.useEffect(() => { refreshTargetsAndCounts(); }, []); + React.useEffect(() => { + searchedTargetsRef.current = searchedTargets; + }); + React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { return; @@ -172,31 +199,17 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent { addSubscription( context.notificationChannel.messages(NotificationCategory.TargetJvmDiscovery) - .subscribe(v => { - const evt: TargetDiscoveryEvent = v.message.event; - const target: Target = { - connectUrl: evt.serviceRef.connectUrl, - alias: evt.serviceRef.alias, - } - if (evt.kind === 'FOUND') { - setTargets(old => old.concat(target)); - getCountForNewTarget(target); - } else if (evt.kind === 'LOST') { - const idx = targets.indexOf(target); - setTargets(old => old.filter(o => o.connectUrl != target.connectUrl)); - setExpandedTargets(old => old.filter(o => o != target)); - setCounts(old => old.splice(idx, 1)); - } - }) + .pipe(concatMap(v => of(handleTargetNotification(v.message.event)))) + .subscribe(() => {} /* do nothing - callback will have already handled updating state */) ); - }, [addSubscription, context, context.notificationChannel, getCountForNewTarget, setTargets, setCounts]); + }, [addSubscription, context, context.notificationChannel, handleTargetNotification]); React.useEffect(() => { addSubscription( @@ -265,7 +278,7 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent ); }); - }, [targets, expandedTargets, counts, searchedTargets, hideEmptyTargets]); + }, [targets, expandedTargets, counts, isHidden]); const recordingRows = React.useMemo(() => { return targets.map((target, idx) => { @@ -288,7 +301,7 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent ); }); - }, [targets, expandedTargets, searchedTargets, hideEmptyTargets, counts]); + }, [targets, expandedTargets, isHidden]); const rowPairs = React.useMemo(() => { let rowPairs: JSX.Element[] = []; @@ -324,7 +337,7 @@ export const AllTargetsArchivedRecordingsTable: React.FunctionComponent {tableColumns.map((key) => ( - {key} + {key} ))} diff --git a/src/app/SecurityPanel/Credentials/CredentialsTableRow.tsx b/src/app/SecurityPanel/Credentials/CredentialsTableRow.tsx deleted file mode 100644 index ecc1c0ecf..000000000 --- a/src/app/SecurityPanel/Credentials/CredentialsTableRow.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright The Cryostat Authors - * - * The Universal Permissive License (UPL), Version 1.0 - * - * Subject to the condition set forth below, permission is hereby granted to any - * person obtaining a copy of this software, associated documentation and/or data - * (collectively the "Software"), free of charge and under any and all copyright - * rights in the Software, and any and all patent rights owned or freely - * licensable by each licensor hereunder covering either (i) the unmodified - * Software as contributed to or provided by such licensor, or (ii) the Larger - * Works (as defined below), to deal in both - * - * (a) the Software, and - * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if - * one is included with the Software (each a "Larger Work" to which the Software - * is contributed by such licensors), - * - * without restriction, including without limitation the rights to copy, create - * derivative works of, display, perform, and distribute the Software and make, - * use, sell, offer for sale, import, export, have made, and have sold the - * Software and the Larger Work(s), and to sublicense the foregoing rights on - * either these or other terms. - * - * This license is subject to the following condition: - * The above copyright notice and either this complete permission notice or at - * a minimum a reference to the UPL must be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import * as React from 'react'; -import { Checkbox } from '@patternfly/react-core'; -import { Tbody, Td, Tr } from '@patternfly/react-table'; - -export interface CredentialsTableRowProps { - key: number; - index: number; - matchExpression: string; - isChecked: boolean; - label: string; - handleCheck: (state: boolean, index: number) => void; -} - -export const CredentialsTableRow: React.FunctionComponent = (props: CredentialsTableRowProps) => { - - const handleCheck = React.useCallback((checked: boolean) => { - props.handleCheck(checked, props.index); - }, [props, props.handleCheck]); - - return ( - - - - - - - {props.matchExpression} - - - - ); -}; diff --git a/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx b/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx new file mode 100644 index 000000000..47834d6d3 --- /dev/null +++ b/src/app/SecurityPanel/Credentials/MatchedTargetsTable.tsx @@ -0,0 +1,150 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { Target } from '@app/Shared/Services/Target.service'; +import { EmptyState, EmptyStateIcon, Title } from '@patternfly/react-core'; +import { LoadingView } from '@app/LoadingView/LoadingView'; +import { SearchIcon } from '@patternfly/react-icons'; +import { InnerScrollContainer, TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; +import _ from 'lodash'; + +export interface MatchedTargetsTableProps { + id: number, + matchExpression: string, +} + +export const MatchedTargetsTable: React.FunctionComponent = (props) => { + const context = React.useContext(ServiceContext); + + const [targets, setTargets] = React.useState([] as Target[]); + const [isLoading, setIsLoading] = React.useState(false); + const addSubscription = useSubscriptions(); + + const tableColumns: string[] = [ + 'Target', + ]; + + const refreshTargetsList = React.useCallback(() => { + setIsLoading(true); + addSubscription( + context.api.getCredential(props.id) + .subscribe( + v => { + setTargets(v.targets); + setIsLoading(false); + } + ) + ); + }, [setIsLoading, addSubscription, context, context.api, setTargets]); + + React.useEffect(() => { + refreshTargetsList(); + }, []); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.TargetJvmDiscovery) + .subscribe(v => { + const evt: TargetDiscoveryEvent = v.message.event; + const target: Target = evt.serviceRef; + if (evt.kind === 'FOUND') { + const match: boolean = eval(props.matchExpression); + if (match) { + setTargets(old => old.concat(target)); + } + } else if (evt.kind === 'LOST') { + setTargets(old => old.filter(o => !_.isEqual(o, target))); + } + }) + ); + }, [addSubscription, context, context.notificationChannel, setTargets]); + + const targetRows = React.useMemo(() => { + return targets.map((target, idx) => { + return ( + + + {(target.alias == target.connectUrl) || !target.alias ? + `${target.connectUrl}` + : + `${target.alias} (${target.connectUrl})`} + + + ); + }); + }, [targets]); + + let view: JSX.Element; + if (isLoading) { + view = (); + } else if (targets.length === 0) { + view =(<> + + + + No Targets + + + ); + } else { + view = (<> + + + + + {tableColumns.map((key) => ( + {key} + ))} + + + + {targetRows} + + + + ); + } + + return (<> + {view} + ); +}; diff --git a/src/app/SecurityPanel/Credentials/StoreJmxCredentials.tsx b/src/app/SecurityPanel/Credentials/StoreJmxCredentials.tsx index 643cbf20c..06d419ae1 100644 --- a/src/app/SecurityPanel/Credentials/StoreJmxCredentials.tsx +++ b/src/app/SecurityPanel/Credentials/StoreJmxCredentials.tsx @@ -36,11 +36,8 @@ * SOFTWARE. */ import * as React from 'react'; -import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; -import { StoredCredential } from '@app/Shared/Services/Api.service'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; import { + Badge, Button, Checkbox, EmptyState, @@ -50,86 +47,202 @@ import { ToolbarContent, ToolbarItem, } from '@patternfly/react-core'; +import { ExpandableRowContent, TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { SearchIcon } from '@patternfly/react-icons'; -import { forkJoin, Observable } from 'rxjs'; - -import { TableComposable, Th, Thead, Tr } from '@patternfly/react-table'; +import { concatMap, forkJoin, Observable, of } from 'rxjs'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { Target } from '@app/Shared/Services/Target.service'; +import { StoredCredential } from '@app/Shared/Services/Api.service'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; import { CreateJmxCredentialModal } from './CreateJmxCredentialModal'; import { SecurityCard } from '../SecurityPanel'; -import { CredentialsTableRow } from './CredentialsTableRow'; import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; import { LoadingView } from '@app/LoadingView/LoadingView'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; +import { MatchedTargetsTable } from './MatchedTargetsTable'; +import _ from 'lodash'; + +const enum Actions { + HANDLE_REFRESH, + HANDLE_TARGET_NOTIFICATION, + HANDLE_CREDENTIALS_STORED_NOTIFICATION, + HANDLE_CREDENTIALS_DELETED_NOTIFICATION, + HANDLE_ROW_CHECK, + HANDLE_HEADER_CHECK, + HANDLE_TOGGLE_EXPANDED, +} + +const reducer = (state, action) => { + switch(action.type) { + case Actions.HANDLE_REFRESH: { + const credentials: StoredCredential[] = action.payload.credentials; + let counts: number[] = []; + for (const c of credentials) { + counts.push(c.numMatchingTargets); + } + return { + ...state, + credentials: credentials, + counts: counts + } + } + case Actions.HANDLE_TARGET_NOTIFICATION: { + const target: Target = action.payload.target; + let updated = [...state.counts]; + for (let i = 0; i < state.credentials.length; i++) { + let match: boolean = eval(state.credentials[i].matchExpression); + if (match) { + updated[i] += (action.payload.kind === 'FOUND' ? 1 : -1); + } + } + return { + ...state, + counts: updated + } + } + case Actions.HANDLE_CREDENTIALS_STORED_NOTIFICATION: { + return { + ...state, + credentials: state.credentials.concat(action.payload.credential), + counts: state.counts.concat(action.payload.credential.numMatchingTargets) + } + } + case Actions.HANDLE_CREDENTIALS_DELETED_NOTIFICATION: { + const deletedCredential: StoredCredential = action.payload.credential; + let deletedIdx; + for (deletedIdx = 0; deletedIdx < state.credentials.length; deletedIdx++) { + if (_.isEqual(deletedCredential, state.credentials[deletedIdx])) break; + } + const updatedCounts = [...state.counts]; + updatedCounts.splice(deletedIdx, 1); + const updatedCheckedCredentials = state.checkedCredentials.filter(o => !_.isEqual(o, deletedCredential)); + + return { + ...state, + credentials: state.credentials.filter(o => !_.isEqual(o, deletedCredential)), + expandedCredentials: state.expandedCredentials.filter(o => !_.isEqual(o, deletedCredential)), + checkedCredentials: updatedCheckedCredentials, + isHeaderChecked: updatedCheckedCredentials.length === 0 ? false : state.isHeaderChecked, + counts: updatedCounts + } + } + case Actions.HANDLE_ROW_CHECK: { + if (action.payload.checked) { + return { + ...state, + checkedCredentials: state.checkedCredentials.concat(action.payload.credential) + } + } else { + return { + ...state, + checkedCredentials: state.checkedCredentials.filter(o => !_.isEqual(o, action.payload.credential)), + isHeaderChecked: false + } + } + } + case Actions.HANDLE_HEADER_CHECK: { + return { + ...state, + checkedCredentials: action.payload.checked ? [...state.credentials] : [], + isHeaderChecked: action.payload.checked + } + } + case Actions.HANDLE_TOGGLE_EXPANDED: { + const credential: StoredCredential = action.payload.credential; + const idx = state.expandedCredentials.indexOf(credential); + const updated = idx >= 0 ? [...state.expandedCredentials.slice(0, idx), ...state.expandedCredentials.slice(idx + 1, state.expandedCredentials.length)] : [...state.expandedCredentials, credential]; + + return { + ...state, + expandedCredentials: updated + } + } + default: { + return state; + } + } +}; export const StoreJmxCredentials = () => { const context = React.useContext(ServiceContext); - const addSubscription = useSubscriptions(); - - const [credentials, setCredentials] = React.useState([] as StoredCredential[]); - const [headerChecked, setHeaderChecked] = React.useState(false); - const [checkedIndices, setCheckedIndices] = React.useState([] as number[]); + const [state, dispatch] = React.useReducer(reducer, { + credentials: [] as StoredCredential[], + expandedCredentials: [] as StoredCredential[], + checkedCredentials: [] as StoredCredential[], + isHeaderChecked: false, + counts: [] as number[] + }); const [showAuthModal, setShowAuthModal] = React.useState(false); const [warningModalOpen, setWarningModalOpen] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); + const addSubscription = useSubscriptions(); - const tableColumns: string[] = ['Match Expression']; + const tableColumns: string[] = ['Match Expression', 'Count']; const tableTitle = 'Stored Credentials'; - const refreshStoredTargetsList = React.useCallback(() => { + const refreshStoredCredentialsAndCounts = React.useCallback(() => { setIsLoading(true); addSubscription(context.api.getCredentials().subscribe((credentials: StoredCredential[]) => { - setCredentials(credentials); + dispatch({ type: Actions.HANDLE_REFRESH, payload: { credentials: credentials }}); setIsLoading(false); })); - }, [context, context.api, setIsLoading, setCredentials]); + }, [addSubscription, context, context.api, setIsLoading]); React.useEffect(() => { - refreshStoredTargetsList(); + refreshStoredCredentialsAndCounts(); }, []); React.useEffect(() => { - addSubscription(context.notificationChannel.messages(NotificationCategory.CredentialsStored).subscribe((msg) => { - setCredentials(old => old.concat([msg.message])); - })); - }, [context, context.notificationChannel, setCredentials]); + if (!context.settings.autoRefreshEnabled()) { + return; + } + const id = window.setInterval(() => refreshStoredCredentialsAndCounts(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits()); + return () => window.clearInterval(id); + }, [context.target, context.settings, refreshStoredCredentialsAndCounts]); React.useEffect(() => { - addSubscription(context.notificationChannel.messages(NotificationCategory.CredentialsDeleted).subscribe((v) => { - setCredentials(old => old.filter(c => c.matchExpression !== v.message.matchExpression)); + addSubscription(context.notificationChannel.messages(NotificationCategory.CredentialsStored).subscribe((v) => { + dispatch({ type: Actions.HANDLE_CREDENTIALS_STORED_NOTIFICATION, payload: { credential: v.message }}); })); - }, [context, context.notificationChannel, setCredentials]); + }, [addSubscription, context, context.notificationChannel]); - const handleRowCheck = React.useCallback( - (checked, index) => { - if (checked) { - setCheckedIndices((ci) => [...ci, index]); - } else { - setHeaderChecked(false); - setCheckedIndices((ci) => ci.filter((v) => v !== index)); - } - }, - [setCheckedIndices, setHeaderChecked] - ); - - const handleHeaderCheck = React.useCallback( - (event, checked) => { - setHeaderChecked(checked); - setCheckedIndices(checked ? Array.from(new Array(targetRows.length), (x, i) => i) : []); - }, - [setHeaderChecked, setCheckedIndices, credentials] - ); - - const handleDeleteCredentials = () => { + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.CredentialsDeleted) + .pipe(concatMap(v => of(dispatch({ type: Actions.HANDLE_CREDENTIALS_DELETED_NOTIFICATION, payload: { credential: v.message }})))) + .subscribe(() => {} /* do nothing - dispatch will have already handled updating state */) + ); + }, [addSubscription, context, context.notificationChannel]); + + const handleTargetNotification = (evt: TargetDiscoveryEvent) => { + if (evt.kind === 'FOUND' || evt.kind === 'LOST') { + 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(() => {} /* do nothing - dispatch will have already handled updating state */) + ); + }, [addSubscription, context, context.notificationChannel]); + + const handleHeaderCheck = React.useCallback((event, checked) => { + dispatch({ type: Actions.HANDLE_HEADER_CHECK, payload: { checked: checked }}); + }, []); + + const handleDeleteCredentials = React.useCallback(() => { const tasks: Observable[] = []; - credentials.forEach((credential, idx) => { - if (checkedIndices.includes(idx)) { - handleRowCheck(false, idx); + state.credentials.forEach((credential) => { + if (state.checkedCredentials.includes(credential)) { tasks.push(context.api.deleteCredentials(credential.id)); } }); addSubscription(forkJoin(tasks).subscribe()); - }; + }, [state.credentials, state.checkedCredentials, context, context.api, addSubscription]); const handleAuthModalOpen = React.useCallback(() => { setShowAuthModal(true); @@ -158,7 +271,7 @@ export const StoreJmxCredentials = () => { , - , ]; @@ -169,7 +282,7 @@ export const StoreJmxCredentials = () => { ))} ); - }, [checkedIndices]); + }, [state.checkedCredentials]); const deleteCredentialModal = React.useMemo(() => { return { onAccept={handleDeleteCredentials} onClose={handleWarningModalClose} /> - }, [checkedIndices]); + }, [state.checkedCredentials]); return ( @@ -188,31 +301,94 @@ export const StoreJmxCredentials = () => { ); }; - const handleCheck = React.useCallback((checked, index) => { - handleRowCheck(checked, index); - }, [handleRowCheck]); + const matchExpressionRows = React.useMemo(() => { + return state.credentials.map((credential, idx) => { + let isExpanded: boolean = state.expandedCredentials.includes(credential); + let isChecked: boolean = state.checkedCredentials.includes(credential); + + const handleToggleExpanded = () => { + if (state.counts[idx] !== 0 || isExpanded) { + dispatch({ type: Actions.HANDLE_TOGGLE_EXPANDED, payload: { credential: credential } }); + } + }; + + const handleRowCheck = (checked: boolean) => { + dispatch({ type: Actions.HANDLE_ROW_CHECK, payload: { checked: checked, credential: credential }}); + }; + + return ( + + + + + + + {credential.matchExpression} + + + + {state.counts[idx]} + + + + ); + }); + }, [state.credentials, state.expandedCredentials, state.checkedCredentials, state.counts]); const targetRows = React.useMemo(() => { - const rows: JSX.Element[] = []; - for (var i = 0; i < credentials.length; i++) { - rows.push( handleCheck(state, index)} - />); - } - return rows; - }, [credentials, checkedIndices]); + return state.credentials.map((credential, idx) => { + let isExpanded: boolean = state.expandedCredentials.includes(credential); + + return ( + + + {isExpanded ? + + + + : + null + } + + + ); + }) + }, [state.credentials, state.expandedCredentials]); + + const rowPairs = React.useMemo(() => { + let rowPairs: JSX.Element[] = []; + for (let i = 0; i < matchExpressionRows.length; i++) { + rowPairs.push(matchExpressionRows[i]); + rowPairs.push(targetRows[i]); + } + return rowPairs; + }, [matchExpressionRows, targetRows]); let content: JSX.Element; if (isLoading) { content = (<> ); - } else if (credentials.length === 0) { + } else if (state.credentials.length === 0) { content = (<> @@ -226,19 +402,22 @@ export const StoreJmxCredentials = () => { + {tableColumns.map((key, idx) => ( - {key} + {key} ))} - {targetRows} + + {rowPairs} + ); } diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index e72ca2da0..d34f3a232 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -590,6 +590,19 @@ export class ApiService { ); } + getCredential(id: number) : Observable { + return this.sendRequest( + 'v2.2', `credentials/${id}`, + { + method: 'GET' + } + ).pipe( + concatMap(resp => resp.json()), + map((response: CredentialResponse) => response.data.result), + first() + ); + } + getCredentials() : Observable { return this.sendRequest( 'v2.2', `credentials`, @@ -706,6 +719,12 @@ interface AssetJwtResponse extends ApiV2Response { } } +interface CredentialResponse extends ApiV2Response { + data: { + result: MatchedCredential; + } +} + interface CredentialsResponse extends ApiV2Response { data: { result: StoredCredential[]; @@ -769,4 +788,10 @@ export interface Metadata { export interface StoredCredential { id: number; matchExpression: string; + numMatchingTargets: number; +} + +export interface MatchedCredential { + matchExpression: string; + targets: Target[]; } diff --git a/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap b/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap index e53b715ee..b0fe1f96d 100644 --- a/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap +++ b/src/test/Archives/__snapshots__/AllTargetsArchivedRecordingsTable.test.tsx.snap @@ -143,14 +143,14 @@ Array [ scope={null} /> Target diff --git a/src/test/SecurityPanel/Credentials/StoreJmxCredentials.test.tsx b/src/test/SecurityPanel/Credentials/StoreJmxCredentials.test.tsx index 1c28b8f8b..47afd3c97 100644 --- a/src/test/SecurityPanel/Credentials/StoreJmxCredentials.test.tsx +++ b/src/test/SecurityPanel/Credentials/StoreJmxCredentials.test.tsx @@ -41,14 +41,22 @@ import renderer, { act } from 'react-test-renderer'; import { render, screen, within } from '@testing-library/react'; import { of, throwError } from 'rxjs'; -import { StoredCredential } from '@app/Shared/Services/Api.service'; +import { MatchedCredential, StoredCredential } from '@app/Shared/Services/Api.service'; import '@testing-library/jest-dom'; import { Modal, ModalVariant } from '@patternfly/react-core'; import { NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; -const mockCredential: StoredCredential = { id: 0, matchExpression: 'target.connectUrl == "service:jmx:rmi://someUrl"' }; -const mockAnotherCredential: StoredCredential = { id: 1, matchExpression: 'target.connectUrl == "service:jmx:rmi://anotherUrl"' }; +const mockCredential: StoredCredential = { id: 0, matchExpression: 'target.connectUrl == "service:jmx:rmi://someUrl"', numMatchingTargets: 1 }; +const mockAnotherCredential: StoredCredential = { id: 1, matchExpression: 'target.connectUrl == "service:jmx:rmi://anotherUrl" || target.connectUrl == "service:jmx:rmi://anotherMatchUrl" || target.connectUrl == "service:jmx:rmi://yetAnotherMatchUrl"', numMatchingTargets: 2 }; + +const mockTarget: Target = { connectUrl: "service:jmx:rmi://someUrl", alias: "someAlias", }; +const mockAnotherTarget: Target = { connectUrl: "service:jmx:rmi://anotherUrl", alias: "anotherAlias", }; +const mockAnotherMatchingTarget: Target = { connectUrl: "service:jmx:rmi://anotherMatchUrl", alias: "anotherMatchAlias", }; +const mockYetAnotherMatchingTarget: Target = { connectUrl: "service:jmx:rmi://yetAnotherMatchUrl", alias: "yetAnotherMatchAlias" }; + +const mockMatchedCredentialResponse: MatchedCredential = { matchExpression: mockCredential.matchExpression, targets: [mockTarget] }; +const mockAnotherMatchedCredentialResponse: MatchedCredential = { matchExpression: mockAnotherCredential.matchExpression, targets: [mockAnotherTarget, mockAnotherMatchingTarget] }; jest.mock('@app/SecurityPanel/Credentials/CreateJmxCredentialModal', () => { return { @@ -69,7 +77,10 @@ jest.mock('@app/SecurityPanel/Credentials/CreateJmxCredentialModal', () => { }); jest.mock('@app/Shared/Services/NotificationChannel.service', () => { - const mockNotification = { message: { matchExpression: mockCredential.matchExpression } } as NotificationMessage; + const mockCredentialNotification = { message: mockCredential } as NotificationMessage; + const evt = { kind: 'LOST', serviceRef: mockTarget } as TargetDiscoveryEvent; + const mockLostTargetNotification = { message: { event: { kind: 'LOST', serviceRef: mockAnotherTarget } } } as NotificationMessage; + const mockFoundTargetNotification = { message: { event: { kind: 'FOUND', serviceRef: mockYetAnotherMatchingTarget } } } as NotificationMessage; return { ...jest.requireActual('@app/Shared/Services/NotificationChannel.service'), NotificationChannel: jest.fn(() => { @@ -78,17 +89,39 @@ jest.mock('@app/Shared/Services/NotificationChannel.service', () => { .fn() .mockReturnValueOnce(of()) // 'renders correctly' .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) - .mockReturnValueOnce(of(mockNotification)) // 'adds the correct table entry when a stored notification is received' + .mockReturnValueOnce(of(mockCredentialNotification)) // 'adds the correct table entry when a stored notification is received' + .mockReturnValueOnce(of()) .mockReturnValueOnce(of()) .mockReturnValueOnce(of()) // 'removes the correct table entry when a deletion notification is received' - .mockReturnValueOnce(of(mockNotification)) + .mockReturnValueOnce(of(mockCredentialNotification)) + .mockReturnValueOnce(of()) .mockReturnValueOnce(of()) // 'renders an empty table after receiving deletion notifications for all credentials' - .mockReturnValueOnce(of(mockNotification)) + .mockReturnValueOnce(of(mockCredentialNotification)) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // 'expands to show the correct nested targets' + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // 'decrements the correct count and updates the correct nested table when a lost target notification is received' + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of(mockLostTargetNotification)) + .mockReturnValueOnce(of(mockLostTargetNotification)) + .mockReturnValueOnce(of(mockLostTargetNotification)) + + .mockReturnValueOnce(of()) // 'increments the correct count and updates the correct nested table when a found target notification is received' + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of(mockFoundTargetNotification)) + .mockReturnValueOnce(of(mockFoundTargetNotification)) + .mockReturnValueOnce(of(mockFoundTargetNotification)) - .mockReturnValue(of()), // all other tests + .mockReturnValue(of()) // remaining tests }; }), }; @@ -103,7 +136,7 @@ jest.mock('@app/Shared/Services/Api.service', () => { .mockReturnValue(of(true)), getCredentials: jest .fn() - .mockReturnValueOnce(of([mockCredential])) // 'renders correctly' + .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'renders correctly' .mockReturnValueOnce(of([])) // 'adds the correct table entry when a stored notification is received' @@ -111,6 +144,12 @@ jest.mock('@app/Shared/Services/Api.service', () => { .mockReturnValueOnce(of([mockCredential])) // 'renders an empty table after receiving deletion notifications for all credentials' + .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'expands to show the correct nested targets' + + .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'decrements the correct count and updates the correct nested table when a lost target notification is received' + + .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'increments the correct count and updates the correct nested table when a found target notification is received' + .mockReturnValueOnce(of([])) // 'opens the JMX auth modal when Add is clicked' .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'shows a popup when Delete is clicked and makes a delete request when deleting one credential after confirming Delete' @@ -118,9 +157,18 @@ jest.mock('@app/Shared/Services/Api.service', () => { .mockReturnValueOnce(of([mockCredential, mockAnotherCredential])) // 'makes multiple delete requests when all credentials are deleted at once' .mockReturnValue(throwError(() => new Error('Too many calls'))), - deleteTargetCredentials: jest.fn(() => { - return of(true); - }), + getCredential: jest + .fn() + .mockReturnValueOnce(of(mockMatchedCredentialResponse)) // 'expands to show the correct nested targets' + .mockReturnValueOnce(of(mockAnotherMatchedCredentialResponse)) + + .mockReturnValueOnce(of(mockMatchedCredentialResponse)) // 'decrements the correct count and updates the correct nested table when a lost target notification is received' + .mockReturnValueOnce(of(mockAnotherMatchedCredentialResponse)) + + .mockReturnValueOnce(of(mockMatchedCredentialResponse)) // 'increments the correct count and updates the correct nested table when a found target notification is received' + .mockReturnValueOnce(of(mockAnotherMatchedCredentialResponse)) + + .mockReturnValue(throwError(() => new Error('Too many calls'))), }; }), }; @@ -129,6 +177,8 @@ jest.mock('@app/Shared/Services/Api.service', () => { import { StoreJmxCredentials } from '@app/SecurityPanel/Credentials/StoreJmxCredentials'; import { ServiceContext, defaultServices } from '@app/Shared/Services/Services'; import { DeleteJMXCredentials, DeleteWarningType } from '@app/Modal/DeleteWarningUtils'; +import { Target } from '@app/Shared/Services/Target.service'; +import { TargetDiscoveryEvent } from '@app/Shared/Services/Targets.service'; jest.spyOn(defaultServices.settings, 'deletionDialogsEnabledFor') .mockReturnValueOnce(true); @@ -158,6 +208,7 @@ describe('', () => { ); expect(screen.getByText(mockCredential.matchExpression)).toBeInTheDocument(); + expect(screen.getByText(mockCredential.numMatchingTargets)).toBeInTheDocument(); expect(apiRequestSpy).toHaveBeenCalledTimes(1); }); @@ -170,7 +221,9 @@ describe('', () => { ); expect(screen.queryByText(mockCredential.matchExpression)).not.toBeInTheDocument(); + expect(screen.queryByText(mockCredential.numMatchingTargets)).not.toBeInTheDocument(); expect(screen.getByText(mockAnotherCredential.matchExpression)).toBeInTheDocument(); + expect(screen.getByText(mockAnotherCredential.numMatchingTargets)).toBeInTheDocument(); expect(apiRequestSpy).toHaveBeenCalledTimes(1); }); @@ -183,11 +236,88 @@ describe('', () => { ); expect(screen.queryByText(mockCredential.matchExpression)).not.toBeInTheDocument(); + expect(screen.queryByText(mockCredential.numMatchingTargets)).not.toBeInTheDocument(); expect(screen.queryByText(mockAnotherCredential.matchExpression)).not.toBeInTheDocument(); + expect(screen.queryByText(mockAnotherCredential.numMatchingTargets)).not.toBeInTheDocument(); expect(screen.getByText('No Stored Credentials')).toBeInTheDocument(); expect(apiRequestSpy).toHaveBeenCalledTimes(1); }); + it('expands to show the correct nested targets', () => { + render( + + + + ); + + expect(screen.queryByText('Target')).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockTarget.alias} (${mockTarget.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockAnotherTarget.alias} (${mockAnotherTarget.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockAnotherMatchingTarget.alias} (${mockAnotherMatchingTarget.connectUrl})`)).not.toBeInTheDocument(); + + const expandButtons = screen.getAllByLabelText('Details'); + + userEvent.click(expandButtons[0]); + + expect(screen.getByText('Target')).toBeInTheDocument(); + expect(screen.getByText(`${mockTarget.alias} (${mockTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.queryByText(`${mockAnotherTarget.alias} (${mockAnotherTarget.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockAnotherMatchingTarget.alias} (${mockAnotherMatchingTarget.connectUrl})`)).not.toBeInTheDocument(); + + userEvent.click(expandButtons[1]); + + expect(screen.getByText(`${mockAnotherTarget.alias} (${mockAnotherTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockAnotherMatchingTarget.alias} (${mockAnotherMatchingTarget.connectUrl})`)).toBeInTheDocument(); + }); + + it('decrements the correct count and updates the correct nested table when a lost target notification is received', async () => { + render( + + + + ); + + // both counts should now be equal to 1 + const counts = screen.getAllByText(mockAnotherCredential.numMatchingTargets - 1); + expect(within(counts[0]).getByText(mockCredential.numMatchingTargets)).toBeTruthy(); + expect(within(counts[1]).getByText(mockAnotherCredential.numMatchingTargets - 1)).toBeTruthy(); + + const expandButtons = screen.getAllByLabelText('Details'); + + userEvent.click(expandButtons[0]); + + expect(screen.getByText(`${mockTarget.alias} (${mockTarget.connectUrl})`)).toBeInTheDocument(); + + userEvent.click(expandButtons[1]); + + expect(screen.queryByText(`${mockAnotherTarget.alias} (${mockAnotherTarget.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.getByText(`${mockAnotherMatchingTarget.alias} (${mockAnotherMatchingTarget.connectUrl})`)).toBeInTheDocument(); + }); + + it('increments the correct count and updates the correct nested table when a found target notification is received', () => { + render( + + + + ); + + expect(screen.getByText(mockCredential.numMatchingTargets)).toBeInTheDocument(); + expect(screen.getByText(mockAnotherCredential.numMatchingTargets + 1)).toBeInTheDocument(); + + const expandButtons = screen.getAllByLabelText('Details'); + + userEvent.click(expandButtons[0]); + + expect(screen.getByText(`${mockTarget.alias} (${mockTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.queryByText(`${mockYetAnotherMatchingTarget.alias} (${mockYetAnotherMatchingTarget.connectUrl})`)).not.toBeInTheDocument(); + + userEvent.click(expandButtons[1]); + + expect(screen.getByText(`${mockAnotherTarget.alias} (${mockAnotherTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockAnotherMatchingTarget.alias} (${mockAnotherMatchingTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockYetAnotherMatchingTarget.alias} (${mockYetAnotherMatchingTarget.connectUrl})`)).toBeInTheDocument(); + }); + it('opens the JMX auth modal when Add is clicked', () => { render( @@ -237,8 +367,10 @@ describe('', () => { ); - expect(screen.getByText(mockAnotherCredential.matchExpression)).toBeInTheDocument(); expect(screen.getByText(mockCredential.matchExpression)).toBeInTheDocument(); + expect(screen.getByText(mockCredential.numMatchingTargets)).toBeInTheDocument(); + expect(screen.getByText(mockAnotherCredential.matchExpression)).toBeInTheDocument(); + expect(screen.getByText(mockAnotherCredential.numMatchingTargets)).toBeInTheDocument(); const checkboxes = screen.getAllByRole('checkbox'); const selectAllCheck = checkboxes[0]; diff --git a/src/test/SecurityPanel/Credentials/__snapshots__/StoreJmxCredentials.test.tsx.snap b/src/test/SecurityPanel/Credentials/__snapshots__/StoreJmxCredentials.test.tsx.snap index 38cb8b6bd..102146438 100644 --- a/src/test/SecurityPanel/Credentials/__snapshots__/StoreJmxCredentials.test.tsx.snap +++ b/src/test/SecurityPanel/Credentials/__snapshots__/StoreJmxCredentials.test.tsx.snap @@ -89,6 +89,11 @@ Array [ data-ouia-safe={true} hidden={false} > + Match Expression + + Count + + + + @@ -152,6 +208,132 @@ Array [ > target.connectUrl == "service:jmx:rmi://someUrl" + + + 1 + + + + + + + + + + + +
+ +
+ + + target.connectUrl == "service:jmx:rmi://anotherUrl" || target.connectUrl == "service:jmx:rmi://anotherMatchUrl" || target.connectUrl == "service:jmx:rmi://yetAnotherMatchUrl" + + + + 2 + + + + + ,