diff --git a/locales/en/public.json b/locales/en/public.json index 9951ad069..a2d8e15c2 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -37,6 +37,16 @@ "SEARCH_PLACEHOLDER": "Find by connection URL or alias...", "TARGET_DISPLAY": "{{alias}} ({{connectUrl}})" }, + "AllTargetsThreadDumpsTable": { + "HIDE_TARGET_WITH_ZERO_DUMPS": "Hide targets with zero Thread Dumps", + "SEARCH_PLACEHOLDER": "Find by JVM ID...", + "TARGET_DISPLAY": "{{alias}} ({{connectUrl}})" + }, + "AllTargetsHeapDumpsTable": { + "HIDE_TARGET_WITH_ZERO_DUMPS": "Hide targets with zero Heap Dumps", + "SEARCH_PLACEHOLDER": "Find by JVM ID...", + "TARGET_DISPLAY": "{{alias}} ({{connectUrl}})" + }, "AppLayout": { "APP_LAUNCHER": { "ABOUT": "About", @@ -392,7 +402,8 @@ "ARIA_LABELS": { "ROW_ACTION": "heap-dump-action-menu", "SEARCH_INPUT": "heap-dump-search-input" - } + }, + "NO_ARCHIVES": "No Heap Dumps" }, "ThreadDumps": { "SEARCH_PLACEHOLDER": "Search Thread Dumps", @@ -401,7 +412,8 @@ "ARIA_LABELS": { "ROW_ACTION": "thread-dump-action-menu", "SEARCH_INPUT": "thread-dump-search-input" - } + }, + "NO_ARCHIVES": "No Thread Dumps" }, "DurationFilter": { "ARIA_LABELS": { diff --git a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx new file mode 100644 index 000000000..1ff87140a --- /dev/null +++ b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx @@ -0,0 +1,550 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target, TargetDiscoveryEvent, NotificationCategory, HeapDump } from '@app/Shared/Services/api.types'; +import { isEqualTarget, indexOfTarget, includesTarget } from '@app/Shared/Services/api.utils'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { hashCode, sortResources, TableColumn } from '@app/utils/utils'; +import { useCryostatTranslation } from '@i18n/i18nextUtil'; +import { + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + SearchInput, + Checkbox, + EmptyState, + EmptyStateIcon, + EmptyStateHeader, + Button, + Icon, + Bullseye, +} from '@patternfly/react-core'; +import { FileIcon, SearchIcon } from '@patternfly/react-icons'; +import { + Table, + Th, + Thead, + Tbody, + Tr, + Td, + ExpandableRowContent, + SortByDirection, + OuterScrollContainer, + InnerScrollContainer, +} from '@patternfly/react-table'; +import { TFunction } from 'i18next'; +import _ from 'lodash'; +import * as React from 'react'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { HeapDumpsTable } from './HeapDumpsTable'; + +const tableColumns: TableColumn[] = [ + { + title: 'Target', + keyPaths: ['target'], + transform: (target: Target, _obj: ArchivesForTarget, t?: TFunction) => { + return target.alias === target.connectUrl || !target.alias + ? `${target.connectUrl}` + : t + ? t('AllTargetsHeapDumpsTable.TARGET_DISPLAY', { + alias: target.alias, + connectUrl: target.connectUrl, + }) + : `${target.alias} (${target.connectUrl})`; + }, + sortable: true, + width: 80, + }, + { + title: 'Archives', + keyPaths: ['archiveCount'], + sortable: true, + width: 15, + }, +]; + +type ArchivesForTarget = { + target: Target; + targetAsObs: Observable; + archiveCount: number; + heapDumps: HeapDump[]; +}; + +export interface AllTargetsHeapDumpsTableProps {} + +export const AllTargetsHeapDumpsTable: React.FC = () => { + const context = React.useContext(ServiceContext); + const { t } = useCryostatTranslation(); + + const [searchText, setSearchText] = React.useState(''); + const [archivesForTargets, setArchivesForTargets] = React.useState([]); + const [expandedTargets, setExpandedTargets] = React.useState([] as Target[]); + const [hideEmptyTargets, setHideEmptyTargets] = React.useState(true); + const [errorMessage, setErrorMessage] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + const addSubscription = useSubscriptions(); + const [sortBy, getSortParams] = useSort(); + + const handleNotification = React.useCallback( + (_: string, heapDump: HeapDump, delta: number) => { + setArchivesForTargets((old) => { + const matchingTargets = old.filter(({ target }) => { + return target.jvmId === heapDump.jvmId; + }); + for (const matchedTarget of matchingTargets) { + const targetIdx = old.findIndex(({ target }) => target.connectUrl === matchedTarget.target.connectUrl); + const heapDumps = [...matchedTarget.heapDumps]; + if (delta === 1) { + heapDumps.push(heapDump); + } else { + const heapDumpIdx = heapDumps.findIndex((t) => t.heapDumpId === heapDump.heapDumpId); + heapDumps.splice(heapDumpIdx, 1); + } + + old.splice(targetIdx, 1, { ...matchedTarget, archiveCount: matchedTarget.archiveCount + delta, heapDumps }); + } + + return [...old]; + }); + }, + [setArchivesForTargets], + ); + + const handleError = React.useCallback( + (error) => { + setIsLoading(false); + setErrorMessage(error.message); + }, + [setIsLoading, setErrorMessage], + ); + + const handleArchivesForTargets = React.useCallback( + (targetNodes: any[]) => { + setIsLoading(false); + setErrorMessage(''); + setArchivesForTargets( + targetNodes.map((node) => { + const target: Target = { + agent: node.target.agent, + id: node.target.id, + jvmId: node.target.jvmId, + connectUrl: node.target.connectUrl, + alias: node.target.alias, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, + }; + return { + target, + targetAsObs: of(target), + archiveCount: node?.archiveCount ?? 0, + heapDumps: node?.heapDumps ?? [], + }; + }), + ); + }, + [setArchivesForTargets, setIsLoading, setErrorMessage], + ); + + const refreshArchivesForTargets = React.useCallback(() => { + setIsLoading(true); + addSubscription( + context.api + .graphql( + `query AllTargetsHeapDumps { + targetNodes { + target { + agent + id + connectUrl + alias + jvmId + heapDumps { + data { + jvmId + downloadUrl + heapDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + ) + .pipe( + map((v) => { + return v.data?.targetNodes + ?.map((node) => { + const target: Target = node?.target; + return { + target, + targetAsObs: of(target), + archiveCount: node?.target?.heapDumps?.aggregate?.count ?? 0, + heapDumps: (node?.target?.heapDumps?.data as HeapDump[]) ?? [], + }; + }) + .filter((v) => !!v.target); + }), + ) + .subscribe({ + next: handleArchivesForTargets, + error: handleError, + }), + ); + }, [addSubscription, context.api, setIsLoading, handleArchivesForTargets, handleError]); + + const getCountForNewTarget = React.useCallback( + (target: Target) => { + addSubscription( + context.api + .graphql( + `query HeapDumpCountForTarget($id: BigInteger!) { + targetNodes(filter: { targetIds: [$id] }) { + target { + heapDumps { + data { + jvmId + downloadUrl + heapDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + { id: target.id! }, + ) + .subscribe((v) => { + setArchivesForTargets((old) => { + return [ + ...old, + { + target: target, + targetAsObs: of(target), + archiveCount: v.data.targetNodes[0]?.target?.heapDumps?.aggregate?.count ?? 0, + heapDumps: v.data.targetNodes[0]?.target?.heapDumps ?? [], + }, + ]; + }); + }), + ); + }, + [addSubscription, context.api], + ); + + const handleLostTarget = React.useCallback( + (target: Target) => { + setArchivesForTargets((old) => old.filter(({ target: t }) => !isEqualTarget(t, target))); + setExpandedTargets((old) => old.filter((t) => !isEqualTarget(t, target))); + }, + [setArchivesForTargets, setExpandedTargets], + ); + + const handleTargetNotification = React.useCallback( + (evt: TargetDiscoveryEvent) => { + if (evt.kind === 'FOUND') { + getCountForNewTarget(evt.serviceRef); + } else if (evt.kind === 'MODIFIED') { + setArchivesForTargets((old) => { + const idx = old.findIndex(({ target: t }) => isEqualTarget(t, evt.serviceRef)); + if (idx >= 0) { + const matched = old[idx]; + if ( + evt.serviceRef.connectUrl === matched.target.connectUrl && + evt.serviceRef.alias === matched.target.alias + ) { + // If alias and connectUrl are not updated, ignore changes. + return old; + } + return old.splice(idx, 1, { ...matched, target: evt.serviceRef, targetAsObs: of(evt.serviceRef) }); + } + return old; + }); + } else if (evt.kind === 'LOST') { + handleLostTarget(evt.serviceRef); + } + }, + [setArchivesForTargets, getCountForNewTarget, handleLostTarget], + ); + + const handleSearchInput = React.useCallback( + (_, searchInput: string) => { + setSearchText(searchInput); + }, + [setSearchText], + ); + + const handleHideEmptyTarget = React.useCallback( + (_, hide: boolean) => setHideEmptyTargets(hide), + [setHideEmptyTargets], + ); + + const handleSearchInputClear = React.useCallback(() => { + setSearchText(''); + }, [setSearchText]); + + const targetDisplay = React.useCallback( + (target: Target): string => { + const _transform = tableColumns[0].transform; + if (_transform) { + return `${_transform(target, undefined, t)}`; + } + // should not occur + return `${target.connectUrl}`; + }, + [t], + ); + + React.useEffect(() => { + refreshArchivesForTargets(); + }, [refreshArchivesForTargets]); + + const searchedArchivesForTargets = React.useMemo(() => { + let updated: ArchivesForTarget[] = archivesForTargets; + if (searchText) { + const reg = new RegExp(_.escapeRegExp(searchText), 'i'); + updated = archivesForTargets.filter(({ target }) => reg.test(targetDisplay(target))); + } + return sortResources( + { + index: sortBy.index ?? 0, + direction: sortBy.direction ?? SortByDirection.asc, + }, + updated.filter((v) => !hideEmptyTargets || v.archiveCount > 0), + tableColumns, + ); + }, [searchText, archivesForTargets, sortBy, hideEmptyTargets, targetDisplay]); + + React.useEffect(() => { + addSubscription( + context.target.authFailure().subscribe(() => { + setErrorMessage(authFailMessage); + }), + ); + }, [context, context.target, setErrorMessage, addSubscription]); + + React.useEffect(() => { + if (!context.settings.autoRefreshEnabled()) { + return; + } + const id = window.setInterval( + () => refreshArchivesForTargets(), + context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits(), + ); + return () => window.clearInterval(id); + }, [context.target, context.settings, refreshArchivesForTargets]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel + .messages(NotificationCategory.TargetJvmDiscovery) + .subscribe((v) => handleTargetNotification(v.message.event)), + ); + }, [addSubscription, context.notificationChannel, handleTargetNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.HeapDumpUploaded).subscribe((v) => { + handleNotification(v.message.jvmId, v.message.heapDump, 1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.HeapDumpDeleted).subscribe((v) => { + handleNotification(v.message.jvmId, v.message.heapDump, -1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + const toggleExpanded = React.useCallback( + (target) => { + const idx = indexOfTarget(expandedTargets, target); + setExpandedTargets((expandedTargets) => + idx >= 0 + ? [...expandedTargets.slice(0, idx), ...expandedTargets.slice(idx + 1, expandedTargets.length)] + : [...expandedTargets, target], + ); + }, + [expandedTargets, setExpandedTargets], + ); + + const targetRows = React.useMemo(() => { + return searchedArchivesForTargets.map(({ target, archiveCount }, idx) => { + const isExpanded: boolean = includesTarget(expandedTargets, target); + + return ( + + { + toggleExpanded(target); + }, + }} + /> + + {targetDisplay(target)} + + + + + + ); + }); + }, [toggleExpanded, searchedArchivesForTargets, expandedTargets, targetDisplay]); + + const recordingRows = React.useMemo(() => { + return searchedArchivesForTargets.map(({ target, targetAsObs }) => { + const isExpanded: boolean = includesTarget(expandedTargets, target); + const keyBase = hashCode(JSON.stringify(target)); + return ( + + + {isExpanded ? ( + + + + ) : null} + + + ); + }); + }, [searchedArchivesForTargets, expandedTargets]); + + const rowPairs = React.useMemo(() => { + const rowPairs: JSX.Element[] = []; + for (let i = 0; i < targetRows.length; i++) { + rowPairs.push(targetRows[i]); + rowPairs.push(recordingRows[i]); + } + return rowPairs; + }, [targetRows, recordingRows]); + + let view: JSX.Element; + + const authRetry = React.useCallback(() => { + context.target.setAuthRetry(); + }, [context.target]); + + const isError = React.useMemo(() => errorMessage != '', [errorMessage]); + + if (isError) { + view = ( + <> + + + ); + } else if (isLoading) { + view = ; + } else if (!searchedArchivesForTargets.length) { + view = ( + <> + + + } + headingLevel="h4" + /> + + + + ); + } else { + view = ( + + + + + ))} + + + {rowPairs} +
+ {tableColumns.map(({ title, width }, idx) => ( + ['width']} + sort={getSortParams(idx)} + > + {title} +
+ ); + } + + return ( + + + + + + + + + + + + + + {view} + + ); +}; diff --git a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx new file mode 100644 index 000000000..b4dc603f1 --- /dev/null +++ b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx @@ -0,0 +1,550 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { authFailMessage, isAuthFail } from '@app/ErrorView/types'; +import { LoadingView } from '@app/Shared/Components/LoadingView'; +import { Target, TargetDiscoveryEvent, NotificationCategory, ThreadDump } from '@app/Shared/Services/api.types'; +import { isEqualTarget, indexOfTarget, includesTarget } from '@app/Shared/Services/api.utils'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSort } from '@app/utils/hooks/useSort'; +import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; +import { hashCode, sortResources, TableColumn } from '@app/utils/utils'; +import { useCryostatTranslation } from '@i18n/i18nextUtil'; +import { + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, + SearchInput, + Checkbox, + EmptyState, + EmptyStateIcon, + EmptyStateHeader, + Button, + Icon, + Bullseye, +} from '@patternfly/react-core'; +import { FileIcon, SearchIcon } from '@patternfly/react-icons'; +import { + Table, + Th, + Thead, + Tbody, + Tr, + Td, + ExpandableRowContent, + SortByDirection, + OuterScrollContainer, + InnerScrollContainer, +} from '@patternfly/react-table'; +import { TFunction } from 'i18next'; +import _ from 'lodash'; +import * as React from 'react'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ThreadDumpsTable } from './ThreadDumpsTable'; + +const tableColumns: TableColumn[] = [ + { + title: 'Target', + keyPaths: ['target'], + transform: (target: Target, _obj: ArchivesForTarget, t?: TFunction) => { + return target.alias === target.connectUrl || !target.alias + ? `${target.connectUrl}` + : t + ? t('AllTargetsThreadDumpsTable.TARGET_DISPLAY', { + alias: target.alias, + connectUrl: target.connectUrl, + }) + : `${target.alias} (${target.connectUrl})`; + }, + sortable: true, + width: 80, + }, + { + title: 'Archives', + keyPaths: ['archiveCount'], + sortable: true, + width: 15, + }, +]; + +type ArchivesForTarget = { + target: Target; + targetAsObs: Observable; + archiveCount: number; + threadDumps: ThreadDump[]; +}; + +export interface AllTargetsThreadDumpsTableProps {} + +export const AllTargetsThreadDumpsTable: React.FC = () => { + const context = React.useContext(ServiceContext); + const { t } = useCryostatTranslation(); + + const [searchText, setSearchText] = React.useState(''); + const [archivesForTargets, setArchivesForTargets] = React.useState([]); + const [expandedTargets, setExpandedTargets] = React.useState([] as Target[]); + const [hideEmptyTargets, setHideEmptyTargets] = React.useState(true); + const [errorMessage, setErrorMessage] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + const addSubscription = useSubscriptions(); + const [sortBy, getSortParams] = useSort(); + + const handleNotification = React.useCallback( + (_: string, threadDump: ThreadDump, delta: number) => { + setArchivesForTargets((old) => { + const matchingTargets = old.filter(({ target }) => { + return target.jvmId === threadDump.jvmId; + }); + for (const matchedTarget of matchingTargets) { + const targetIdx = old.findIndex(({ target }) => target.connectUrl === matchedTarget.target.connectUrl); + const threadDumps = [...matchedTarget.threadDumps]; + if (delta === 1) { + threadDumps.push(threadDump); + } else { + const threadDumpIdx = threadDumps.findIndex((t) => t.threadDumpId === threadDump.threadDumpId); + threadDumps.splice(threadDumpIdx, 1); + } + + old.splice(targetIdx, 1, { ...matchedTarget, archiveCount: matchedTarget.archiveCount + delta, threadDumps }); + } + + return [...old]; + }); + }, + [setArchivesForTargets], + ); + + const handleError = React.useCallback( + (error) => { + setIsLoading(false); + setErrorMessage(error.message); + }, + [setIsLoading, setErrorMessage], + ); + + const handleArchivesForTargets = React.useCallback( + (targetNodes: any[]) => { + setIsLoading(false); + setErrorMessage(''); + setArchivesForTargets( + targetNodes.map((node) => { + const target: Target = { + agent: node.target.agent, + id: node.target.id, + jvmId: node.target.jvmId, + connectUrl: node.target.connectUrl, + alias: node.target.alias, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, + }; + return { + target, + targetAsObs: of(target), + archiveCount: node?.archiveCount ?? 0, + threadDumps: node?.threadDumps ?? [], + }; + }), + ); + }, + [setArchivesForTargets, setIsLoading, setErrorMessage], + ); + + const refreshArchivesForTargets = React.useCallback(() => { + setIsLoading(true); + addSubscription( + context.api + .graphql( + `query AllTargetsThreadDumps { + targetNodes { + target { + agent + id + connectUrl + alias + jvmId + threadDumps { + data { + jvmId + downloadUrl + threadDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + ) + .pipe( + map((v) => { + return v.data?.targetNodes + ?.map((node) => { + const target: Target = node?.target; + return { + target, + targetAsObs: of(target), + archiveCount: node?.target?.threadDumps?.aggregate?.count ?? 0, + threadDumps: (node?.target?.threadDumps?.data as ThreadDump[]) ?? [], + }; + }) + .filter((v) => !!v.target); + }), + ) + .subscribe({ + next: handleArchivesForTargets, + error: handleError, + }), + ); + }, [addSubscription, context.api, setIsLoading, handleArchivesForTargets, handleError]); + + const getCountForNewTarget = React.useCallback( + (target: Target) => { + addSubscription( + context.api + .graphql( + `query ThreadDumpCountForTarget($id: BigInteger!) { + targetNodes(filter: { targetIds: [$id] }) { + target { + threadDumps { + data { + jvmId + downloadUrl + threadDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + { id: target.id! }, + ) + .subscribe((v) => { + setArchivesForTargets((old) => { + return [ + ...old, + { + target: target, + targetAsObs: of(target), + archiveCount: v.data.targetNodes[0]?.target?.threadDumps?.aggregate?.count ?? 0, + threadDumps: v.data.targetNodes[0]?.target?.threadDumps ?? [], + }, + ]; + }); + }), + ); + }, + [addSubscription, context.api], + ); + + const handleLostTarget = React.useCallback( + (target: Target) => { + setArchivesForTargets((old) => old.filter(({ target: t }) => !isEqualTarget(t, target))); + setExpandedTargets((old) => old.filter((t) => !isEqualTarget(t, target))); + }, + [setArchivesForTargets, setExpandedTargets], + ); + + const handleTargetNotification = React.useCallback( + (evt: TargetDiscoveryEvent) => { + if (evt.kind === 'FOUND') { + getCountForNewTarget(evt.serviceRef); + } else if (evt.kind === 'MODIFIED') { + setArchivesForTargets((old) => { + const idx = old.findIndex(({ target: t }) => isEqualTarget(t, evt.serviceRef)); + if (idx >= 0) { + const matched = old[idx]; + if ( + evt.serviceRef.connectUrl === matched.target.connectUrl && + evt.serviceRef.alias === matched.target.alias + ) { + // If alias and connectUrl are not updated, ignore changes. + return old; + } + return old.splice(idx, 1, { ...matched, target: evt.serviceRef, targetAsObs: of(evt.serviceRef) }); + } + return old; + }); + } else if (evt.kind === 'LOST') { + handleLostTarget(evt.serviceRef); + } + }, + [setArchivesForTargets, getCountForNewTarget, handleLostTarget], + ); + + const handleSearchInput = React.useCallback( + (_, searchInput: string) => { + setSearchText(searchInput); + }, + [setSearchText], + ); + + const handleHideEmptyTarget = React.useCallback( + (_, hide: boolean) => setHideEmptyTargets(hide), + [setHideEmptyTargets], + ); + + const handleSearchInputClear = React.useCallback(() => { + setSearchText(''); + }, [setSearchText]); + + const targetDisplay = React.useCallback( + (target: Target): string => { + const _transform = tableColumns[0].transform; + if (_transform) { + return `${_transform(target, undefined, t)}`; + } + // should not occur + return `${target.connectUrl}`; + }, + [t], + ); + + React.useEffect(() => { + refreshArchivesForTargets(); + }, [refreshArchivesForTargets]); + + const searchedArchivesForTargets = React.useMemo(() => { + let updated: ArchivesForTarget[] = archivesForTargets; + if (searchText) { + const reg = new RegExp(_.escapeRegExp(searchText), 'i'); + updated = archivesForTargets.filter(({ target }) => reg.test(targetDisplay(target))); + } + return sortResources( + { + index: sortBy.index ?? 0, + direction: sortBy.direction ?? SortByDirection.asc, + }, + updated.filter((v) => !hideEmptyTargets || v.archiveCount > 0), + tableColumns, + ); + }, [searchText, archivesForTargets, sortBy, hideEmptyTargets, targetDisplay]); + + React.useEffect(() => { + addSubscription( + context.target.authFailure().subscribe(() => { + setErrorMessage(authFailMessage); + }), + ); + }, [context, context.target, setErrorMessage, addSubscription]); + + React.useEffect(() => { + if (!context.settings.autoRefreshEnabled()) { + return; + } + const id = window.setInterval( + () => refreshArchivesForTargets(), + context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits(), + ); + return () => window.clearInterval(id); + }, [context.target, context.settings, refreshArchivesForTargets]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel + .messages(NotificationCategory.TargetJvmDiscovery) + .subscribe((v) => handleTargetNotification(v.message.event)), + ); + }, [addSubscription, context.notificationChannel, handleTargetNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.ThreadDumpSuccess).subscribe((v) => { + handleNotification(v.message.jvmId, v.message.threadDump, 1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.ThreadDumpDeleted).subscribe((v) => { + handleNotification(v.message.jvmId, v.message.threadDump, -1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + const toggleExpanded = React.useCallback( + (target) => { + const idx = indexOfTarget(expandedTargets, target); + setExpandedTargets((expandedTargets) => + idx >= 0 + ? [...expandedTargets.slice(0, idx), ...expandedTargets.slice(idx + 1, expandedTargets.length)] + : [...expandedTargets, target], + ); + }, + [expandedTargets, setExpandedTargets], + ); + + const targetRows = React.useMemo(() => { + return searchedArchivesForTargets.map(({ target, archiveCount }, idx) => { + const isExpanded: boolean = includesTarget(expandedTargets, target); + + return ( + + { + toggleExpanded(target); + }, + }} + /> + + {targetDisplay(target)} + + + + + + ); + }); + }, [toggleExpanded, searchedArchivesForTargets, expandedTargets, targetDisplay]); + + const threadDumpRows = React.useMemo(() => { + return searchedArchivesForTargets.map(({ target, targetAsObs }) => { + const isExpanded: boolean = includesTarget(expandedTargets, target); + const keyBase = hashCode(JSON.stringify(target)); + return ( + + + {isExpanded ? ( + + + + ) : null} + + + ); + }); + }, [searchedArchivesForTargets, expandedTargets]); + + const rowPairs = React.useMemo(() => { + const rowPairs: JSX.Element[] = []; + for (let i = 0; i < targetRows.length; i++) { + rowPairs.push(targetRows[i]); + rowPairs.push(threadDumpRows[i]); + } + return rowPairs; + }, [targetRows, threadDumpRows]); + + let view: JSX.Element; + + const authRetry = React.useCallback(() => { + context.target.setAuthRetry(); + }, [context.target]); + + const isError = React.useMemo(() => errorMessage != '', [errorMessage]); + + if (isError) { + view = ( + <> + + + ); + } else if (isLoading) { + view = ; + } else if (!searchedArchivesForTargets.length) { + view = ( + <> + + + } + headingLevel="h4" + /> + + + + ); + } else { + view = ( + + + + + ))} + + + {rowPairs} +
+ {tableColumns.map(({ title, width }, idx) => ( + ['width']} + sort={getSortParams(idx)} + > + {title} +
+ ); + } + + return ( + + + + + + + + + + + + + + {view} + + ); +}; diff --git a/src/app/Diagnostics/AnalyzeHeapDumps.tsx b/src/app/Diagnostics/AnalyzeHeapDumps.tsx index 90f2fd094..78aabc06b 100644 --- a/src/app/Diagnostics/AnalyzeHeapDumps.tsx +++ b/src/app/Diagnostics/AnalyzeHeapDumps.tsx @@ -16,7 +16,6 @@ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { NullableTarget } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; import { TargetContextSelector } from '@app/TargetView/TargetContextSelector'; import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getActiveTab, switchTab } from '@app/utils/utils'; @@ -24,6 +23,8 @@ import { Card, CardBody, Stack, StackItem, Tab, Tabs, TabTitleText } from '@patt import { t } from 'i18next'; import * as React from 'react'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import { of } from 'rxjs'; +import { AllTargetsHeapDumpsTable } from './AllTargetsHeapDumpsTable'; import { HeapDumpsTable } from './HeapDumpsTable'; export interface AnalyzeHeapDumpsProps {} @@ -39,6 +40,7 @@ export const AnalyzeHeapDumps: React.FC = ({ ...props }) const addSubscription = useSubscriptions(); const [target, setTarget] = React.useState(undefined as NullableTarget); + const targetAsObs = React.useMemo(() => of(target), [target]); React.useEffect(() => { addSubscription(context.target.target().subscribe((t) => setTarget(t))); @@ -69,17 +71,17 @@ export const AnalyzeHeapDumps: React.FC = ({ ...props }) {target ? ( - + ) : ( // FIXME this should be an "AllTargetsHeapDumpsTable" like the AllTargetsArchivedRecordingsTable - + )} ), - [activeTab, onTabSelect, target], + [activeTab, onTabSelect, target, targetAsObs], ); return ( diff --git a/src/app/Diagnostics/AnalyzeThreadDumps.tsx b/src/app/Diagnostics/AnalyzeThreadDumps.tsx index b44c511b2..0ce112af1 100644 --- a/src/app/Diagnostics/AnalyzeThreadDumps.tsx +++ b/src/app/Diagnostics/AnalyzeThreadDumps.tsx @@ -16,7 +16,6 @@ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { NullableTarget } from '@app/Shared/Services/api.types'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; import { TargetContextSelector } from '@app/TargetView/TargetContextSelector'; import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; import { getActiveTab, switchTab } from '@app/utils/utils'; @@ -24,6 +23,8 @@ import { Card, CardBody, Stack, StackItem, Tab, Tabs, TabTitleText } from '@patt import { t } from 'i18next'; import * as React from 'react'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import { of } from 'rxjs'; +import { AllTargetsThreadDumpsTable } from './AllTargetsThreadDumpsTable'; import { ThreadDumpsTable } from './ThreadDumpsTable'; export interface AnalyzeThreadDumpsProps {} @@ -39,6 +40,7 @@ export const AnalyzeThreadDumps: React.FC = ({ ...props const addSubscription = useSubscriptions(); const [target, setTarget] = React.useState(undefined as NullableTarget); + const targetAsObs = React.useMemo(() => of(target), [target]); React.useEffect(() => { addSubscription(context.target.target().subscribe((t) => setTarget(t))); @@ -69,17 +71,17 @@ export const AnalyzeThreadDumps: React.FC = ({ ...props {target ? ( - + ) : ( // FIXME this should be an "AllTargetsThreadDumpsTable" like the AllTargetsArchivedRecordingsTable - + )} ), - [activeTab, onTabSelect, target], + [activeTab, onTabSelect, target, targetAsObs], ); return ( diff --git a/src/app/Diagnostics/HeapDumpsTable.tsx b/src/app/Diagnostics/HeapDumpsTable.tsx index 07e3814ae..0f5aaa37b 100644 --- a/src/app/Diagnostics/HeapDumpsTable.tsx +++ b/src/app/Diagnostics/HeapDumpsTable.tsx @@ -17,7 +17,7 @@ import { ErrorView } from '@app/ErrorView/ErrorView'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteOrDisableWarningType } from '@app/Modal/types'; import { LoadingView } from '@app/Shared/Components/LoadingView'; -import { NotificationCategory, HeapDump } from '@app/Shared/Services/api.types'; +import { NotificationCategory, HeapDump, NullableTarget, Target } from '@app/Shared/Services/api.types'; import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import useDayjs from '@app/utils/hooks/useDayjs'; @@ -59,6 +59,7 @@ import { } from '@patternfly/react-table'; import _ from 'lodash'; import * as React from 'react'; +import { concatMap, first, Observable, of } from 'rxjs'; const tableColumns: TableColumn[] = [ { @@ -78,9 +79,11 @@ const tableColumns: TableColumn[] = [ }, ]; -export interface HeapDumpsProps {} +export interface HeapDumpsProps { + target: Observable; +} -export const HeapDumpsTable: React.FC = ({}) => { +export const HeapDumpsTable: React.FC = ({ target: propsTarget }) => { const context = React.useContext(ServiceContext); const { t } = useCryostatTranslation(); const addSubscription = useSubscriptions(); @@ -127,31 +130,59 @@ export const HeapDumpsTable: React.FC = ({}) => { [setIsLoading, setErrorMessage], ); + const queryTargetHeapDumps = React.useCallback( + (target: Target) => context.api.getTargetHeapDumps(target), + [context.api], + ); + const refreshHeapDumps = React.useCallback(() => { setIsLoading(true); addSubscription( - context.api.getHeapDumps().subscribe({ - next: (value) => handleHeapDumps(value), - error: (err) => handleError(err), - }), + propsTarget + .pipe( + first(), + concatMap((target: Target | undefined) => { + if (target) { + return queryTargetHeapDumps(target); + } else { + setIsLoading(false); + return of([]); + } + }), + ) + .subscribe({ + next: handleHeapDumps, + error: handleError, + }), ); - }, [addSubscription, context.api, setIsLoading, handleHeapDumps, handleError]); + }, [addSubscription, propsTarget, setIsLoading, handleHeapDumps, handleError, queryTargetHeapDumps]); const handleDelete = React.useCallback( (heapDump: HeapDump) => { addSubscription( - context.api.deleteHeapDump(heapDump.heapDumpId).subscribe(() => { - // Do nothing, leave it to the notification handler. - }), + propsTarget + .pipe( + first(), + concatMap((target: Target | undefined) => { + if (target) { + return context.api.deleteHeapDump(target, heapDump.heapDumpId); + } else { + return of([]); + } + }), + ) + .subscribe({ + error: handleError, + }), ); }, - [addSubscription, context.api], + [addSubscription, handleError, propsTarget, context.api], ); React.useEffect(() => { addSubscription( context.notificationChannel.messages(NotificationCategory.HeapDumpDeleted).subscribe((msg) => { - setHeapDumps((old) => old.filter((t) => t.heapDumpId !== msg.message.heapDumpId)); + setHeapDumps((old) => old.filter((t) => t.heapDumpId !== msg.message.heapDump.heapDumpId)); }), ); }, [addSubscription, context.notificationChannel, refreshHeapDumps]); @@ -180,12 +211,12 @@ export const HeapDumpsTable: React.FC = ({}) => { React.useEffect(() => { addSubscription( - context.target.target().subscribe(() => { + propsTarget.subscribe(() => { setFilterText(''); refreshHeapDumps(); }), ); - }, [context.target, addSubscription, refreshHeapDumps]); + }, [propsTarget, addSubscription, refreshHeapDumps]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { diff --git a/src/app/Diagnostics/ThreadDumpsTable.tsx b/src/app/Diagnostics/ThreadDumpsTable.tsx index b6f43183d..16834eed2 100644 --- a/src/app/Diagnostics/ThreadDumpsTable.tsx +++ b/src/app/Diagnostics/ThreadDumpsTable.tsx @@ -17,7 +17,7 @@ import { ErrorView } from '@app/ErrorView/ErrorView'; import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; import { DeleteOrDisableWarningType } from '@app/Modal/types'; import { LoadingView } from '@app/Shared/Components/LoadingView'; -import { NotificationCategory, ThreadDump } from '@app/Shared/Services/api.types'; +import { NotificationCategory, NullableTarget, Target, ThreadDump } from '@app/Shared/Services/api.types'; import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { ServiceContext } from '@app/Shared/Services/Services'; import useDayjs from '@app/utils/hooks/useDayjs'; @@ -59,6 +59,7 @@ import { } from '@patternfly/react-table'; import _ from 'lodash'; import * as React from 'react'; +import { concatMap, first, Observable, of } from 'rxjs'; const tableColumns: TableColumn[] = [ { @@ -78,9 +79,11 @@ const tableColumns: TableColumn[] = [ }, ]; -export interface ThreadDumpsProps {} +export interface ThreadDumpsProps { + target: Observable; +} -export const ThreadDumpsTable: React.FC = ({}) => { +export const ThreadDumpsTable: React.FC = ({ target: propsTarget }) => { const context = React.useContext(ServiceContext); const { t } = useCryostatTranslation(); const addSubscription = useSubscriptions(); @@ -127,25 +130,53 @@ export const ThreadDumpsTable: React.FC = ({}) => { [setIsLoading, setErrorMessage], ); + const queryTargetThreadDumps = React.useCallback( + (target: Target) => context.api.getTargetThreadDumps(target), + [context.api], + ); + const refreshThreadDumps = React.useCallback(() => { setIsLoading(true); addSubscription( - context.api.getThreadDumps().subscribe({ - next: (value) => handleThreadDumps(value), - error: (err) => handleError(err), - }), + propsTarget + .pipe( + first(), + concatMap((target: Target | undefined) => { + if (target) { + return queryTargetThreadDumps(target); + } else { + setIsLoading(false); + return of([]); + } + }), + ) + .subscribe({ + next: handleThreadDumps, + error: handleError, + }), ); - }, [addSubscription, context.api, setIsLoading, handleThreadDumps, handleError]); + }, [addSubscription, propsTarget, setIsLoading, handleThreadDumps, handleError, queryTargetThreadDumps]); const handleDelete = React.useCallback( (threadDump: ThreadDump) => { addSubscription( - context.api.deleteThreadDump(threadDump.threadDumpId).subscribe(() => { - // do nothing - table state update is performed by ThreadDumpDeleted notification handler - }), + propsTarget + .pipe( + first(), + concatMap((target: Target | undefined) => { + if (target) { + return context.api.deleteThreadDump(target, threadDump.threadDumpId); + } else { + return of([]); + } + }), + ) + .subscribe({ + error: handleError, + }), ); }, - [addSubscription, context.api], + [addSubscription, handleError, propsTarget, context.api], ); const handleWarningModalAccept = React.useCallback(() => { @@ -176,19 +207,19 @@ export const ThreadDumpsTable: React.FC = ({}) => { React.useEffect(() => { addSubscription( context.notificationChannel.messages(NotificationCategory.ThreadDumpDeleted).subscribe((msg) => { - setThreadDumps((old) => old.filter((t) => t.threadDumpId !== msg.message.threadDumpId)); + setThreadDumps((old) => old.filter((t) => t.threadDumpId !== msg.message.threadDump.threadDumpId)); }), ); }, [addSubscription, context.notificationChannel, refreshThreadDumps]); React.useEffect(() => { addSubscription( - context.target.target().subscribe(() => { + propsTarget.subscribe(() => { setFilterText(''); refreshThreadDumps(); }), ); - }, [context.target, addSubscription, refreshThreadDumps]); + }, [propsTarget, addSubscription, refreshThreadDumps]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { @@ -308,7 +339,7 @@ export const ThreadDumpsTable: React.FC = ({}) => { {threadDumpRows.length ? ( - +
{tableColumns.map(({ title, sortable }, index) => ( diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 6b2ebdba3..c80b897aa 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -711,42 +711,20 @@ export class ApiService { ); } - deleteThreadDump(threaddumpname: string, suppressNotifications = false): Observable { - return this.target.target().pipe( - concatMap((target) => - this.sendRequest( - 'beta', - `diagnostics/targets/${target?.id}/threaddump/${threaddumpname}`, - { - method: 'DELETE', - }, - undefined, - suppressNotifications, - ).pipe( - map((resp) => resp.ok), - first(), - ), - ), + deleteThreadDump(target: Target, threadDumpId: string): Observable { + return this.sendRequest('beta', `diagnostics/targets/${target?.id}/threaddump/${threadDumpId}`, { + method: 'DELETE', + }).pipe( + map((resp) => resp.ok), first(), ); } - deleteHeapDump(heapdumpname: string, suppressNotifications = false): Observable { - return this.target.target().pipe( - concatMap((target) => - this.sendRequest( - 'beta', - `diagnostics/targets/${target?.id}/heapdump/${heapdumpname}`, - { - method: 'DELETE', - }, - undefined, - suppressNotifications, - ).pipe( - map((resp) => resp.ok), - first(), - ), - ), + deleteHeapDump(target: Target, heapDumpId: string): Observable { + return this.sendRequest('beta', `diagnostics/targets/${target?.id}/heapdump/${heapDumpId}`, { + method: 'DELETE', + }).pipe( + map((resp) => resp.ok), first(), ); } @@ -1726,6 +1704,60 @@ export class ApiService { ).pipe(map((v) => (v.data?.targetNodes[0]?.target?.archivedRecordings?.data as ArchivedRecording[]) ?? [])); } + getTargetThreadDumps(target: TargetStub): Observable { + return this.graphql( + ` + query ThreadDumpsForTarget($id: BigInteger!) { + targetNodes(filter: { targetIds: [$id] }) { + target { + threadDumps { + data { + jvmId + downloadUrl + threadDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + { id: target.id! }, + true, + true, + ).pipe(map((v) => (v.data?.targetNodes[0]?.target?.threadDumps?.data as ThreadDump[]) ?? [])); + } + + getTargetHeapDumps(target: TargetStub): Observable { + return this.graphql( + ` + query HeapDumpsForTarget($id: BigInteger!) { + targetNodes(filter: { targetIds: [$id] }) { + target { + heapDumps { + data { + jvmId + downloadUrl + heapDumpId + lastModified + size + } + aggregate { + count + } + } + } + } + }`, + { id: target.id! }, + true, + true, + ).pipe(map((v) => (v.data?.targetNodes[0]?.target?.heapDumps?.data as HeapDump[]) ?? [])); + } + getTargetActiveRecordings( target: TargetStub, suppressNotifications = false, diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index 52622235d..e444ff4ed 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -196,7 +196,27 @@ export interface ThreadDumpsResponse { data: { targetNodes: { target: { - threadDumps: ThreadDump[]; + threadDumps: { + data: ThreadDump[]; + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + +export interface HeapDumpsResponse { + data: { + targetNodes: { + target: { + heapDumps: { + data: HeapDump[]; + aggregate: { + count: number; + }; + }; }; }[]; }; @@ -319,6 +339,34 @@ export interface RecordingCountResponse { }; } +export interface ThreadDumpCountResponse { + data: { + targetNodes: { + target: { + threadDumps: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + +export interface HeapDumpCountResponse { + data: { + targetNodes: { + target: { + heapDumps: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + // ====================================== // Credential resources // ====================================== diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index f3a8f0dfe..80665a080 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -411,7 +411,7 @@ export const messageKeys = new Map([ { variant: AlertVariant.success, title: 'Heap Dump Deleted', - body: (evt) => `${evt.message.heapDumpId} was deleted`, + body: (evt) => `${evt.message.heapDump.heapDumpId} was deleted`, } as NotificationMessageMapper, ], [ @@ -419,7 +419,7 @@ export const messageKeys = new Map([ { variant: AlertVariant.success, title: 'Heap Dump Uploaded', - body: (evt) => `${evt.message.filename} was uploaded`, + body: (evt) => `${evt.message.heapDump.heapDumpId} was uploaded`, } as NotificationMessageMapper, ], [ @@ -427,7 +427,7 @@ export const messageKeys = new Map([ { variant: AlertVariant.success, title: 'Thread Dump Succeeded', - body: (evt) => `Thread Dump created for target: ${evt.message.targetId}`, + body: (evt) => `Thread Dump ${evt.message.threadDump.threadDumpId} created for target: ${evt.message.jvmId}`, } as NotificationMessageMapper, ], [ @@ -443,7 +443,7 @@ export const messageKeys = new Map([ { variant: AlertVariant.success, title: 'Thread Dump Deleted', - body: (evt) => `${evt.message.threadDumpId} was deleted`, + body: (evt) => `${evt.message.threadDump.threadDumpId} was deleted`, } as NotificationMessageMapper, ], [ diff --git a/src/test/Diagnostics/AllTargetsHeapDumpsTable.test.tsx b/src/test/Diagnostics/AllTargetsHeapDumpsTable.test.tsx new file mode 100644 index 000000000..ee7df4731 --- /dev/null +++ b/src/test/Diagnostics/AllTargetsHeapDumpsTable.test.tsx @@ -0,0 +1,425 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AllTargetsHeapDumpsTable } from '@app/Diagnostics/AllTargetsHeapDumpsTable'; +import { HeapDump, NotificationMessage, Target } from '@app/Shared/Services/api.types'; +import { defaultServices } from '@app/Shared/Services/Services'; +import '@testing-library/jest-dom'; +import { cleanup, screen, within } from '@testing-library/react'; +import { of } from 'rxjs'; +import { createMockForPFTableRef, render, renderSnapshot } from '../utils'; + +const mockNewConnectUrl = 'service:jmx:rmi://someNewUrl'; +const mockNewAlias = 'newTarget'; +const mockNewTarget: Target = { + agent: false, + jvmId: 'target4', + connectUrl: mockNewConnectUrl, + alias: mockNewAlias, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; + +const mockTargetFoundNotification = { + message: { + event: { kind: 'FOUND', serviceRef: mockNewTarget }, + }, +} as NotificationMessage; + +const mockConnectUrl1 = 'service:jmx:rmi://someUrl1'; +const mockAlias1 = 'fooTarget1'; +const mockTarget1: Target = { + agent: false, + jvmId: 'target1', + connectUrl: mockConnectUrl1, + alias: mockAlias1, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; +const mockConnectUrl2 = 'service:jmx:rmi://someUrl2'; +const mockAlias2 = 'fooTarget2'; +const mockTarget2: Target = { + agent: false, + jvmId: 'target2', + connectUrl: mockConnectUrl2, + alias: mockAlias2, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; +const mockConnectUrl3 = 'service:jmx:rmi://someUrl3'; +const mockAlias3 = 'fooTarget3'; +const mockTarget3: Target = { + agent: false, + jvmId: 'target3', + connectUrl: mockConnectUrl3, + alias: mockAlias3, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; + +const mockHeapDump: HeapDump = { + downloadUrl: 'someDownloadUrl', + heapDumpId: 'someUuid', + jvmId: mockTarget1.jvmId, +}; + +const mockCount1 = 1; +const mockCount2 = 3; +const mockCount3 = 0; +const mockNewCount = 12; + +const mockHeapDumpDeletedNotification = { + message: { + jvmId: mockTarget1.jvmId, + heapDump: { + jvmId: mockTarget1.jvmId, + downloadUrl: 'foo', + threadDumpId: mockHeapDump, + lastModified: 0, + size: 0, + }, + }, +} as NotificationMessage; + +const mockHeapDumpNotification = { + message: { + jvmId: mockTarget1.jvmId, + heapDump: { + jvmId: mockTarget1.jvmId, + downloadUrl: 'foo', + threadDumpId: mockHeapDump, + lastModified: 0, + size: 0, + }, + }, +} as NotificationMessage; + +const mockTargetLostNotification = { + message: { + event: { kind: 'LOST', serviceRef: mockTarget1 }, + }, +} as NotificationMessage; + +const mockTargetsAndCountsResponse = { + data: { + targetNodes: [ + { + target: { + ...mockTarget1, + heapDumps: { + data: { + jvmId: mockAlias1, + downloadUrl: 'foo', + heapDumpId: 'fooDump', + lastModified: 0, + size: 123, + }, + aggregate: { + count: mockCount1, + }, + }, + }, + }, + { + target: { + ...mockTarget2, + heapDumps: { + data: { + jvmId: mockAlias2, + downloadUrl: 'bar', + heapDumpId: 'barDump', + lastModified: 1, + size: 456, + }, + aggregate: { + count: mockCount2, + }, + }, + }, + }, + { + target: { + ...mockTarget3, + heapDumps: { + data: { + jvmId: mockAlias3, + downloadUrl: 'foobar', + heapDumpId: 'foobarDump', + lastModified: 3, + size: 789, + }, + aggregate: { + count: mockCount3, + }, + }, + }, + }, + ], + }, +}; +const mockNewTargetCountResponse = { + data: { + targetNodes: [ + { + target: { + ...mockNewTarget, + heapDumps: { + data: { + jvmId: mockNewTarget.jvmId, + downloadUrl: 'foobar', + heapDumpId: 'foobarDump1', + lastModified: 4, + size: 101112, + }, + aggregate: { + count: mockNewCount, + }, + }, + }, + }, + ], + }, +}; + +jest.mock('@app/Diagnostics/HeapDumpsTable', () => { + return { + HeapDumpsTable: jest.fn((_) => { + return
Heap Dumps Table
; + }), + }; +}); + +jest.mock('@app/Shared/Services/Target.service', () => ({ + ...jest.requireActual('@app/Shared/Services/Target.service'), // Require actual implementation of utility functions for Target +})); + +jest + .spyOn(defaultServices.api, 'graphql') + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // renders correctly + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // has the correct table elements + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // hides targets with zero recordings + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // correctly handles the search function + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // expands targets to show their + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // adds a target upon receiving a notification (on load) + .mockReturnValueOnce(of(mockNewTargetCountResponse)) // adds a target upon receiving a notification (on notification) + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // removes a target upon receiving a notification) + .mockReturnValue(of(mockTargetsAndCountsResponse)); // remaining tests + +jest + .spyOn(defaultServices.notificationChannel, 'messages') + .mockReturnValueOnce(of()) // renders correctly + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // has the correct table elements + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // hides targets with zero recordings + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // correctly handles the search function + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // expands targets to show their + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockTargetFoundNotification)) // adds a target upon receiving a notification + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockTargetLostNotification)) // removes a target upon receiving a notification + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockHeapDumpNotification)) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of(mockHeapDumpDeletedNotification)) // decrements the count when an archived recording is deleted + .mockReturnValueOnce(of()); + +jest.spyOn(window, 'open').mockReturnValue(null); + +describe('', () => { + afterEach(cleanup); + + it('renders correctly', async () => { + const tree = await renderSnapshot({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + createNodeMock: createMockForPFTableRef, + }); + expect(tree?.toJSON()).toMatchSnapshot(); + }); + + it('has the correct table elements', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + expect(screen.getByLabelText('all-targets-table')).toBeInTheDocument(); + expect(screen.getByText('Target')).toBeInTheDocument(); + expect(screen.getByText('Archives')).toBeInTheDocument(); + expect(screen.getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockCount1}`)).toBeInTheDocument(); + expect(screen.getByText(`${mockTarget2.alias} (${mockTarget2.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockCount2}`)).toBeInTheDocument(); + // Default to hide target with 0 archives + expect(screen.queryByText(`${mockTarget3.alias} (${mockTarget3.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockCount3}`)).not.toBeInTheDocument(); + }); + + it('hides targets with zero Heap Dumps', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); + + // By default targets with zero recordings are hidden so the only rows + // in the table should be mockTarget1 (mockCount1 == 1) and mockTarget2 (mockCount2 == 3) + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); + const secondTarget = rows[1]; + expect(within(secondTarget).getByText(`${mockTarget2.alias} (${mockTarget2.connectUrl})`)).toBeTruthy(); + expect(within(secondTarget).getByText(`${mockCount2}`)).toBeTruthy(); + + const checkbox = screen.getByLabelText('all-targets-hide-check'); + await user.click(checkbox); + + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + const thirdTarget = rows[2]; + expect(within(thirdTarget).getByText(`${mockTarget3.alias} (${mockTarget3.connectUrl})`)).toBeTruthy(); + expect(within(thirdTarget).getByText(`${mockCount3}`)).toBeTruthy(); + }); + + it('correctly handles the search function', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); + const search = screen.getByLabelText('Search input'); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + await user.type(search, '1'); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(1); + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); + + await user.type(search, 'asdasdjhj'); + expect(screen.getByText('No Heap Dumps')).toBeInTheDocument(); + expect(screen.queryByLabelText('all-targets-table')).not.toBeInTheDocument(); + + await user.clear(search); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + }); + + it('expands targets to show their ', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); + + expect(screen.queryByText('Heap Dumps Table')).not.toBeInTheDocument(); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + const firstTarget = rows[0]; + const expand = within(firstTarget).getByLabelText('Details'); + await user.click(expand); + + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + + const expandedTable = rows[1]; + expect(within(expandedTable).getByText('Heap Dumps Table')).toBeTruthy(); + + await user.click(expand); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(screen.queryByText('Heap Dumps Table')).not.toBeInTheDocument(); + }); + + it('adds a target upon receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + expect(screen.getByText(`${mockNewTarget.alias} (${mockNewTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockNewCount}`)).toBeInTheDocument(); + }); + + it('removes a target upon receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + expect(screen.queryByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockCount1}`)).not.toBeInTheDocument(); + }); + + it('increments the count when a Thread Dump is saved', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockCount1 + 1}`)).toBeTruthy(); + }); + + it('decrements the count when a Heap Dump is deleted', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); + + const checkbox = screen.getByLabelText('all-targets-hide-check'); + await user.click(checkbox); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const rows = within(tableBody).getAllByRole('row'); + + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1 - 1}`)).toBeTruthy(); + }); +}); diff --git a/src/test/Diagnostics/AllTargetsThreadDumpsTable.test.tsx b/src/test/Diagnostics/AllTargetsThreadDumpsTable.test.tsx new file mode 100644 index 000000000..e1998c0d1 --- /dev/null +++ b/src/test/Diagnostics/AllTargetsThreadDumpsTable.test.tsx @@ -0,0 +1,425 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AllTargetsThreadDumpsTable } from '@app/Diagnostics/AllTargetsThreadDumpsTable'; +import { NotificationMessage, Target, ThreadDump } from '@app/Shared/Services/api.types'; +import { defaultServices } from '@app/Shared/Services/Services'; +import '@testing-library/jest-dom'; +import { cleanup, screen, within } from '@testing-library/react'; +import { of } from 'rxjs'; +import { createMockForPFTableRef, render, renderSnapshot } from '../utils'; + +const mockNewConnectUrl = 'service:jmx:rmi://someNewUrl'; +const mockNewAlias = 'newTarget'; +const mockNewTarget: Target = { + agent: false, + jvmId: 'target4', + connectUrl: mockNewConnectUrl, + alias: mockNewAlias, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; + +const mockTargetFoundNotification = { + message: { + event: { kind: 'FOUND', serviceRef: mockNewTarget }, + }, +} as NotificationMessage; + +const mockConnectUrl1 = 'service:jmx:rmi://someUrl1'; +const mockAlias1 = 'fooTarget1'; +const mockTarget1: Target = { + agent: false, + jvmId: 'target1', + connectUrl: mockConnectUrl1, + alias: mockAlias1, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; +const mockConnectUrl2 = 'service:jmx:rmi://someUrl2'; +const mockAlias2 = 'fooTarget2'; +const mockTarget2: Target = { + agent: false, + jvmId: 'target2', + connectUrl: mockConnectUrl2, + alias: mockAlias2, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; +const mockConnectUrl3 = 'service:jmx:rmi://someUrl3'; +const mockAlias3 = 'fooTarget3'; +const mockTarget3: Target = { + agent: false, + jvmId: 'target3', + connectUrl: mockConnectUrl3, + alias: mockAlias3, + labels: [], + annotations: { + cryostat: [], + platform: [], + }, +}; + +const mockThreadDump: ThreadDump = { + downloadUrl: 'someDownloadUrl', + threadDumpId: 'someUuid', + jvmId: mockTarget1.jvmId, +}; + +const mockCount1 = 1; +const mockCount2 = 3; +const mockCount3 = 0; +const mockNewCount = 12; + +const mockThreadDumpDeletedNotification = { + message: { + jvmId: mockTarget1.jvmId, + threadDump: { + jvmId: mockTarget1.jvmId, + downloadUrl: 'foo', + threadDumpId: mockThreadDump, + lastModified: 0, + size: 0, + }, + }, +} as NotificationMessage; + +const mockThreadDumpNotification = { + message: { + jvmId: mockTarget1.jvmId, + threadDump: { + jvmId: mockTarget1.jvmId, + downloadUrl: 'foo', + threadDumpId: mockThreadDump, + lastModified: 0, + size: 0, + }, + }, +} as NotificationMessage; + +const mockTargetLostNotification = { + message: { + event: { kind: 'LOST', serviceRef: mockTarget1 }, + }, +} as NotificationMessage; + +const mockTargetsAndCountsResponse = { + data: { + targetNodes: [ + { + target: { + ...mockTarget1, + threadDumps: { + data: { + jvmId: mockAlias1, + downloadUrl: 'foo', + threadDumpId: 'fooDump', + lastModified: 0, + size: 123, + }, + aggregate: { + count: mockCount1, + }, + }, + }, + }, + { + target: { + ...mockTarget2, + threadDumps: { + data: { + jvmId: mockAlias2, + downloadUrl: 'bar', + threadDumpId: 'barDump', + lastModified: 1, + size: 456, + }, + aggregate: { + count: mockCount2, + }, + }, + }, + }, + { + target: { + ...mockTarget3, + threadDumps: { + data: { + jvmId: mockAlias3, + downloadUrl: 'foobar', + threadDumpId: 'foobarDump', + lastModified: 3, + size: 789, + }, + aggregate: { + count: mockCount3, + }, + }, + }, + }, + ], + }, +}; +const mockNewTargetCountResponse = { + data: { + targetNodes: [ + { + target: { + ...mockNewTarget, + threadDumps: { + data: { + jvmId: mockNewTarget.jvmId, + downloadUrl: 'foobar', + threadDumpId: 'foobarDump1', + lastModified: 4, + size: 101112, + }, + aggregate: { + count: mockNewCount, + }, + }, + }, + }, + ], + }, +}; + +jest.mock('@app/Diagnostics/ThreadDumpsTable', () => { + return { + ThreadDumpsTable: jest.fn((_) => { + return
Thread Dumps Table
; + }), + }; +}); + +jest.mock('@app/Shared/Services/Target.service', () => ({ + ...jest.requireActual('@app/Shared/Services/Target.service'), // Require actual implementation of utility functions for Target +})); + +jest + .spyOn(defaultServices.api, 'graphql') + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // renders correctly + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // has the correct table elements + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // hides targets with zero recordings + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // correctly handles the search function + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // expands targets to show their + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // adds a target upon receiving a notification (on load) + .mockReturnValueOnce(of(mockNewTargetCountResponse)) // adds a target upon receiving a notification (on notification) + .mockReturnValueOnce(of(mockTargetsAndCountsResponse)) // removes a target upon receiving a notification) + .mockReturnValue(of(mockTargetsAndCountsResponse)); // remaining tests + +jest + .spyOn(defaultServices.notificationChannel, 'messages') + .mockReturnValueOnce(of()) // renders correctly + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // has the correct table elements + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // hides targets with zero recordings + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // correctly handles the search function + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) // expands targets to show their + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockTargetFoundNotification)) // adds a target upon receiving a notification + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockTargetLostNotification)) // removes a target upon receiving a notification + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of(mockThreadDumpNotification)) + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of()) + + .mockReturnValueOnce(of()) + .mockReturnValueOnce(of(mockThreadDumpDeletedNotification)) // decrements the count when an archived recording is deleted + .mockReturnValueOnce(of()); + +jest.spyOn(window, 'open').mockReturnValue(null); + +describe('', () => { + afterEach(cleanup); + + it('renders correctly', async () => { + const tree = await renderSnapshot({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + createNodeMock: createMockForPFTableRef, + }); + expect(tree?.toJSON()).toMatchSnapshot(); + }); + + it('has the correct table elements', async () => { + render({ routerConfigs: { routes: [{ path: '/thread-dumps', element: }] } }); + + expect(screen.getByLabelText('all-targets-table')).toBeInTheDocument(); + expect(screen.getByText('Target')).toBeInTheDocument(); + expect(screen.getByText('Archives')).toBeInTheDocument(); + expect(screen.getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockCount1}`)).toBeInTheDocument(); + expect(screen.getByText(`${mockTarget2.alias} (${mockTarget2.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockCount2}`)).toBeInTheDocument(); + // Default to hide target with 0 archives + expect(screen.queryByText(`${mockTarget3.alias} (${mockTarget3.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockCount3}`)).not.toBeInTheDocument(); + }); + + it('hides targets with zero Thread Dumps', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); + + // By default targets with zero recordings are hidden so the only rows + // in the table should be mockTarget1 (mockCount1 == 1) and mockTarget2 (mockCount2 == 3) + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); + const secondTarget = rows[1]; + expect(within(secondTarget).getByText(`${mockTarget2.alias} (${mockTarget2.connectUrl})`)).toBeTruthy(); + expect(within(secondTarget).getByText(`${mockCount2}`)).toBeTruthy(); + + const checkbox = screen.getByLabelText('all-targets-hide-check'); + await user.click(checkbox); + + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + const thirdTarget = rows[2]; + expect(within(thirdTarget).getByText(`${mockTarget3.alias} (${mockTarget3.connectUrl})`)).toBeTruthy(); + expect(within(thirdTarget).getByText(`${mockCount3}`)).toBeTruthy(); + }); + + it('correctly handles the search function', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); + const search = screen.getByLabelText('Search input'); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + await user.type(search, '1'); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(1); + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1}`)).toBeTruthy(); + + await user.type(search, 'asdasdjhj'); + expect(screen.getByText('No Thread Dumps')).toBeInTheDocument(); + expect(screen.queryByLabelText('all-targets-table')).not.toBeInTheDocument(); + + await user.clear(search); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + }); + + it('expands targets to show their ', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); + + expect(screen.queryByText('Thread Dumps Table')).not.toBeInTheDocument(); + + let tableBody = screen.getAllByRole('rowgroup')[1]; + let rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + const firstTarget = rows[0]; + const expand = within(firstTarget).getByLabelText('Details'); + await user.click(expand); + + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(3); + + const expandedTable = rows[1]; + expect(within(expandedTable).getByText('Thread Dumps Table')).toBeTruthy(); + + await user.click(expand); + tableBody = screen.getAllByRole('rowgroup')[1]; + rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(screen.queryByText('Thread Dumps Table')).not.toBeInTheDocument(); + }); + + it('adds a target upon receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/thread-dumps', element: }] } }); + + expect(screen.getByText(`${mockNewTarget.alias} (${mockNewTarget.connectUrl})`)).toBeInTheDocument(); + expect(screen.getByText(`${mockNewCount}`)).toBeInTheDocument(); + }); + + it('removes a target upon receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/thread-dumps', element: }] } }); + + expect(screen.queryByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).not.toBeInTheDocument(); + expect(screen.queryByText(`${mockCount1}`)).not.toBeInTheDocument(); + }); + + it('increments the count when a Thread Dump is saved', async () => { + render({ routerConfigs: { routes: [{ path: '/thread-dumps', element: }] } }); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const rows = within(tableBody).getAllByRole('row'); + expect(rows).toHaveLength(2); + + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockCount1 + 1}`)).toBeTruthy(); + }); + + it('decrements the count when a Thread Dump is deleted', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); + + const checkbox = screen.getByLabelText('all-targets-hide-check'); + await user.click(checkbox); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const rows = within(tableBody).getAllByRole('row'); + + const firstTarget = rows[0]; + expect(within(firstTarget).getByText(`${mockTarget1.alias} (${mockTarget1.connectUrl})`)).toBeTruthy(); + expect(within(firstTarget).getByText(`${mockCount1 - 1}`)).toBeTruthy(); + }); +}); diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx index e4a1a36f1..83538bdd7 100644 --- a/src/test/Diagnostics/HeapDumpsTable.test.tsx +++ b/src/test/Diagnostics/HeapDumpsTable.test.tsx @@ -61,7 +61,7 @@ const mockHeapDumpNotification = { jest.spyOn(defaultServices.settings, 'deletionDialogsEnabledFor').mockReturnValue(true); jest.spyOn(defaultServices.settings, 'datetimeFormat').mockReturnValue(of(defaultDatetimeFormat)); -jest.spyOn(defaultServices.api, 'getHeapDumps').mockReturnValue(of([mockHeapDump])); +jest.spyOn(defaultServices.api, 'getTargetHeapDumps').mockReturnValue(of([mockHeapDump])); jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockTarget)); @@ -74,7 +74,9 @@ describe('', () => { afterEach(cleanup); it('should add a Heap Dump after receiving a notification', async () => { - render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); const addTemplateName = screen.getByText('someUuid'); expect(addTemplateName).toBeInTheDocument(); @@ -82,7 +84,9 @@ describe('', () => { }); it('should display the column header fields', async () => { - render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); const nameHeader = screen.getByText('ID'); expect(nameHeader).toBeInTheDocument(); @@ -100,7 +104,7 @@ describe('', () => { it('should show warning modal and delete a Heap Dump when confirmed', async () => { const deleteRequestSpy = jest.spyOn(defaultServices.api, 'deleteHeapDump').mockReturnValue(of(true)); const { user } = render({ - routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, }); await act(async () => { @@ -128,12 +132,12 @@ describe('', () => { }); expect(deleteRequestSpy).toHaveBeenCalledTimes(1); - expect(deleteRequestSpy).toHaveBeenCalledWith('someUuid'); + expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget, 'someUuid'); }); it('should shown empty state when table is empty', async () => { const { user } = render({ - routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, }); const filterInput = screen.getByLabelText(testT('HeapDumps.ARIA_LABELS.SEARCH_INPUT')); diff --git a/src/test/Diagnostics/ThreadDumpsTable.test.tsx b/src/test/Diagnostics/ThreadDumpsTable.test.tsx index 350fe21b4..22ddae6e7 100644 --- a/src/test/Diagnostics/ThreadDumpsTable.test.tsx +++ b/src/test/Diagnostics/ThreadDumpsTable.test.tsx @@ -60,8 +60,7 @@ const mockThreadDumpNotification = { jest.spyOn(defaultServices.settings, 'deletionDialogsEnabledFor').mockReturnValue(true); jest.spyOn(defaultServices.settings, 'datetimeFormat').mockReturnValue(of(defaultDatetimeFormat)); - -jest.spyOn(defaultServices.api, 'getThreadDumps').mockReturnValue(of([mockThreadDump])); +jest.spyOn(defaultServices.api, 'getTargetThreadDumps').mockReturnValue(of([mockThreadDump])); jest.spyOn(defaultServices.target, 'target').mockReturnValue(of(mockTarget)); @@ -74,7 +73,9 @@ describe('', () => { afterEach(cleanup); it('should add a Thread Dump after receiving a notification', async () => { - render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); + render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); const addTemplateName = screen.getByText('someUuid'); expect(addTemplateName).toBeInTheDocument(); @@ -82,7 +83,9 @@ describe('', () => { }); it('should display the column header fields', async () => { - render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); + render({ + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, + }); const nameHeader = screen.getByText('ID'); expect(nameHeader).toBeInTheDocument(); @@ -96,7 +99,7 @@ describe('', () => { it('should show warning modal and delete a Thread Dump when confirmed', async () => { const deleteRequestSpy = jest.spyOn(defaultServices.api, 'deleteThreadDump').mockReturnValue(of(true)); const { user } = render({ - routerConfigs: { routes: [{ path: '/threaddumps', element: }] }, + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, }); await act(async () => { @@ -124,12 +127,12 @@ describe('', () => { }); expect(deleteRequestSpy).toHaveBeenCalledTimes(1); - expect(deleteRequestSpy).toHaveBeenCalledWith('someUuid'); + expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget, 'someUuid'); }); it('should shown empty state when table is empty', async () => { const { user } = render({ - routerConfigs: { routes: [{ path: '/threaddumps', element: }] }, + routerConfigs: { routes: [{ path: '/thread-dumps', element: }] }, }); const filterInput = screen.getByLabelText(testT('ThreadDumps.ARIA_LABELS.SEARCH_INPUT')); diff --git a/src/test/Diagnostics/__snapshots__/AllTargetsHeapDumpsTable.test.tsx.snap b/src/test/Diagnostics/__snapshots__/AllTargetsHeapDumpsTable.test.tsx.snap new file mode 100644 index 000000000..c175b1c4b --- /dev/null +++ b/src/test/Diagnostics/__snapshots__/AllTargetsHeapDumpsTable.test.tsx.snap @@ -0,0 +1,508 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[` renders correctly 1`] = ` +
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +`; diff --git a/src/test/Diagnostics/__snapshots__/AllTargetsThreadDumpsTable.test.tsx.snap b/src/test/Diagnostics/__snapshots__/AllTargetsThreadDumpsTable.test.tsx.snap new file mode 100644 index 000000000..0e5a942bf --- /dev/null +++ b/src/test/Diagnostics/__snapshots__/AllTargetsThreadDumpsTable.test.tsx.snap @@ -0,0 +1,508 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[` renders correctly 1`] = ` +
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+`;