From 6469f1456e85efaa2fbca56c3222e9ae60c41917 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 12 Sep 2025 16:14:10 -0400 Subject: [PATCH 01/11] All targets view for thread dumps --- .../AllTargetsThreadDumpsTable.tsx | 555 ++++++++++++++++++ src/app/Diagnostics/AnalyzeThreadDumps.tsx | 10 +- src/app/Diagnostics/ThreadDumpsTable.tsx | 40 +- src/app/Shared/Services/Api.service.tsx | 27 + src/app/Shared/Services/api.types.ts | 21 +- 5 files changed, 638 insertions(+), 15 deletions(-) create mode 100644 src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx diff --git a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx new file mode 100644 index 000000000..88d27ebbf --- /dev/null +++ b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx @@ -0,0 +1,555 @@ +/* + * 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.target, v.message.threadDumpId, 1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.ThreadDumpDeleted).subscribe((v) => { + handleNotification(v.message.target, v.message.threadDumpId, -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/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/ThreadDumpsTable.tsx b/src/app/Diagnostics/ThreadDumpsTable.tsx index b6f43183d..585d9283d 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,15 +130,32 @@ 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) => { @@ -183,12 +203,12 @@ export const ThreadDumpsTable: React.FC = ({}) => { React.useEffect(() => { addSubscription( - context.target.target().subscribe(() => { + propsTarget.subscribe(() => { setFilterText(''); refreshThreadDumps(); }), ); - }, [context.target, addSubscription, refreshThreadDumps]); + }, [propsTarget, addSubscription, refreshThreadDumps]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 8d0816360..c348311ff 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -1650,6 +1650,33 @@ 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[]) ?? [])); + } + getTargetActiveRecordings( target: TargetStub, suppressNotifications = false, diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index 0ad429d4c..f2810a9b8 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -196,7 +196,12 @@ export interface ThreadDumpsResponse { data: { targetNodes: { target: { - threadDumps: ThreadDump[]; + threadDumps: { + data: ThreadDump[]; + aggregate: { + count: number; + }; + }; }; }[]; }; @@ -311,6 +316,20 @@ export interface RecordingCountResponse { }; } +export interface ThreadDumpCountResponse { + data: { + targetNodes: { + target: { + threadDumps: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + // ====================================== // Credential resources // ====================================== From b7c96aeef662f252def2e9bcb03982370def2a48 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Wed, 17 Sep 2025 14:37:47 -0400 Subject: [PATCH 02/11] Localization, add all targets heap dumps table --- locales/en/public.json | 10 + .../Diagnostics/AllTargetsHeapDumpsTable.tsx | 555 ++++++++++++++++++ src/app/Diagnostics/AnalyzeHeapDumps.tsx | 11 +- src/app/Diagnostics/HeapDumpsTable.tsx | 43 +- src/app/Shared/Services/Api.service.tsx | 27 + src/app/Shared/Services/api.types.ts | 29 + src/test/Diagnostics/HeapDumpsTable.test.tsx | 8 +- .../Diagnostics/ThreadDumpsTable.test.tsx | 8 +- 8 files changed, 668 insertions(+), 23 deletions(-) create mode 100644 src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx diff --git a/locales/en/public.json b/locales/en/public.json index 9951ad069..8dba084c6 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", diff --git a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx new file mode 100644 index 000000000..ca077c21a --- /dev/null +++ b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx @@ -0,0 +1,555 @@ +/* + * 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 + 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?.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.target, v.message.heapDumpId, 1); + }), + ); + }, [addSubscription, context.notificationChannel, handleNotification]); + + React.useEffect(() => { + addSubscription( + context.notificationChannel.messages(NotificationCategory.HeapDumpDeleted).subscribe((v) => { + handleNotification(v.message.target, v.message.heapDumpId, -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/AnalyzeHeapDumps.tsx b/src/app/Diagnostics/AnalyzeHeapDumps.tsx index 90f2fd094..aaa0020eb 100644 --- a/src/app/Diagnostics/AnalyzeHeapDumps.tsx +++ b/src/app/Diagnostics/AnalyzeHeapDumps.tsx @@ -11,7 +11,7 @@ * 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. + * limitations under thecontext.target License. */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { NullableTarget } from '@app/Shared/Services/api.types'; @@ -25,6 +25,8 @@ import { t } from 'i18next'; import * as React from 'react'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; import { HeapDumpsTable } from './HeapDumpsTable'; +import { of } from 'rxjs'; +import { AllTargetsHeapDumpsTable } from './AllTargetsHeapDumpsTable'; export interface AnalyzeHeapDumpsProps {} @@ -39,6 +41,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 +72,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/HeapDumpsTable.tsx b/src/app/Diagnostics/HeapDumpsTable.tsx index 07e3814ae..7d1751dbc 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,15 +130,33 @@ 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), - }), - ); - }, [addSubscription, context.api, setIsLoading, handleHeapDumps, handleError]); + propsTarget + .pipe( + first(), + concatMap((target: Target | undefined) => { + if (target) { + return queryTargetHeapDumps(target); + } else { + setIsLoading(false); + return of([]); + } + }), + ) + .subscribe({ + next: handleHeapDumps, + error: handleError, + }), + ); + }, [addSubscription, propsTarget, setIsLoading, handleHeapDumps, handleError, queryTargetHeapDumps]); const handleDelete = React.useCallback( (heapDump: HeapDump) => { @@ -180,12 +201,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/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 7fe064895..54e032da5 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -1753,6 +1753,33 @@ export class ApiService { ).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 2a87826b7..e444ff4ed 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -207,6 +207,21 @@ export interface ThreadDumpsResponse { }; } +export interface HeapDumpsResponse { + data: { + targetNodes: { + target: { + heapDumps: { + data: HeapDump[]; + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + // ====================================== // Recording resources // ====================================== @@ -338,6 +353,20 @@ export interface ThreadDumpCountResponse { }; } +export interface HeapDumpCountResponse { + data: { + targetNodes: { + target: { + heapDumps: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + // ====================================== // Credential resources // ====================================== diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx index e4a1a36f1..481a9bf68 100644 --- a/src/test/Diagnostics/HeapDumpsTable.test.tsx +++ b/src/test/Diagnostics/HeapDumpsTable.test.tsx @@ -74,7 +74,7 @@ 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 +82,7 @@ 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 +100,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 () => { @@ -133,7 +133,7 @@ describe('', () => { 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..2d431235f 100644 --- a/src/test/Diagnostics/ThreadDumpsTable.test.tsx +++ b/src/test/Diagnostics/ThreadDumpsTable.test.tsx @@ -74,7 +74,7 @@ describe('', () => { afterEach(cleanup); it('should add a Thread Dump after receiving a notification', async () => { - render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); + render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); const addTemplateName = screen.getByText('someUuid'); expect(addTemplateName).toBeInTheDocument(); @@ -82,7 +82,7 @@ describe('', () => { }); it('should display the column header fields', async () => { - render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); + render({ routerConfigs: { routes: [{ path: '/threaddumps', element: }] } }); const nameHeader = screen.getByText('ID'); expect(nameHeader).toBeInTheDocument(); @@ -96,7 +96,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: '/threaddumps', element: }] }, }); await act(async () => { @@ -129,7 +129,7 @@ describe('', () => { it('should shown empty state when table is empty', async () => { const { user } = render({ - routerConfigs: { routes: [{ path: '/threaddumps', element: }] }, + routerConfigs: { routes: [{ path: '/threaddumps', element: }] }, }); const filterInput = screen.getByLabelText(testT('ThreadDumps.ARIA_LABELS.SEARCH_INPUT')); From 91c6111363db47250b182b60f6ed4807b63bdff8 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 18 Sep 2025 13:38:48 -0400 Subject: [PATCH 03/11] prettier, eslint, fix test --- src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx | 7 +------ .../Diagnostics/AllTargetsThreadDumpsTable.tsx | 7 +------ src/app/Diagnostics/AnalyzeHeapDumps.tsx | 5 ++--- src/app/Diagnostics/HeapDumpsTable.tsx | 3 +-- src/test/Diagnostics/HeapDumpsTable.test.tsx | 14 +++++++++----- src/test/Diagnostics/ThreadDumpsTable.test.tsx | 15 +++++++++------ 6 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx index ca077c21a..e8d37642e 100644 --- a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx @@ -17,12 +17,7 @@ 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 { 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'; diff --git a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx index 88d27ebbf..17d5b350c 100644 --- a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx @@ -17,12 +17,7 @@ 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 { 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'; diff --git a/src/app/Diagnostics/AnalyzeHeapDumps.tsx b/src/app/Diagnostics/AnalyzeHeapDumps.tsx index aaa0020eb..aefa48557 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,9 +23,9 @@ 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 { HeapDumpsTable } from './HeapDumpsTable'; import { of } from 'rxjs'; import { AllTargetsHeapDumpsTable } from './AllTargetsHeapDumpsTable'; +import { HeapDumpsTable } from './HeapDumpsTable'; export interface AnalyzeHeapDumpsProps {} @@ -72,7 +71,7 @@ export const AnalyzeHeapDumps: React.FC = ({ ...props }) {target ? ( - + ) : ( // FIXME this should be an "AllTargetsHeapDumpsTable" like the AllTargetsArchivedRecordingsTable diff --git a/src/app/Diagnostics/HeapDumpsTable.tsx b/src/app/Diagnostics/HeapDumpsTable.tsx index 7d1751dbc..865a7c5b6 100644 --- a/src/app/Diagnostics/HeapDumpsTable.tsx +++ b/src/app/Diagnostics/HeapDumpsTable.tsx @@ -130,7 +130,6 @@ export const HeapDumpsTable: React.FC = ({ target: propsTarget } [setIsLoading, setErrorMessage], ); - const queryTargetHeapDumps = React.useCallback( (target: Target) => context.api.getTargetHeapDumps(target), [context.api], @@ -155,7 +154,7 @@ export const HeapDumpsTable: React.FC = ({ target: propsTarget } next: handleHeapDumps, error: handleError, }), - ); + ); }, [addSubscription, propsTarget, setIsLoading, handleHeapDumps, handleError, queryTargetHeapDumps]); const handleDelete = React.useCallback( diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx index 481a9bf68..30b66206c 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 () => { @@ -133,7 +137,7 @@ describe('', () => { 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 2d431235f..f6b5b5823 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 () => { @@ -129,7 +132,7 @@ describe('', () => { 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')); From 216f71437f79557de1a4577815091ec066879593 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 18 Sep 2025 20:05:03 -0400 Subject: [PATCH 04/11] AllTargetsThreadDumpsTable test --- locales/en/public.json | 3 +- .../AllTargetsThreadDumpsTable.tsx | 6 +- src/app/Diagnostics/ThreadDumpsTable.tsx | 2 +- src/app/Shared/Services/api.utils.ts | 4 +- .../AllTargetsThreadDumpsTable.test.tsx | 425 +++++++++++++++ .../AllTargetsThreadDumpsTable.test.tsx.snap | 508 ++++++++++++++++++ 6 files changed, 941 insertions(+), 7 deletions(-) create mode 100644 src/test/Diagnostics/AllTargetsThreadDumpsTable.test.tsx create mode 100644 src/test/Diagnostics/__snapshots__/AllTargetsThreadDumpsTable.test.tsx.snap diff --git a/locales/en/public.json b/locales/en/public.json index 8dba084c6..42dc186ec 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -411,7 +411,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/AllTargetsThreadDumpsTable.tsx b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx index 17d5b350c..c421978d0 100644 --- a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx @@ -375,7 +375,7 @@ export const AllTargetsThreadDumpsTable: React.FC { addSubscription( context.notificationChannel.messages(NotificationCategory.ThreadDumpSuccess).subscribe((v) => { - handleNotification(v.message.target, v.message.threadDumpId, 1); + handleNotification(v.message.jvmId, v.message.threadDump, 1); }), ); }, [addSubscription, context.notificationChannel, handleNotification]); @@ -383,7 +383,7 @@ export const AllTargetsThreadDumpsTable: React.FC { addSubscription( context.notificationChannel.messages(NotificationCategory.ThreadDumpDeleted).subscribe((v) => { - handleNotification(v.message.target, v.message.threadDumpId, -1); + handleNotification(v.message.jvmId, v.message.threadDump, -1); }), ); }, [addSubscription, context.notificationChannel, handleNotification]); @@ -487,7 +487,7 @@ export const AllTargetsThreadDumpsTable: React.FC } headingLevel="h4" /> diff --git a/src/app/Diagnostics/ThreadDumpsTable.tsx b/src/app/Diagnostics/ThreadDumpsTable.tsx index 585d9283d..6eb803935 100644 --- a/src/app/Diagnostics/ThreadDumpsTable.tsx +++ b/src/app/Diagnostics/ThreadDumpsTable.tsx @@ -328,7 +328,7 @@ export const ThreadDumpsTable: React.FC = ({ target: propsTarg {threadDumpRows.length ? ( - +
{tableColumns.map(({ title, sortable }, index) => ( diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index f3a8f0dfe..44a32c7be 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -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.targetId}`, } 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/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/__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`] = ` +
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +`; From f8596323d3c79824f995b68394e1ccad5a695717 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 18 Sep 2025 20:20:38 -0400 Subject: [PATCH 05/11] AllTargetsHeapDumpsTable test --- locales/en/public.json | 3 +- .../Diagnostics/AllTargetsHeapDumpsTable.tsx | 6 +- src/app/Shared/Services/api.utils.ts | 2 +- .../AllTargetsHeapDumpsTable.test.tsx | 425 +++++++++++++++ .../AllTargetsHeapDumpsTable.test.tsx.snap | 508 ++++++++++++++++++ 5 files changed, 939 insertions(+), 5 deletions(-) create mode 100644 src/test/Diagnostics/AllTargetsHeapDumpsTable.test.tsx create mode 100644 src/test/Diagnostics/__snapshots__/AllTargetsHeapDumpsTable.test.tsx.snap diff --git a/locales/en/public.json b/locales/en/public.json index 42dc186ec..a2d8e15c2 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -402,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", diff --git a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx index e8d37642e..c2bc558db 100644 --- a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx @@ -375,7 +375,7 @@ export const AllTargetsHeapDumpsTable: React.FC = React.useEffect(() => { addSubscription( context.notificationChannel.messages(NotificationCategory.HeapDumpUploaded).subscribe((v) => { - handleNotification(v.message.target, v.message.heapDumpId, 1); + handleNotification(v.message.jvmId, v.message.heapDump, 1); }), ); }, [addSubscription, context.notificationChannel, handleNotification]); @@ -383,7 +383,7 @@ export const AllTargetsHeapDumpsTable: React.FC = React.useEffect(() => { addSubscription( context.notificationChannel.messages(NotificationCategory.HeapDumpDeleted).subscribe((v) => { - handleNotification(v.message.target, v.message.heapDumpId, -1); + handleNotification(v.message.jvmId, v.message.heapDump, -1); }), ); }, [addSubscription, context.notificationChannel, handleNotification]); @@ -487,7 +487,7 @@ export const AllTargetsHeapDumpsTable: React.FC = } headingLevel="h4" /> diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index 44a32c7be..8a168b5ee 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, ], [ 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/__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`] = ` +
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+`; From 559c1f873f5777aadf6d5c1157419aa1af0ae259 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 18 Sep 2025 20:28:31 -0400 Subject: [PATCH 06/11] Notification fix --- src/app/Shared/Services/api.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index 8a168b5ee..a0cbc33d5 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -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, ], [ From d080e46157efd76993294da127b6877ae6abbc4f Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 18 Sep 2025 20:34:32 -0400 Subject: [PATCH 07/11] typo fix --- src/app/Diagnostics/AnalyzeHeapDumps.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Diagnostics/AnalyzeHeapDumps.tsx b/src/app/Diagnostics/AnalyzeHeapDumps.tsx index aefa48557..78aabc06b 100644 --- a/src/app/Diagnostics/AnalyzeHeapDumps.tsx +++ b/src/app/Diagnostics/AnalyzeHeapDumps.tsx @@ -11,7 +11,7 @@ * 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 thecontext.target License. + * limitations under the License. */ import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; import { NullableTarget } from '@app/Shared/Services/api.types'; From 313fcbbcfe18e133e67de47a9809be773a71087a Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 26 Sep 2025 16:48:04 -0400 Subject: [PATCH 08/11] Fix thread dump notification, missing target after switching to all-targets view --- .../Diagnostics/AllTargetsHeapDumpsTable.tsx | 2 +- .../AllTargetsThreadDumpsTable.tsx | 6 +-- src/app/Diagnostics/HeapDumpsTable.tsx | 19 +++++++-- src/app/Diagnostics/ThreadDumpsTable.tsx | 21 +++++++--- src/app/Shared/Services/Api.service.tsx | 42 +++++-------------- src/app/Shared/Services/api.utils.ts | 2 +- src/test/Diagnostics/HeapDumpsTable.test.tsx | 2 +- .../Diagnostics/ThreadDumpsTable.test.tsx | 2 +- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx index c2bc558db..1ff87140a 100644 --- a/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsHeapDumpsTable.tsx @@ -231,7 +231,7 @@ export const AllTargetsHeapDumpsTable: React.FC = data { jvmId downloadUrl - threadDumpId + heapDumpId lastModified size } diff --git a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx index c421978d0..b4dc603f1 100644 --- a/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx +++ b/src/app/Diagnostics/AllTargetsThreadDumpsTable.tsx @@ -434,7 +434,7 @@ export const AllTargetsThreadDumpsTable: React.FC { + const threadDumpRows = React.useMemo(() => { return searchedArchivesForTargets.map(({ target, targetAsObs }) => { const isExpanded: boolean = includesTarget(expandedTargets, target); const keyBase = hashCode(JSON.stringify(target)); @@ -456,10 +456,10 @@ export const AllTargetsThreadDumpsTable: React.FC = ({ target: propsTarget } 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(() => { diff --git a/src/app/Diagnostics/ThreadDumpsTable.tsx b/src/app/Diagnostics/ThreadDumpsTable.tsx index 6eb803935..16834eed2 100644 --- a/src/app/Diagnostics/ThreadDumpsTable.tsx +++ b/src/app/Diagnostics/ThreadDumpsTable.tsx @@ -160,12 +160,23 @@ export const ThreadDumpsTable: React.FC = ({ target: propsTarg 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(() => { @@ -196,7 +207,7 @@ export const ThreadDumpsTable: React.FC = ({ target: propsTarg 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]); diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 54e032da5..ab846c510 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}/threaddump/${heapDumpId}`, { + method: 'DELETE', + }).pipe( + map((resp) => resp.ok), first(), ); } diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index a0cbc33d5..80665a080 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -427,7 +427,7 @@ export const messageKeys = new Map([ { variant: AlertVariant.success, title: 'Thread Dump Succeeded', - body: (evt) => `Thread Dump ${evt.message.threadDump.threadDumpId} created for target: ${evt.message.targetId}`, + body: (evt) => `Thread Dump ${evt.message.threadDump.threadDumpId} created for target: ${evt.message.jvmId}`, } as NotificationMessageMapper, ], [ diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx index 30b66206c..edc7ecdc5 100644 --- a/src/test/Diagnostics/HeapDumpsTable.test.tsx +++ b/src/test/Diagnostics/HeapDumpsTable.test.tsx @@ -132,7 +132,7 @@ describe('', () => { }); expect(deleteRequestSpy).toHaveBeenCalledTimes(1); - expect(deleteRequestSpy).toHaveBeenCalledWith('someUuid'); + expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget,'someUuid'); }); it('should shown empty state when table is empty', async () => { diff --git a/src/test/Diagnostics/ThreadDumpsTable.test.tsx b/src/test/Diagnostics/ThreadDumpsTable.test.tsx index f6b5b5823..22ddae6e7 100644 --- a/src/test/Diagnostics/ThreadDumpsTable.test.tsx +++ b/src/test/Diagnostics/ThreadDumpsTable.test.tsx @@ -127,7 +127,7 @@ describe('', () => { }); expect(deleteRequestSpy).toHaveBeenCalledTimes(1); - expect(deleteRequestSpy).toHaveBeenCalledWith('someUuid'); + expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget, 'someUuid'); }); it('should shown empty state when table is empty', async () => { From bd41c9b3e78166f3afd080dabca99675be2d9bf0 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 26 Sep 2025 16:56:25 -0400 Subject: [PATCH 09/11] correct endpoint --- src/app/Shared/Services/Api.service.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index ab846c510..c80b897aa 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -721,7 +721,7 @@ export class ApiService { } deleteHeapDump(target: Target, heapDumpId: string): Observable { - return this.sendRequest('beta', `diagnostics/targets/${target?.id}/threaddump/${heapDumpId}`, { + return this.sendRequest('beta', `diagnostics/targets/${target?.id}/heapdump/${heapDumpId}`, { method: 'DELETE', }).pipe( map((resp) => resp.ok), From 193ddd5e2d2140147c0f5495ebd050fbbe6467f0 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 26 Sep 2025 17:04:53 -0400 Subject: [PATCH 10/11] Heap dumps deletion notification fix --- src/app/Diagnostics/HeapDumpsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Diagnostics/HeapDumpsTable.tsx b/src/app/Diagnostics/HeapDumpsTable.tsx index d5e503eda..0f5aaa37b 100644 --- a/src/app/Diagnostics/HeapDumpsTable.tsx +++ b/src/app/Diagnostics/HeapDumpsTable.tsx @@ -182,7 +182,7 @@ export const HeapDumpsTable: React.FC = ({ target: propsTarget } 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]); From 51232c8924391aeb1d5128bbbdf71c5dc76c4483 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 26 Sep 2025 17:10:31 -0400 Subject: [PATCH 11/11] prettier --- src/test/Diagnostics/HeapDumpsTable.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx index edc7ecdc5..83538bdd7 100644 --- a/src/test/Diagnostics/HeapDumpsTable.test.tsx +++ b/src/test/Diagnostics/HeapDumpsTable.test.tsx @@ -132,7 +132,7 @@ describe('', () => { }); expect(deleteRequestSpy).toHaveBeenCalledTimes(1); - expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget,'someUuid'); + expect(deleteRequestSpy).toHaveBeenCalledWith(mockTarget, 'someUuid'); }); it('should shown empty state when table is empty', async () => {