diff --git a/locales/en/public.json b/locales/en/public.json index 31d3f146a..f20e6fb3b 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -364,14 +364,29 @@ }, "DATETIME": "Date and Time" }, + "Diagnostics": { + "THREAD_DUMPS_TAB_TITLE": "Thread Dumps" + }, "DiagnosticsCard": { "DIAGNOSTICS_ACTION_FAILURE": "Diagnostics Failure: {{kind}}", "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_THREAD_DUMP_BUTTON": "Invoke Thread Dump", + "DIAGONSTICS_THREAD_REDIRECT_BUTTON": "View collected Thread Dumps", "KINDS": { - "GC": "Garbage Collection" + "GC": "Garbage Collection", + "THREAD_DUMP": "Thread Dump" + } + }, + "ThreadDumps": { + "SEARCH_PLACEHOLDER": "Search Thread Dumps", + "DELETION_FAILURE_CATEGORY": "Thread Dump Deletion Failure", + "DELETION_FAILURE_MESSAGE": "No Thread Dump to delete.", + "ARIA_LABELS": { + "ROW_ACTION": "thread-dump-action-menu", + "SEARCH_INPUT": "thread-dump-search-input" } }, "DurationFilter": { @@ -543,7 +558,8 @@ "NavGroups": { "CONSOLE": "Console", "FLIGHT_RECORDER": "Flight Recorder", - "OVERVIEW": "Overview" + "OVERVIEW": "Overview", + "DIAGNOSTICS": "Diagnostics" } }, "RuleDeleteWarningModal": { diff --git a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx index 259d393b9..607d4e599 100644 --- a/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx +++ b/src/app/Dashboard/Diagnostics/DiagnosticsCard.tsx @@ -20,6 +20,8 @@ import { DashboardCardSizes, 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'; @@ -37,9 +39,12 @@ 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 { concatMap, filter, first } from 'rxjs/operators'; import { DashboardCard } from '../DashboardCard'; 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 [threadDumpReady, setThreadDumpReady] = React.useState(false); const handleError = React.useCallback( (kind, error) => { @@ -58,6 +64,30 @@ 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 ? setThreadDumpReady(true) : setThreadDumpReady(false)), + error: () => setThreadDumpReady(false), + }), + ); + }, [addSubscription, serviceContext.api, serviceContext.target, setThreadDumpReady]); + + React.useEffect(() => { + addSubscription( + serviceContext.notificationChannel.messages(NotificationCategory.ThreadDumpSuccess).subscribe(() => { + setThreadDumpReady(true); + }), + ); + }, [addSubscription, serviceContext.notificationChannel, setThreadDumpReady]); + const handleGC = React.useCallback(() => { setRunning(true); addSubscription( @@ -68,6 +98,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); + setThreadDumpReady(true); + }, + }), + ); + }, [addSubscription, serviceContext.api, handleError, setRunning, t]); + const header = React.useMemo(() => { return ( {...props.actions || []}, hasNoOffset: false, className: undefined }}> @@ -98,15 +141,36 @@ export const DiagnosticsCard: DashboardCardFC = (props) => /> {t('DiagnosticsCard.DIAGNOSTICS_CARD_DESCRIPTION')} - + + + + + + + + + + {deleteThreadDumpModal} + + + {threadDumpRows.length ? ( + + + + {tableColumns.map(({ title, sortable }, index) => ( + + ))} + + + {threadDumpRows} +
+ {title} +
+ ) : ( + + } + headingLevel="h4" + /> + + )} + + + + ); + } +}; + +export interface ThreadDumpActionProps { + threadDump: ThreadDump; + onDownload: (threadDump: ThreadDump) => void; + onDelete: (threadDump: ThreadDump) => void; +} + +export const ThreadDumpAction: React.FC = ({ threadDump, onDelete, onDownload }) => { + const { t } = useCryostatTranslation(); + const [isOpen, setIsOpen] = React.useState(false); + + const actionItems = React.useMemo(() => { + return [ + { + title: 'Download Thread Dump', + key: 'download-threaddump', + onClick: () => onDownload(threadDump), + }, + { + isSeparator: true, + }, + { + key: 'delete-threaddump', + title: 'Delete', + isDanger: true, + onClick: () => onDelete(threadDump), + }, + ]; + }, [onDelete, onDownload, threadDump]); + + const handleToggle = React.useCallback((_, opened: boolean) => setIsOpen(opened), [setIsOpen]); + + const dropdownItems = React.useMemo( + () => + actionItems.map((action) => ( + { + 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} + + ); +}; diff --git a/src/app/Modal/types.ts b/src/app/Modal/types.ts index 534b38468..316bad010 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', + DeleteThreadDump = 'DeleteThreadDump', DeleteActiveProbes = 'DeleteActiveProbes', DeleteCredentials = 'DeleteCredentials', DeleteCustomTargets = 'DeleteCustomTargets', @@ -92,6 +93,14 @@ export const DeleteActiveProbes: DeleteOrDisableWarning = { ariaLabel: 'Active probes remove warning', }; +export const DeleteThreadDump: DeleteOrDisableWarning = { + id: DeleteOrDisableWarningType.DeleteThreadDump, + title: 'Permanently delete your archived thread dump?', + label: 'Delete Thread Dump', + description: `If you click Delete, your thread dump will be lost.`, + ariaLabel: 'Thread Dump delete warning', +}; + export const DeleteCredentials: DeleteOrDisableWarning = { id: DeleteOrDisableWarningType.DeleteCredentials, title: 'Permanently delete your Credentials?', @@ -140,6 +149,7 @@ export const DeleteWarningKinds: DeleteOrDisableWarning[] = [ DeleteEventTemplates, DeleteProbeTemplates, DeleteActiveProbes, + DeleteThreadDump, DeleteCredentials, DeleteCustomTargets, DeleteDashboardLayout, diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index c718de9ea..76b86cd52 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, + ThreadDump, } from './api.types'; import { isHttpError, @@ -669,6 +670,67 @@ export class ApiService { ); } + runThreadDump(suppressNotifications = false): Observable { + return this.target.target().pipe( + concatMap((target) => + this.sendRequest( + 'beta', + `diagnostics/targets/${target?.id}/threaddump?format=threadPrint`, + { + method: 'POST', + }, + undefined, + suppressNotifications, + ).pipe( + concatMap((resp) => resp.text()), + first(), + ), + ), + first(), + ); + } + + 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(), + ), + ), + first(), + ); + } + + getThreadDumps(suppressNotifications = false): Observable { + return this.target.target().pipe( + filter((t) => !!t), + concatMap((target) => + this.sendRequest( + 'beta', + `diagnostics/targets/${target!.id}/threaddump`, + { + method: 'GET', + }, + undefined, + suppressNotifications, + ).pipe( + concatMap((resp) => resp.json()), + first(), + ), + ), + first(), + ); + } + insertProbes(templateName: string): Observable { return this.target.target().pipe( filter((t) => !!t), @@ -1033,6 +1095,20 @@ export class ApiService { }); } + downloadThreadDump(threadDump: ThreadDump): void { + this.ctx.url(threadDump.downloadUrl).subscribe((resourceUrl) => { + let filename = this.target.target().pipe( + filter((t) => !!t), + map((t) => `${t?.alias}_${threadDump.uuid}.thread_dump`), + first(), + ); + filename.subscribe((name) => { + resourceUrl += `?filename=${name}`; + this.downloadFile(resourceUrl, name); + }); + }); + } + downloadTemplate(template: EventTemplate): void { let url: Observable | undefined; switch (template.type) { @@ -1687,9 +1763,7 @@ export class ApiService { let href = url; anchor.download = filename; if (q) { - // TODO more robust processing of the incoming url string. If it already contains - // query parameters then this concatenation will result in two ? separators. - href += `?${q}`; + href.includes('?') ? (href += `&${q}`) : (href += `?${q}`); } anchor.href = href; anchor.click(); diff --git a/src/app/Shared/Services/api.types.ts b/src/app/Shared/Services/api.types.ts index 08fe440cd..34338176b 100644 --- a/src/app/Shared/Services/api.types.ts +++ b/src/app/Shared/Services/api.types.ts @@ -192,6 +192,16 @@ export interface MBeanMetricsResponse { }; } +export interface ThreadDumpsResponse { + data: { + targetNodes: { + target: { + threadDumps: ThreadDump[]; + }; + }[]; + }; +} + // ====================================== // Recording resources // ====================================== @@ -231,6 +241,13 @@ export interface Recording { metadata: Metadata; } +export interface ThreadDump { + downloadUrl: string; + uuid: string; + jvmId?: string; + lastModified?: number; +} + export interface ArchivedRecording extends Recording { jvmId?: string; archivedTime: number; @@ -537,6 +554,8 @@ export enum NotificationCategory { LayoutTemplateCreated = 'LayoutTemplateCreated', // generated client-side TargetCredentialsStored = 'TargetCredentialsStored', TargetCredentialsDeleted = 'TargetCredentialsDeleted', + ThreadDumpSuccess = 'ThreadDumpSuccess', + ThreadDumpFailure = 'ThreadDumpFailure', 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..19c96bfc2 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.ThreadDumpSuccess, + { + variant: AlertVariant.success, + title: 'Thread Dump Succeeded', + body: (evt) => `Thread Dump created for target: ${evt.message.targetId}`, + } as NotificationMessageMapper, + ], + [ + NotificationCategory.ThreadDumpFailure, + { + variant: AlertVariant.danger, + title: 'Thread Dump Failed', + body: (evt) => `Failed to create Thread Dump for target: ${evt.message.targetId}`, + } as NotificationMessageMapper, + ], [ NotificationCategory.CredentialsStored, { diff --git a/src/app/routes.tsx b/src/app/routes.tsx index e40209777..474408d6c 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: 'Thread Dumps', + path: toPath('/diagnostics'), + title: 'Thread Dumps', + description: 'Create and view thread dumps on single target JVMs.', + navGroup: DIAGNOSTICS, + navSubgroup: ANALYZE, + }, +]; + 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[] = []; diff --git a/src/test/Diagnostics/Diagnostics.test.tsx b/src/test/Diagnostics/Diagnostics.test.tsx new file mode 100644 index 000000000..ad611ac92 --- /dev/null +++ b/src/test/Diagnostics/Diagnostics.test.tsx @@ -0,0 +1,164 @@ +/* + * 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 { ThreadDumpsTable } from '@app/Diagnostics/ThreadDumpsTable'; +import { DeleteThreadDump } from '@app/Modal/types'; +import { + MessageMeta, + MessageType, + NotificationCategory, + NotificationMessage, + ThreadDump, +} 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 mockThreadDump: ThreadDump = { + 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 mockThreadDumpNotification = { + meta: { + category: NotificationCategory.ThreadDumpSuccess, + type: mockMessageType, + } as MessageMeta, + message: { + targetId: mockThreadDump.jvmId, + }, +} as NotificationMessage; + +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.target, 'target').mockReturnValue(of(mockTarget)); + +jest + .spyOn(defaultServices.notificationChannel, 'messages') + .mockReturnValueOnce(of(mockThreadDumpNotification)) + .mockReturnValue(of()); + +const dumpThreadsSpy = jest.spyOn(defaultServices.api, 'runThreadDump').mockReturnValue(of('someJobId')); + +describe('', () => { + afterEach(cleanup); + + it('should add a Thread Dump after receiving a notification', async () => { + render({ routerConfigs: { routes: [{ path: '/diagnostics', element: }] } }); + + const addTemplateName = screen.getByText('someUuid'); + expect(addTemplateName).toBeInTheDocument(); + expect(addTemplateName).toBeVisible(); + }); + + it('should display the column header fields', async () => { + render({ routerConfigs: { routes: [{ path: '/diagnostics', element: }] } }); + + const nameHeader = screen.getByText('ID'); + expect(nameHeader).toBeInTheDocument(); + expect(nameHeader).toBeVisible(); + + const xmlHeader = screen.getByText('Last Modified'); + expect(xmlHeader).toBeInTheDocument(); + expect(xmlHeader).toBeVisible(); + }); + + it('should upload a Thread Dump when button is clicked', async () => { + const { user } = render({ + routerConfigs: { routes: [{ path: '/diagnostics', element: }] }, + }); + + await act(async () => { + const uploadButton = screen.getByRole('button', { name: 'dump-threads' }); + expect(uploadButton).toBeInTheDocument(); + expect(uploadButton).toBeVisible(); + + await user.click(uploadButton); + expect(dumpThreadsSpy).toHaveBeenCalledTimes(1); + }); + }); + + 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: '/diagnostics', element: }] }, + }); + + await act(async () => { + await user.click(screen.getByLabelText(testT('ThreadDumps.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(DeleteThreadDump.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: '/diagnostics', element: }] }, + }); + + const filterInput = screen.getByLabelText(testT('ThreadDumps.ARIA_LABELS.SEARCH_INPUT')); + expect(filterInput).toBeInTheDocument(); + expect(filterInput).toBeVisible(); + + await user.type(filterInput, 'someveryoddname'); + + expect(screen.queryByText('someThreadDump')).not.toBeInTheDocument(); + + const hintText = screen.getByText('No Thread Dumps'); + expect(hintText).toBeInTheDocument(); + expect(hintText).toBeVisible(); + }); +}); diff --git a/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap b/src/test/Settings/__snapshots__/DeletionDialogControl.test.tsx.snap index d522b969a..eb7d55475 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="DeleteThreadDump" + > + + + + + + +
+
+
+ +
+
+