From 750c1197079783851bcb32333c72c2e0fc0e44f2 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Tue, 29 Jul 2025 10:31:41 -0400 Subject: [PATCH 01/14] Initial implementation --- locales/en/public.json | 14 +- .../Dashboard/Diagnostics/DiagnosticsCard.tsx | 83 +++- src/app/Diagnostics/Diagnostics.tsx | 68 +++ src/app/Diagnostics/HeapDumpsTable.tsx | 430 ++++++++++++++++++ src/app/Modal/types.ts | 10 + src/app/Shared/Services/Api.service.tsx | 75 +++ src/app/Shared/Services/api.types.ts | 9 + src/app/Shared/Services/api.utils.ts | 16 + src/app/routes.tsx | 24 +- 9 files changed, 715 insertions(+), 14 deletions(-) create mode 100644 src/app/Diagnostics/Diagnostics.tsx create mode 100644 src/app/Diagnostics/HeapDumpsTable.tsx diff --git a/locales/en/public.json b/locales/en/public.json index 566ec030e..f93729fc1 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -369,9 +369,19 @@ "DIAGNOSTICS_CARD_DESCRIPTION": "Perform diagnostic operations on the target.", "DIAGNOSTICS_CARD_DESCRIPTION_FULL": "Perform diagonstic operations from a list of supported operations on the target.", "DIAGNOSTICS_CARD_TITLE": "Diagnostics", - "DIAGNOSTICS_GC_BUTTON": "Start Garbage Collection", + "DIAGNOSTICS_GC_BUTTON": "Invoke Garbage Collection", + "DIAGNOSTICS_HEAP_DUMP_BUTTON": "Invoke Heap Dump", + "DIAGONSTICS_HEAP_REDIRECT_BUTTON": "View collected Heap Dumps", "KINDS": { - "GC": "Garbage Collection" + "GC": "Garbage Collection", + "HEAP_DUMP": "Heap Dump" + } + }, + "HeapDumps": { + "SEARCH_PLACEHOLDER": "Search Heap Dumps", + "ARIA_LABELS": { + "ROW_ACTION": "heap-dump-action-menu", + "SEARCH_INPUT": "heap-dump-search-input" } }, "DurationFilter": { diff --git a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx index 259d393b9..e78fc076d 100644 --- a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx +++ b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx @@ -20,6 +20,7 @@ import { DashboardCardSizes, DashboardCardDescriptor, } from '@app/Dashboard/types'; +import { CryostatLink } from '@app/Shared/Components/CryostatLink'; import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; @@ -37,10 +38,14 @@ import { EmptyStateVariant, EmptyStateHeader, EmptyStateFooter, + ActionList, + Tooltip, } from '@patternfly/react-core'; -import { WrenchIcon } from '@patternfly/react-icons'; +import { ListIcon, WrenchIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { DashboardCard } from '../DashboardCard'; +import { NotificationCategory, Target } from '@app/Shared/Services/api.types'; +import { concatMap, filter, first } from 'rxjs/operators'; export interface DiagnosticsCardProps extends DashboardCardTypeProps {} @@ -50,6 +55,7 @@ export const DiagnosticsCard: DashboardCardFC = (props) => const notifications = React.useContext(NotificationsContext); const addSubscription = useSubscriptions(); const [running, setRunning] = React.useState(false); + const [heapDumpReady, setHeapDumpReady] = React.useState(false); const handleError = React.useCallback( (kind, error) => { @@ -58,6 +64,29 @@ export const DiagnosticsCard: DashboardCardFC = (props) => [notifications, t], ); + React.useEffect(() => { + addSubscription( + serviceContext.target.target() + .pipe( + filter((target) => !!target), + first(), + concatMap(() => serviceContext.api.getThreadDumps()), + ) + .subscribe({ + next: (dumps) => dumps.length > 0 ? setHeapDumpReady(true) : setHeapDumpReady(false), + error: () => setHeapDumpReady(false), + }) + ); + }) + + React.useEffect(() => { + addSubscription( + serviceContext.notificationChannel.messages(NotificationCategory.HeapDumpSuccess).subscribe(() => { + setHeapDumpReady(true) + }), + ); + }, [addSubscription, serviceContext.notificationChannel, setHeapDumpReady]); + const handleGC = React.useCallback(() => { setRunning(true); addSubscription( @@ -68,6 +97,19 @@ export const DiagnosticsCard: DashboardCardFC = (props) => ); }, [addSubscription, serviceContext.api, handleError, setRunning, t]); + const handleThreadDump = React.useCallback(() => { + setRunning(true); + addSubscription( + serviceContext.api.runThreadDump(true).subscribe({ + error: (err) => handleError(t('DiagnosticsCard.KINDS.THREADS'), err), + complete: () => { + setRunning(false); + setHeapDumpReady(true); + }, + }), + ); + }, [addSubscription, serviceContext.api, handleError, setRunning, t]); + const header = React.useMemo(() => { return ( {...props.actions || []}, hasNoOffset: false, className: undefined }}> @@ -98,15 +140,36 @@ export const DiagnosticsCard: DashboardCardFC = (props) => /> {t('DiagnosticsCard.DIAGNOSTICS_CARD_DESCRIPTION')} - + + + + + + + + + + {deleteHeapDumpModal} + + + {heapDumpRows.length ? ( + + + + {tableColumns.map(({ title, sortable }, index) => ( + + ))} + + + {heapDumpRows} +
+ {title} +
+ ) : ( + + } + headingLevel="h4" + /> + + )} + + + + ); + } +}; + +export interface HeapDumpActionProps { + heapDump: HeapDump; + onDelete: (heapDump: HeapDump) => void; + onDownload: (heapDump: HeapDump) => void; +} + +export const HeapDumpAction: React.FC = ({ heapDump, onDelete, onDownload }) => { + const { t } = useCryostatTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + + const actionItems = React.useMemo(() => { + return [ + { + key: 'delete-heapdump', + title: 'Delete', + isDanger: true, + onClick: () => onDelete(heapDump), + }, + { + title: 'Download Heap Dump', + key: 'download-heapdump', + onClick: () => onDownload(heapDump), + }, + ]; + }, [onDelete, onDownload, heapDump]); + + const handleToggle = React.useCallback((_, opened: boolean) => setIsOpen(opened), [setIsOpen]); + + const dropdownItems = React.useMemo( + () => + actionItems.map((action, idx) =>( + { + setIsOpen(false); + action.onClick && action.onClick(); + }} + isDanger={action.isDanger} + > + {action.title} + + ), + ), + [actionItems, setIsOpen], + ); + + return ( + ) => ( + handleToggle(event, !isOpen)} + > + + + )} + onOpenChange={setIsOpen} + onOpenChangeKeys={['Escape']} + isOpen={isOpen} + popperProps={{ + position: 'right', + enableFlip: true, + }} + > + {dropdownItems} + + ); +}; \ No newline at end of file diff --git a/src/app/Modal/types.ts b/src/app/Modal/types.ts index 534b38468..6bde6c2d7 100644 --- a/src/app/Modal/types.ts +++ b/src/app/Modal/types.ts @@ -20,6 +20,7 @@ export enum DeleteOrDisableWarningType { DisableAutomatedRules = 'DisableAutomatedRules', DeleteEventTemplates = 'DeleteEventTemplates', DeleteProbeTemplates = 'DeleteProbeTemplates', + DeleteHeapDump = 'DeleteHeapDump', DeleteActiveProbes = 'DeleteActiveProbes', DeleteCredentials = 'DeleteCredentials', DeleteCustomTargets = 'DeleteCustomTargets', @@ -92,6 +93,14 @@ export const DeleteActiveProbes: DeleteOrDisableWarning = { ariaLabel: 'Active probes remove warning', }; +export const DeleteHeapDump: DeleteOrDisableWarning = { + id: DeleteOrDisableWarningType.DeleteHeapDump, + title: 'Permanently delete your archived heap dump?', + label: 'Delete Heap Dump', + description: `If you click Delete, your heap dump will be lost.`, + ariaLabel: 'Heap Dump delete warning', +}; + export const DeleteCredentials: DeleteOrDisableWarning = { id: DeleteOrDisableWarningType.DeleteCredentials, title: 'Permanently delete your Credentials?', @@ -141,6 +150,7 @@ export const DeleteWarningKinds: DeleteOrDisableWarning[] = [ DeleteProbeTemplates, DeleteActiveProbes, DeleteCredentials, + DeleteHeapDump, DeleteCustomTargets, DeleteDashboardLayout, DeleteLayoutTemplate, diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index e991dbe39..fdc83df8d 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -68,6 +68,7 @@ import { MBeanMetricsResponse, BuildInfo, AggregateReport, + HeapDump, } from './api.types'; import { isHttpError, @@ -669,6 +670,67 @@ export class ApiService { ); } + runHeapDump(suppressNotifications = false): Observable { + return this.target.target().pipe( + concatMap((target) => + this.sendRequest( + 'beta', + `diagnostics/targets/${target?.id}/heapdump?`, + { + method: 'POST', + }, + undefined, + suppressNotifications, + ).pipe( + concatMap((resp) => resp.text()), + first(), + ), + ), + 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(), + ), + ), + first(), + ); + } + + getHeapDumps(suppressNotifications = false): Observable { + return this.target.target().pipe( + filter((t) => !!t), + concatMap((target) => + this.sendRequest( + 'beta', + `diagnostics/targets/${target!.id}/heapdump`, + { + method: 'GET', + }, + undefined, + suppressNotifications, + ).pipe( + concatMap((resp) => resp.json()), + first(), + ), + ), + first(), + ); + } + insertProbes(templateName: string): Observable { return this.target.target().pipe( filter((t) => !!t), @@ -1029,6 +1091,19 @@ export class ApiService { }); } + downloadHeapDump(heapDump: HeapDump): void { + this.ctx.url(heapDump.downloadUrl).subscribe((resourceUrl) => { + let filename = this.target.target().pipe( + filter(t => !!t) , + map((t) => `${t?.alias}_${heapDump.uuid}.heap_dump`), + first() + ) + filename.subscribe((name) => { + this.downloadFile(resourceUrl, name); + }); + }); + } + downloadTemplate(template: EventTemplate): void { let url: Observable | undefined; switch (template.type) { diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index 08fe440cd..d31daec05 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -249,6 +249,13 @@ export interface ActiveRecording extends Recording { remoteId: number; } +export interface HeapDump { + downloadUrl: string; + uuid: string; + jvmId?: string; + lastModified?: number; +} + export interface ActiveRecordingsFilterInput { name?: string; state?: string; @@ -537,6 +544,8 @@ export enum NotificationCategory { LayoutTemplateCreated = 'LayoutTemplateCreated', // generated client-side TargetCredentialsStored = 'TargetCredentialsStored', TargetCredentialsDeleted = 'TargetCredentialsDeleted', + HeapDumpSuccess = 'HeapDumpSuccess', + HeapDumpFailure = 'HeapDumpFailure', CredentialsStored = 'CredentialsStored', CredentialsDeleted = 'CredentialsDeleted', ReportSuccess = 'ReportSuccess', diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index b3749b1db..e9a81b581 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -390,6 +390,22 @@ export const messageKeys = new Map([ body: (evt) => `Credentials deleted for target: ${evt.message.target}`, } as NotificationMessageMapper, ], + [ + NotificationCategory.HeapDumpSuccess, + { + variant: AlertVariant.success, + title: 'Thread Dump Succeeded', + body: (evt) => `Heap Dump created for target: ${evt.message.targetId}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.HeapDumpFailure, + { + variant: AlertVariant.danger, + title: 'Thread Dump Failed', + body: (evt) => `Failed to create Heap Dump for target: ${evt.message.targetId}`, + } as NotificationMessageMapper, + ], [ NotificationCategory.CredentialsStored, { diff --git a/src/app/routes.tsx b/src/app/routes.tsx index e40209777..51e4997f5 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -21,6 +21,7 @@ import Archives from './Archives/Archives'; import CreateRecording from './CreateRecording/CreateRecording'; import Dashboard from './Dashboard/Dashboard'; import DashboardSolo from './Dashboard/DashboardSolo'; +import Diagnostics from './Diagnostics/Diagnostics'; import Events from './Events/Events'; import JMCAgent from './JMCAgent/JMCAgent'; import NotFound from './NotFound/NotFound'; @@ -43,7 +44,8 @@ let routeFocusTimer: number; const OVERVIEW = 'Routes.NavGroups.OVERVIEW'; const FLIGHT_RECORDER = 'Routes.NavGroups.FLIGHT_RECORDER'; const CONSOLE = 'Routes.NavGroups.CONSOLE'; -const navGroups = [OVERVIEW, FLIGHT_RECORDER, CONSOLE]; +const DIAGNOSTICS = 'Routes.NavGroups.DIAGNOSTICS'; +const navGroups = [OVERVIEW, FLIGHT_RECORDER, DIAGNOSTICS, CONSOLE]; const ANALYZE = 'Routes.ANALYZE'; const CAPTURE = 'Routes.CAPTURE'; @@ -166,6 +168,18 @@ const flightRecorderRoutes: IAppRoute[] = [ }, ]; +const diagnosticsRoutes: IAppRoute[] = [ + { + component: Diagnostics, + label: 'Heap Dumps', + path: toPath('/diagnostics'), + title: 'Heap Dumps', + description: 'Create and view heap dumps on single target JVMs.', + navGroup: DIAGNOSTICS, + navSubgroup: CAPTURE, + }, +]; + const consoleRoutes: IAppRoute[] = [ { component: SecurityPanel, @@ -201,7 +215,13 @@ const nonNavRoutes: IAppRoute[] = [ }, ]; -const routes: IAppRoute[] = [...overviewRoutes, ...flightRecorderRoutes, ...consoleRoutes, ...nonNavRoutes]; +const routes: IAppRoute[] = [ + ...overviewRoutes, + ...flightRecorderRoutes, + ...consoleRoutes, + ...diagnosticsRoutes, + ...nonNavRoutes, +]; const flatten = (routes: IAppRoute[]): IAppRoute[] => { const ret: IAppRoute[] = []; From af497bd1081969afd7b906e953ee610b8bbf2a8f Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Fri, 29 Aug 2025 16:13:15 -0400 Subject: [PATCH 02/14] eslint, prettier, typo fixes --- locales/en/public.json | 7 ++++-- .../Dashboard/Diagnostics/DiagnosticsCard.tsx | 24 +++++++++---------- src/app/Diagnostics/HeapDumps.tsx | 2 +- src/app/Diagnostics/HeapDumpsTable.tsx | 7 ++---- src/app/routes.tsx | 6 ++--- src/test/Diagnostics/Diagnostics.test.tsx | 10 ++++---- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/locales/en/public.json b/locales/en/public.json index 4962e8eab..25b914821 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -365,7 +365,8 @@ "DATETIME": "Date and Time" }, "Diagnostics": { - "THREAD_DUMPS_TAB_TITLE": "Thread Dumps" + "THREAD_DUMPS_TAB_TITLE": "Thread Dumps", + "HEAP_DUMPS_TAB_TITLE": "Heap Dumps" }, "DiagnosticsCard": { "DIAGNOSTICS_ACTION_FAILURE": "Diagnostics Failure: {{kind}}", @@ -379,7 +380,8 @@ "DIAGONSTICS_THREAD_REDIRECT_BUTTON": "View collected Thread Dumps", "KINDS": { "GC": "Garbage Collection", - "HEAP_DUMP": "Heap Dump" + "HEAP_DUMP": "Heap Dump", + "THREADS": "Thread Dump" } }, "HeapDumps": { @@ -389,6 +391,7 @@ "ARIA_LABELS": { "ROW_ACTION": "heap-dump-action-menu", "SEARCH_INPUT": "heap-dump-search-input" + } }, "ThreadDumps": { "SEARCH_PLACEHOLDER": "Search Thread Dumps", diff --git a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx index df4beb879..376e64810 100644 --- a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx +++ b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx @@ -21,6 +21,7 @@ import { DashboardCardDescriptor, } from '@app/Dashboard/types'; import { CryostatLink } from '@app/Shared/Components/CryostatLink'; +import { NotificationCategory } from '@app/Shared/Services/api.types'; import { NotificationsContext } from '@app/Shared/Services/Notifications.service'; import { FeatureLevel } from '@app/Shared/Services/service.types'; import { ServiceContext } from '@app/Shared/Services/Services'; @@ -45,7 +46,6 @@ import { ListIcon, WrenchIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { concatMap, filter, first } from 'rxjs/operators'; import { DashboardCard } from '../DashboardCard'; -import { NotificationCategory, Target } from '@app/Shared/Services/api.types'; export interface DiagnosticsCardProps extends DashboardCardTypeProps {} @@ -98,12 +98,12 @@ export const DiagnosticsCard: DashboardCardFC = (props) => }, [addSubscription, serviceContext.api, serviceContext.target, setHeapDumpReady]); React.useEffect(() => { - addSubscription( - serviceContext.notificationChannel.messages(NotificationCategory.HeapDumpSuccess).subscribe(() => { - setHeapDumpReady(true) - }), - ); - }, [addSubscription, serviceContext.notificationChannel, setHeapDumpReady]); + addSubscription( + serviceContext.notificationChannel.messages(NotificationCategory.HeapDumpSuccess).subscribe(() => { + setHeapDumpReady(true); + }), + ); + }, [addSubscription, serviceContext.notificationChannel, setHeapDumpReady]); React.useEffect(() => { addSubscription( @@ -140,7 +140,7 @@ export const DiagnosticsCard: DashboardCardFC = (props) => setRunning(true); addSubscription( serviceContext.api.runHeapDump(true).subscribe({ - error: (err) => handleError(t('DiagnosticsCard.KINDS.HEAP'), err), + error: (err) => handleError(t('DiagnosticsCard.KINDS.HEAP_DUMP'), err), complete: () => { setRunning(false); setHeapDumpReady(true); @@ -200,11 +200,11 @@ export const DiagnosticsCard: DashboardCardFC = (props) => > {t('DiagnosticsCard.DIAGNOSTICS_THREAD_DUMP_BUTTON')} - + - + - - + + - - - {deleteHeapDumpModal} - - - {heapDumpRows.length ? ( - - - - {tableColumns.map(({ title, sortable }, index) => ( - - ))} - - - {heapDumpRows} -
- {title} -
- ) : ( - - } - headingLevel="h4" - /> - - )} - - - + + + + + + + + + + {deleteHeapDumpModal} + + + {heapDumpRows.length ? ( + + + + {tableColumns.map(({ title, sortable }, index) => ( + + ))} + + + {heapDumpRows} +
+ {title} +
+ ) : ( + + } + headingLevel="h4" + /> + + )} +
+
); } }; @@ -368,7 +340,7 @@ export const HeapDumpAction: React.FC = ({ heapDump, onDele const actionItems = React.useMemo(() => { return [ { - title: 'Download Heap Dump', + title: 'Download', key: 'download-heapdump', onClick: () => onDownload(heapDump), }, @@ -388,19 +360,23 @@ export const HeapDumpAction: React.FC = ({ heapDump, onDele const dropdownItems = React.useMemo( () => - actionItems.map((action) => ( - { - setIsOpen(false); - action.onClick && action.onClick(); - }} - isDanger={action.isDanger} - > - {action.title} - - )), + actionItems.map((action, idx) => + action.isSeparator ? ( + + ) : ( + { + setIsOpen(false); + action.onClick && action.onClick(); + }} + isDanger={action.isDanger} + > + {action.title} + + ), + ), [actionItems, setIsOpen], ); diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index 06a930fd6..f829d483a 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -562,8 +562,10 @@ export enum NotificationCategory { LayoutTemplateCreated = 'LayoutTemplateCreated', // generated client-side TargetCredentialsStored = 'TargetCredentialsStored', TargetCredentialsDeleted = 'TargetCredentialsDeleted', + HeapDumpSuccess = 'HeapDumpSuccess', HeapDumpUploaded = 'HeapDumpUploaded', HeapDumpFailure = 'HeapDumpFailure', + HeapDumpDeleted = 'HeapDumpDeleted', ThreadDumpSuccess = 'ThreadDumpSuccess', ThreadDumpFailure = 'ThreadDumpFailure', ThreadDumpDeleted = 'ThreadDumpDeleted', diff --git a/src/app/Shared/Services/api.utils.ts b/src/app/Shared/Services/api.utils.ts index ffa66b478..03b16bf91 100644 --- a/src/app/Shared/Services/api.utils.ts +++ b/src/app/Shared/Services/api.utils.ts @@ -394,7 +394,7 @@ export const messageKeys = new Map([ NotificationCategory.HeapDumpSuccess, { variant: AlertVariant.success, - title: 'Thread Dump Succeeded', + title: 'Heap Dump Succeeded', body: (evt) => `Heap Dump created for target: ${evt.message.targetId}`, } as NotificationMessageMapper, ], @@ -402,10 +402,26 @@ export const messageKeys = new Map([ NotificationCategory.HeapDumpFailure, { variant: AlertVariant.danger, - title: 'Thread Dump Failed', + title: 'Heap Dump Failed', body: (evt) => `Failed to create Heap Dump for target: ${evt.message.targetId}`, } as NotificationMessageMapper, ], + [ + NotificationCategory.HeapDumpDeleted, + { + variant: AlertVariant.success, + title: 'Heap Dump Deleted', + body: (evt) => `${evt.message.heapDumpId} was deleted`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.HeapDumpUploaded, + { + variant: AlertVariant.success, + title: 'Heap Dump Uploaded', + body: (evt) => `${evt.message.filename} was uploaded`, + } as NotificationMessageMapper, + ], [ NotificationCategory.ThreadDumpSuccess, { diff --git a/src/app/routes.tsx b/src/app/routes.tsx index f7bb0f710..6bb12d62f 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -23,7 +23,7 @@ import Dashboard from './Dashboard/Dashboard'; import DashboardSolo from './Dashboard/DashboardSolo'; import AnalyzeThreadDumps from './Diagnostics/AnalyzeThreadDumps'; import CaptureDiagnostics from './Diagnostics/CaptureDiagnostics'; -import HeapDumps from './Diagnostics/HeapDumps'; +import HeapDumps from './Diagnostics/AnalyzeHeapDumps'; import Events from './Events/Events'; import JMCAgent from './JMCAgent/JMCAgent'; import NotFound from './NotFound/NotFound'; From 749f76d62f41cb9c913c517fbdcb623fec9c249f Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 11 Sep 2025 15:40:32 -0400 Subject: [PATCH 05/14] File size support for heap dumps --- src/app/Diagnostics/HeapDumpsTable.tsx | 15 ++++++++++----- src/app/Shared/Services/api.types.ts | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/Diagnostics/HeapDumpsTable.tsx b/src/app/Diagnostics/HeapDumpsTable.tsx index 6c49d17d5..483dc68e9 100644 --- a/src/app/Diagnostics/HeapDumpsTable.tsx +++ b/src/app/Diagnostics/HeapDumpsTable.tsx @@ -22,10 +22,9 @@ import { NotificationsContext } from '@app/Shared/Services/Notifications.service import { ServiceContext } from '@app/Shared/Services/Services'; import useDayjs from '@app/utils/hooks/useDayjs'; import { useSubscriptions } from '@app/utils/hooks/useSubscriptions'; -import { TableColumn, sortResources } from '@app/utils/utils'; +import { TableColumn, formatBytes, sortResources } from '@app/utils/utils'; import { useCryostatTranslation } from '@i18n/i18nextUtil'; import { - Button, EmptyState, EmptyStateIcon, Stack, @@ -41,12 +40,11 @@ import { MenuToggleElement, MenuToggle, SearchInput, - Tooltip, Timestamp, TimestampTooltipVariant, Divider, } from '@patternfly/react-core'; -import { SearchIcon, EllipsisVIcon, UploadIcon } from '@patternfly/react-icons'; +import { SearchIcon, EllipsisVIcon } from '@patternfly/react-icons'; import { ISortBy, SortByDirection, @@ -61,7 +59,6 @@ import { } from '@patternfly/react-table'; import _ from 'lodash'; import * as React from 'react'; -import { first } from 'rxjs/operators'; const tableColumns: TableColumn[] = [ { @@ -74,6 +71,11 @@ const tableColumns: TableColumn[] = [ keyPaths: ['lastModified'], sortable: true, }, + { + title: 'Size', + keyPaths: ['size'], + sortable: true, + }, ]; export interface HeapDumpsProps {} @@ -263,6 +265,9 @@ export const HeapDumpsTable: React.FC = ({}) => { {dayjs(t.lastModified).tz(datetimeContext.timeZone.full).format('L LTS z')} + + {formatBytes(t.size ?? 0)} + diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index f829d483a..4407e4d6d 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -272,6 +272,7 @@ export interface HeapDump { uuid: string; jvmId?: string; lastModified?: number; + size?: number; } export interface ActiveRecordingsFilterInput { From c0d9137c27444a9a47732b9d9955983786929809 Mon Sep 17 00:00:00 2001 From: jmatsuok Date: Thu, 11 Sep 2025 16:01:32 -0400 Subject: [PATCH 06/14] Heap Dump Frontend tests --- src/test/Diagnostics/HeapDumpsTable.test.tsx | 151 ++++++++++++++++ .../DeletionDialogControl.test.tsx.snap | 51 +++++- .../NotificationControl.test.tsx.snap | 168 +++++++++++++++++- 3 files changed, 358 insertions(+), 12 deletions(-) create mode 100644 src/test/Diagnostics/HeapDumpsTable.test.tsx diff --git a/src/test/Diagnostics/HeapDumpsTable.test.tsx b/src/test/Diagnostics/HeapDumpsTable.test.tsx new file mode 100644 index 000000000..68065aeab --- /dev/null +++ b/src/test/Diagnostics/HeapDumpsTable.test.tsx @@ -0,0 +1,151 @@ +/* + * 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 { HeapDumpsTable } from '@app/Diagnostics/HeapDumpsTable'; +import { DeleteHeapDump } from '@app/Modal/types'; +import { + MessageMeta, + MessageType, + NotificationCategory, + NotificationMessage, + HeapDump, +} from '@app/Shared/Services/api.types'; +import { defaultServices } from '@app/Shared/Services/Services'; +import '@testing-library/jest-dom'; +import { defaultDatetimeFormat } from '@i18n/datetime'; +import { cleanup, screen, within, act } from '@testing-library/react'; +import { of } from 'rxjs'; +import { render, testT } from '../utils'; + +const mockMessageType = { type: 'application', subtype: 'json' } as MessageType; + +const mockHeapDump: HeapDump = { + downloadUrl: 'someDownloadUrl', + uuid: 'someUuid', + jvmId: 'someJvmId', +}; + +const mockConnectUrl = 'service:jmx:rmi://someUrl'; +const mockTarget = { + agent: false, + connectUrl: mockConnectUrl, + alias: 'fooTarget', + jvmId: 'foo', + labels: [], + annotations: { cryostat: [], platform: [] }, +}; + +const mockHeapDumpNotification = { + meta: { + category: NotificationCategory.HeapDumpSuccess, + type: mockMessageType, + } as MessageMeta, + message: { + targetId: mockHeapDump.jvmId, + }, +} as NotificationMessage; + +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.target, 'target').mockReturnValue(of(mockTarget)); + +jest + .spyOn(defaultServices.notificationChannel, 'messages') + .mockReturnValueOnce(of(mockHeapDumpNotification)) + .mockReturnValue(of()); + +describe('', () => { + afterEach(cleanup); + + it('should add a Heap Dump after receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + const addTemplateName = screen.getByText('someUuid'); + expect(addTemplateName).toBeInTheDocument(); + expect(addTemplateName).toBeVisible(); + }); + + it('should display the column header fields', async () => { + render({ routerConfigs: { routes: [{ path: '/heapdumps', element: }] } }); + + const nameHeader = screen.getByText('ID'); + expect(nameHeader).toBeInTheDocument(); + expect(nameHeader).toBeVisible(); + + const modifiedHeader = screen.getByText('Last Modified'); + expect(modifiedHeader).toBeInTheDocument(); + expect(modifiedHeader).toBeVisible(); + + const sizeHeader = screen.getByText('Size'); + expect(sizeHeader).toBeInTheDocument(); + expect(sizeHeader).toBeVisible(); + }); + + 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: }] }, + }); + + await act(async () => { + await user.click(screen.getByLabelText(testT('HeapDumps.ARIA_LABELS.ROW_ACTION'))); + + const deleteButton = await screen.findByText('Delete'); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toBeVisible(); + + await user.click(deleteButton); + + const warningModal = await screen.findByRole('dialog'); + expect(warningModal).toBeInTheDocument(); + expect(warningModal).toBeVisible(); + + const modalTitle = within(warningModal).getByText(DeleteHeapDump.title); + expect(modalTitle).toBeInTheDocument(); + expect(modalTitle).toBeVisible(); + + const confirmButton = within(warningModal).getByText('Delete'); + expect(confirmButton).toBeInTheDocument(); + expect(confirmButton).toBeVisible(); + + await user.click(confirmButton); + }); + + expect(deleteRequestSpy).toHaveBeenCalledTimes(1); + expect(deleteRequestSpy).toHaveBeenCalledWith('someUuid'); + }); + + it('should shown empty state when table is empty', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/heapdumps', element: }] }, + }); + + const filterInput = screen.getByLabelText(testT('HeapDumps.ARIA_LABELS.SEARCH_INPUT')); + expect(filterInput).toBeInTheDocument(); + expect(filterInput).toBeVisible(); + + await user.type(filterInput, 'someveryoddname'); + + expect(screen.queryByText('someHeapDump')).not.toBeInTheDocument(); + + const hintText = screen.getByText('No Heap Dumps'); + expect(hintText).toBeInTheDocument(); + expect(hintText).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap b/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap index eb7d55475..939860cac 100644 --- a/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap +++ b/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap @@ -351,6 +351,45 @@ exports[` renders correctly 1`] = ` data-ouia-component-id="OUIA-Generated-Switch-8" data-ouia-component-type="PF5/Switch" data-ouia-safe={true} + htmlFor="DeleteHeapDump" + > + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+