diff --git a/src/app/Dashboard/AddCard.tsx b/src/app/Dashboard/AddCard.tsx index 32ecbebea8..e947b0a136 100644 --- a/src/app/Dashboard/AddCard.tsx +++ b/src/app/Dashboard/AddCard.tsx @@ -37,9 +37,10 @@ */ import { CardConfig } from '@app/Shared/Redux/Configurations/DashboardConfigSlice'; import { dashboardConfigAddCardIntent, StateDispatch } from '@app/Shared/Redux/ReduxStore'; +import { ServiceContext } from '@app/Shared/Services/Services'; import { EmptyText } from '@app/Topology/Shared/EmptyText'; import QuickSearchIcon from '@app/Topology/Shared/QuickSearchIcon'; -import { QuickSearchButton } from '@app/Topology/Toolbar/QuickSearchButton'; +import { fakeServices } from '@app/utils/fakeData'; import { useFeatureLevel } from '@app/utils/useFeatureLevel'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { portalRoot } from '@app/utils/utils'; @@ -211,7 +212,7 @@ export const AddCard: React.FC = ({ variant, ..._props }) => { default: return null; } - }, [handleStart, t]); + }, [handleStart, t, variant]); return ( <> @@ -219,7 +220,7 @@ export const AddCard: React.FC = ({ variant, ..._props }) => { @@ -254,7 +255,7 @@ export const AddCard: React.FC = ({ variant, ..._props }) => { {t('Dashboard.ADD_CARD_HELPER_TEXT')} - + @@ -321,7 +322,13 @@ export const CardGallery: React.FC = ({ selection, onSelect }) key={title} hasSelectableInput isSelectableRaised - onClick={(event) => onSelect(event, t(title))} + onClick={(event) => { + if (selection === t(title)) { + setToViewCard(availableCards.find((card) => t(card.title) === selection)); + } else { + onSelect(event, t(title)); + } + }} isFullHeight isFlat isSelected={selection === t(title)} @@ -361,13 +368,13 @@ export const CardGallery: React.FC = ({ selection, onSelect }) } const { title, icon, labels, preview } = toViewCard; return ( - + setToViewCard(undefined)} /> - + @@ -391,7 +398,16 @@ export const CardGallery: React.FC = ({ selection, onSelect }) {getFullDescription(t(toViewCard.title), t)} {preview ? ( - preview +
+
{ + e.preventDefault(); + e.stopPropagation(); + }} + className="non-interactive-overlay" + /> + {preview} +
) : ( diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx index c014e46174..f16d4f0ec0 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisCard.tsx @@ -969,4 +969,5 @@ export const AutomatedAnalysisCardDescriptor: DashboardCardDescriptor = { color: 'blue', }, ], + preview: , }; diff --git a/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx b/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx index 52100115f8..04ca5ff4de 100644 --- a/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx +++ b/src/app/Dashboard/Charts/jfr/JFRMetricsChartController.tsx @@ -42,7 +42,6 @@ import { SettingsService } from '@app/Shared/Services/Settings.service'; import { NO_TARGET, Target, TargetService } from '@app/Shared/Services/Target.service'; import { BehaviorSubject, - catchError, concatMap, distinctUntilChanged, finalize, @@ -151,50 +150,6 @@ export class JFRMetricsChartController { if (target === NO_TARGET) { return of(false); } - - return this._api - .graphql( - ` - query ActiveRecordingsForAutomatedAnalysis($connectUrl: String) { - targetNodes(filter: { name: $connectUrl }) { - recordings { - active (filter: { - labels: ["origin=${RECORDING_NAME}"], - state: "RUNNING", - }) { - aggregate { - count - } - } - } - } - }`, - { connectUrl: target.connectUrl } - ) - .pipe( - map((resp) => { - const nodes = resp.data.targetNodes; - if (nodes.length === 0) { - return false; - } - const count = nodes[0].recordings.active.aggregate.count; - return count > 0; - }), - catchError((_) => of(false)) - ); + return this._api.targetHasRecording(target, RECORDING_NAME); } } - -interface CountResponse { - data: { - targetNodes: { - recordings: { - active: { - aggregate: { - count: number; - }; - }; - }; - }[]; - }; -} diff --git a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx index 4f403c47da..c6ebc880ea 100644 --- a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx +++ b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx @@ -569,4 +569,17 @@ export const MBeanMetricsChartCardDescriptor: DashboardCardDescriptor = { color: 'blue', }, ], + preview: ( + + ), }; diff --git a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx index c69fff6d80..887a004e84 100644 --- a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx +++ b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartController.tsx @@ -36,12 +36,11 @@ * SOFTWARE. */ -import { ApiService, MBeanMetrics, MBeanMetricsResponse } from '@app/Shared/Services/Api.service'; +import { ApiService, MBeanMetrics } from '@app/Shared/Services/Api.service'; import { SettingsService } from '@app/Shared/Services/Settings.service'; import { Target, TargetService } from '@app/Shared/Services/Target.service'; import { BehaviorSubject, - catchError, concatMap, distinctUntilChanged, finalize, @@ -49,7 +48,6 @@ import { map, merge, Observable, - of, pairwise, ReplaySubject, Subject, @@ -164,27 +162,6 @@ export class MBeanMetricsChartController { l += '}'; q.push(l); }); - return this._api - .graphql( - ` - query MBeanMXMetricsForTarget($connectUrl: String) { - targetNodes(filter: { name: $connectUrl }) { - mbeanMetrics { - ${q.join('\n')} - } - } - }`, - { connectUrl: target.connectUrl } - ) - .pipe( - map((resp) => { - const nodes = resp.data.targetNodes; - if (!nodes || nodes.length === 0) { - return {}; - } - return nodes[0]?.mbeanMetrics; - }), - catchError((_) => of({})) - ); + return this._api.getTargetMBeanMetrics(target, q); } } diff --git a/src/app/Dashboard/DashboardCard.tsx b/src/app/Dashboard/DashboardCard.tsx index 5b4a1e1863..57ca8ab8df 100644 --- a/src/app/Dashboard/DashboardCard.tsx +++ b/src/app/Dashboard/DashboardCard.tsx @@ -62,7 +62,6 @@ export const DashboardCard: React.FC = ({ isDraggable = true, isResizable = true, cardSizes, - ...props }: DashboardCardProps) => { const cardRef = React.useRef(null); diff --git a/src/app/Dashboard/DashboardLayoutConfig.tsx b/src/app/Dashboard/DashboardLayoutConfig.tsx index e4f8e25488..50c5f9546f 100644 --- a/src/app/Dashboard/DashboardLayoutConfig.tsx +++ b/src/app/Dashboard/DashboardLayoutConfig.tsx @@ -63,7 +63,7 @@ import { ToolbarItem, } from '@patternfly/react-core'; import { Dropdown } from '@patternfly/react-core/dist/js/next'; -import { DownloadIcon, PencilAltIcon, PlusCircleIcon, TrashIcon, UploadIcon } from '@patternfly/react-icons'; +import { DownloadIcon, PencilAltIcon, TrashIcon, UploadIcon } from '@patternfly/react-icons'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; diff --git a/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx b/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx index f791fc119f..3a4fd5729b 100644 --- a/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx +++ b/src/app/Dashboard/JvmDetails/JvmDetailsCard.tsx @@ -90,12 +90,10 @@ export const JvmDetailsCard: React.FC = (props) => { {...props.actions || []} } + style={{ height: '36em' }} // FIXME: Remove after implementing height resizing {...props} > - + @@ -131,4 +129,5 @@ export const JvmDetailsCardDescriptor: DashboardCardDescriptor = { color: 'blue', }, ], + preview: , }; diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index e1b599f9db..f90c9358d6 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { EventType } from '@app/Events/EventTypes'; import { Notifications } from '@app/Notifications/Notifications'; import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; import { Rule } from '@app/Rules/Rules'; @@ -1155,6 +1156,37 @@ export class ApiService { ); } + targetHasRecording(target: Target, recordingName: string): Observable { + return this.graphql( + ` + query ActiveRecordingsForAutomatedAnalysis($connectUrl: String) { + targetNodes(filter: { name: $connectUrl }) { + recordings { + active (filter: { + labels: ["origin=${recordingName}"], + state: "${RecordingState.RUNNING}", + }) { + aggregate { + count + } + } + } + } + }`, + { connectUrl: target.connectUrl } + ).pipe( + map((resp) => { + const nodes = resp.data.targetNodes; + if (nodes.length === 0) { + return false; + } + const count = nodes[0].recordings.active.aggregate.count; + return count > 0; + }), + catchError((_) => of(false)) + ); + } + checkCredentialForTarget( target: Target, credentials: { username: string; password: string } @@ -1209,6 +1241,82 @@ export class ApiService { ); } + getTargetMBeanMetrics(target: Target, queries: string[]): Observable { + return this.graphql( + ` + query MBeanMXMetricsForTarget($connectUrl: String) { + targetNodes(filter: { name: $connectUrl }) { + mbeanMetrics { + ${queries.join('\n')} + } + } + }`, + { connectUrl: target.connectUrl } + ).pipe( + map((resp) => { + const nodes = resp.data.targetNodes; + if (!nodes || nodes.length === 0) { + return {}; + } + return nodes[0]?.mbeanMetrics; + }), + catchError((_) => of({})) + ); + } + + getTargetArchivedRecordings(target: Target): Observable { + return this.graphql( + ` + query ArchivedRecordingsForTarget($connectUrl: String) { + archivedRecordings(filter: { sourceTarget: $connectUrl }) { + data { + name + downloadUrl + reportUrl + metadata { + labels + } + size + archivedTime + } + } + }`, + { connectUrl: target.connectUrl }, + true, + true + ).pipe(map((v) => v.data.archivedRecordings.data as ArchivedRecording[])); + } + + getTargetActiveRecordings(target: Target): Observable { + return this.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/recordings`, + 'v1', + undefined, + true, + true + ); + } + + getTargetEventTemplates(target: Target): Observable { + return this.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/templates`, + 'v1', + undefined, + true, + true + ); + } + + getTargetEventTypes(target: Target): Observable { + return this.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/events`, + 'v1', + undefined, + true, + true + ); + } + downloadDashboardLayout(layout: DashboardLayout): void { const serializedLayout = this.getSerializedDashboardLayout(layout); const filename = `cryostat-dashboard-${layout.name}.json`; @@ -1491,6 +1599,20 @@ interface DiscoveryResponse extends ApiV2Response { }; } +interface RecordingCountResponse { + data: { + targetNodes: { + recordings: { + active: { + aggregate: { + count: number; + }; + }; + }; + }[]; + }; +} + interface XMLHttpResponse { body: any; headers: object; @@ -1616,6 +1738,7 @@ export enum RecordingState { RUNNING = 'RUNNING', STOPPING = 'STOPPING', } + export type Recording = ActiveRecording | ArchivedRecording; export const isActiveRecording = (toCheck: Recording): toCheck is ActiveRecording => { diff --git a/src/app/Topology/Shared/Entity/utils.tsx b/src/app/Topology/Shared/Entity/utils.tsx index fd3174c1b4..385fd7bd95 100644 --- a/src/app/Topology/Shared/Entity/utils.tsx +++ b/src/app/Topology/Shared/Entity/utils.tsx @@ -41,12 +41,10 @@ import { Rule } from '@app/Rules/Rules'; import { ActiveRecording, ApiService, - ArchivedRecording, EventProbe, Recording, RecordingState, StoredCredential, - UPLOADS_SUBDIRECTORY, } from '@app/Shared/Services/Api.service'; import { NotificationCategory, NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; import { ServiceContext } from '@app/Shared/Services/Services'; @@ -130,74 +128,13 @@ export const getTargetOwnedResources = ( ): Observable => { switch (resourceType) { case 'activeRecordings': - return apiService.doGet( - `targets/${encodeURIComponent(target.connectUrl)}/recordings`, - 'v1', - undefined, - true, - true - ); - /* eslint-disable @typescript-eslint/no-explicit-any */ + return apiService.getTargetActiveRecordings(target); case 'archivedRecordings': - return apiService - .graphql( - ` - query ArchivedRecordingsForTarget($connectUrl: String) { - archivedRecordings(filter: { sourceTarget: $connectUrl }) { - data { - name - downloadUrl - reportUrl - metadata { - labels - } - size - } - } - }`, - { connectUrl: target.connectUrl }, - true, - true - ) - .pipe(map((v) => v.data.archivedRecordings.data as ArchivedRecording[])); - case 'archivedUploadRecordings': - return apiService - .graphql( - `query UploadedRecordings($filter: ArchivedRecordingFilterInput){ - archivedRecordings(filter: $filter) { - data { - name - downloadUrl - reportUrl - metadata { - labels - } - size - } - } - }`, - { filter: { sourceTarget: UPLOADS_SUBDIRECTORY } }, - true, - true - ) - .pipe(map((v) => v.data.archivedRecordings.data as ArchivedRecording[])); - /* eslint-enable @typescript-eslint/no-explicit-any */ + return apiService.getTargetArchivedRecordings(target); case 'eventTemplates': - return apiService.doGet( - `targets/${encodeURIComponent(target.connectUrl)}/templates`, - 'v1', - undefined, - true, - true - ); + return apiService.getTargetEventTemplates(target); case 'eventTypes': - return apiService.doGet( - `targets/${encodeURIComponent(target.connectUrl)}/events`, - 'v1', - undefined, - true, - true - ); + return apiService.getTargetEventTypes(target); case 'agentProbes': return apiService.getActiveProbesForTarget(target, true, true); case 'automatedRules': diff --git a/src/app/Topology/SideBar/TopologySideBar.tsx b/src/app/Topology/SideBar/TopologySideBar.tsx index edffd9a6e4..4f44ddc884 100644 --- a/src/app/Topology/SideBar/TopologySideBar.tsx +++ b/src/app/Topology/SideBar/TopologySideBar.tsx @@ -54,7 +54,7 @@ export const TopologySideBar: React.FC = ({ children, onCl - + {children} diff --git a/src/app/Topology/styles/base.css b/src/app/Topology/styles/base.css index fb275b7d75..274556f975 100644 --- a/src/app/Topology/styles/base.css +++ b/src/app/Topology/styles/base.css @@ -145,6 +145,11 @@ Below CSS rules only apply to Topology components overflow: auto; } +.dashboard-card-preview .entity-overview .pf-c-tab-content { + overflow: hidden; +} + + .entity-overview__wrapper { padding: 1.5em; padding-right: 0.5em; diff --git a/src/app/app.css b/src/app/app.css index 1dbb1315ac..827d3ace9e 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -595,3 +595,26 @@ svg.topology__node-decorator-icon.progress { #card-catalog-wizard .pf-c-wizard__main-body { height: 100% } + +.non-interactive-overlay { + position: absolute; + height: 100%; + width: 100%; + z-index: 99999; +} + +.dashboard-card-preview { + transform: scale(0.9); + position: relative; + border: solid; + border-color: var(--pf-global--BorderColor--light-100); + border-width: 1px; + border-radius: 3px; + overflow: hidden; + height: 100%; + padding: 0.5em; +} + +.dashboard-card-preview .pf-c-card { + box-shadow: none; +} diff --git a/src/app/utils/fakeData.ts b/src/app/utils/fakeData.ts index fed79eb62e..bba56f868d 100644 --- a/src/app/utils/fakeData.ts +++ b/src/app/utils/fakeData.ts @@ -36,10 +36,29 @@ * SOFTWARE. */ -import { ActiveRecording, RecordingState } from '@app/Shared/Services/Api.service'; -import { Target } from '@app/Shared/Services/Target.service'; +import { EventType } from '@app/Events/EventTypes'; +import { Notifications, NotificationsInstance } from '@app/Notifications/Notifications'; +import { Rule } from '@app/Rules/Rules'; +import { + ActiveRecording, + ApiService, + ArchivedRecording, + EventProbe, + EventTemplate, + MBeanMetrics, + Recording, + RecordingAttributes, + RecordingState, + SimpleResponse, + StoredCredential, +} from '@app/Shared/Services/Api.service'; +import { LoginService } from '@app/Shared/Services/Login.service'; +import { CachedReportValue, ReportService, RuleEvaluation } from '@app/Shared/Services/Report.service'; +import { defaultServices, Services } from '@app/Shared/Services/Services'; +import { Target, TargetService } from '@app/Shared/Services/Target.service'; +import { from, Observable, of } from 'rxjs'; -const fakeTarget: Target = { +export const fakeTarget: Target = { jvmId: 'rpZeYNB9wM_TEnXoJvAFuR0jdcUBXZgvkXiKhjQGFvY=', connectUrl: 'service:jmx:rmi:///jndi/rmi://10-128-2-25.my-namespace.pod:9097/jmxrmi', alias: 'quarkus-test-77f556586c-25bkv', @@ -59,7 +78,7 @@ const fakeTarget: Target = { }, }; -const fakeAARecording: ActiveRecording = { +export const fakeAARecording: ActiveRecording = { name: 'automated-analysis', downloadUrl: 'https://clustercryostat-sample-default.apps.ci-ln-25fg5f2-76ef8.origin-ci-int-aws.dev.rhcloud.com:443/api/v1/targets/service:jmx:rmi:%2F%2F%2Fjndi%2Frmi:%2F%2F10-128-2-27.my-namespace.pod:9097%2Fjmxrmi/recordings/automated-analysis', @@ -81,3 +100,153 @@ const fakeAARecording: ActiveRecording = { maxSize: 1048576, maxAge: 0, }; + +export const fakeEvaluations: RuleEvaluation[] = [ + { + name: 'Passwords in Environment Variables', + description: 'The environment variables in the recording may contain passwords.', + score: 100, + topic: 'environment_variables', + }, + { + name: 'Class Leak', + description: 'No classes with identical names have been loaded more times than the limit.', + score: 0, + topic: 'classloading', + }, + { + name: 'Class Loading Pressure', + description: 'No significant time was spent loading new classes during this recording.', + score: 0, + topic: 'classloading', + }, +]; + +export const fakeCachedReport: CachedReportValue = { + report: fakeEvaluations, + timestamp: 1663027200000, +}; + +class FakeTargetService extends TargetService { + target(): Observable { + return of(fakeTarget); + } +} + +class FakeReportService extends ReportService { + constructor(notifications: Notifications, login: LoginService) { + super(login, notifications); + } + + reportJson(_recording: Recording, _connectUrl: string): Observable { + return of(fakeEvaluations); + } + + getCachedAnalysisReport(_connectUrl: string): CachedReportValue { + return fakeCachedReport; + } +} + +class FakeApiService extends ApiService { + constructor(target: TargetService, notifications: Notifications, login: LoginService) { + super(target, notifications, login); + } + + // MBean Metrics card + getTargetMBeanMetrics(_target: Target, _queries: string[]): Observable { + return from([{ os: { processCpuLoad: 0 } }, { os: { processCpuLoad: 1 } }, { os: { processCpuLoad: 0.5 } }]); + } + + // JFR Metrics card + targetHasRecording(_target: Target, _recordingName: string): Observable { + return of(true); + } + + uploadActiveRecordingToGrafana(_recordingName: string): Observable { + return of(true); + } + + grafanaDashboardUrl(): Observable { + return of('https://grafana-url'); + } + + // JVM Detail Cards + // Note T is expected to array due to its usage in EntityDetail component. + getTargetActiveRecordings(_target: Target): Observable { + return of([fakeAARecording]); + } + + getTargetArchivedRecordings(_target: Target): Observable { + return of([]); + } + + getTargetEventTemplates(_target: Target): Observable { + return of([]); + } + + getTargetEventTypes(_target: Target): Observable { + return of([]); + } + + getActiveProbesForTarget( + _target: Target, + _suppressNotifications?: boolean, + _skipStatusCheck?: boolean + ): Observable { + return of([]); + } + + getRules(_suppressNotifications?: boolean, _skipStatusCheck?: boolean): Observable { + return of([]); + } + + getCredentials(_suppressNotifications?: boolean, _skipStatusCheck?: boolean): Observable { + return of([]); + } + + // Automatic Analysis Card + // This fakes the fetch for Automatic Analysis recording to return available. + // Then subsequent graphql call for archived recording is ignored + graphql( + _query: string, + _variables?: unknown, + _suppressNotifications?: boolean | undefined, + _skipStatusCheck?: boolean | undefined + ): Observable { + return of({ + data: { + targetNodes: [ + { + recordings: { + active: { + data: [fakeAARecording], + }, + }, + }, + ], + }, + } as T); + } + + createRecording(_recordingAttributes: RecordingAttributes): Observable { + return of({ + ok: true, + status: 200, + }); + } + + deleteRecording(_recordingName: string): Observable { + return of(true); + } +} + +const target = new FakeTargetService(); +const api = new FakeApiService(target, NotificationsInstance, defaultServices.login); +const reports = new FakeReportService(NotificationsInstance, defaultServices.login); + +export const fakeServices: Services = { + ...defaultServices, + target, + api, + reports, +};