diff --git a/locales/en/common.json b/locales/en/common.json index 60ea403ca..923ced568 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -10,6 +10,8 @@ "CARD_TYPE": "Card type", "CLEAR_FILTERS": "Clear all filters", "CRITICAL": "CRITICAL", + "CREATE": "Create", + "CREATING": "Creating", "CRYOSTAT_TRADEMARK": "Copyright The Cryostat Authors, The Universal Permissive License (UPL), Version 1.0", "DATE": "Date", "DESCRIPTION": "Description", diff --git a/package.json b/package.json index 88003cd71..48cd4049a 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@patternfly/react-icons": "^4.93.6", "@patternfly/react-styles": "^4.92.6", "@patternfly/react-table": "^4.112.39", + "@patternfly/react-topology": "4.91.27", "@reduxjs/toolkit": "^1.9.3", "@types/lodash": "^4.14.191", "@types/react-router-dom": "^5.3.3", diff --git a/src/app/Archives/Archives.tsx b/src/app/Archives/Archives.tsx index 0b717a085..ba9946fff 100644 --- a/src/app/Archives/Archives.tsx +++ b/src/app/Archives/Archives.tsx @@ -44,10 +44,11 @@ import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Card, CardBody, EmptyState, EmptyStateIcon, Tab, Tabs, TabTitleText, Title } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; import * as React from 'react'; +import { StaticContext } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { of } from 'rxjs'; import { AllArchivedRecordingsTable } from './AllArchivedRecordingsTable'; import { AllTargetsArchivedRecordingsTable } from './AllTargetsArchivedRecordingsTable'; - /* This specific target is used as the "source" for the Uploads version of the ArchivedRecordingsTable. The connectUrl is the 'uploads' because for actions performed on uploaded archived recordings, @@ -60,12 +61,19 @@ export const uploadAsTarget: Target = { alias: '', }; -export interface ArchivesProps {} +export type SupportedTab = 'all-archives' | 'all-targets' | 'uploads'; + +export interface ArchivesProps { + tab?: SupportedTab; +} -export const Archives: React.FC = (_) => { +export const Archives: React.FC, StaticContext, ArchivesProps>> = ({ + location, + ..._props +}) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const [activeTab, setActiveTab] = React.useState(0); + const [activeTab, setActiveTab] = React.useState(location?.state?.tab || 'all-archives'); const [archiveEnabled, setArchiveEnabled] = React.useState(false); React.useEffect(() => { @@ -76,14 +84,14 @@ export const Archives: React.FC = (_) => { const cardBody = React.useMemo(() => { return archiveEnabled ? ( - setActiveTab(Number(idx))}> - All Targets}> + setActiveTab(`${key}` as SupportedTab)}> + All Targets}> - All Archives}> + All Archives}> - Uploads}> + Uploads}> @@ -106,4 +114,4 @@ export const Archives: React.FC = (_) => { ); }; -export default Archives; +export default withRouter(Archives); diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx index 76686df87..2d15e8b78 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx @@ -44,7 +44,7 @@ import { TemplateType, } from '@app/Shared/Services/Api.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; +import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; import { SelectTemplateSelectorForm } from '@app/TemplateSelector/SelectTemplateSelectorForm'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { @@ -64,12 +64,17 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { concatMap, filter, first } from 'rxjs'; +import { concatMap, filter, first, Observable } from 'rxjs'; + interface AutomatedAnalysisConfigFormProps { useTitle?: boolean; + targetObs?: Observable; } -export const AutomatedAnalysisConfigForm: React.FC = ({ useTitle = false }) => { +export const AutomatedAnalysisConfigForm: React.FC = ({ + useTitle = false, + targetObs, +}) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); const { t } = useTranslation(); @@ -103,8 +108,7 @@ export const AutomatedAnalysisConfigForm: React.FC { setIsLoading(true); addSubscription( - context.target - .target() + (targetObs ? targetObs : context.target.target()) .pipe( filter((target) => target !== NO_TARGET), first(), @@ -125,7 +129,7 @@ export const AutomatedAnalysisConfigForm: React.FC { addSubscription( @@ -139,12 +143,12 @@ export const AutomatedAnalysisConfigForm: React.FC { addSubscription( - context.target.target().subscribe(() => { + (targetObs ? targetObs : context.target.target()).subscribe(() => { refreshTemplates(); setIsLoading(false); }) ); - }, [addSubscription, context.target, refreshTemplates, setIsLoading]); + }, [addSubscription, targetObs, refreshTemplates, setIsLoading, context.target]); const getEventString = React.useCallback((templateName: string, templateType: string) => { let str = ''; @@ -340,13 +344,11 @@ export const AutomatedAnalysisConfigForm: React.FC; } - return ( + return useTitle ? (
- {useTitle ? ( - {formContent} - ) : ( - formContent - )} + {formContent}
+ ) : ( + formContent ); }; diff --git a/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx b/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx index 50d3dddea..7d708b40e 100644 --- a/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx +++ b/src/app/Dashboard/Charts/jfr/JFRMetricsChartCard.tsx @@ -37,6 +37,7 @@ */ import { CreateRecordingProps } from '@app/CreateRecording/CreateRecording'; +import { DashboardCardDescriptor, DashboardCardProps, DashboardCardSizes } from '@app/Dashboard/Dashboard'; import { LoadingView } from '@app/LoadingView/LoadingView'; import { ServiceContext } from '@app/Shared/Services/Services'; import { FeatureLevel } from '@app/Shared/Services/Settings.service'; @@ -60,7 +61,6 @@ import * as React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; import { interval } from 'rxjs'; -import { DashboardCardDescriptor, DashboardCardProps, DashboardCardSizes } from '@app/Dashboard/Dashboard'; import { DashboardCard } from '../../DashboardCard'; import { ChartContext } from './../ChartContext'; import { ControllerState, RECORDING_NAME } from './JFRMetricsChartController'; diff --git a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx index 697b7262e..b4fc05eea 100644 --- a/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx +++ b/src/app/Dashboard/Charts/mbean/MBeanMetricsChartCard.tsx @@ -36,6 +36,7 @@ * SOFTWARE. */ +import { DashboardCardDescriptor, DashboardCardProps, DashboardCardSizes } from '@app/Dashboard/Dashboard'; import { ServiceContext } from '@app/Shared/Services/Services'; import { FeatureLevel } from '@app/Shared/Services/Settings.service'; import useDayjs from '@app/utils/useDayjs'; @@ -56,7 +57,6 @@ import _ from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { interval } from 'rxjs'; -import { DashboardCardDescriptor, DashboardCardProps, DashboardCardSizes } from '@app/Dashboard/Dashboard'; import { DashboardCard } from '../../DashboardCard'; import { ChartContext } from './../ChartContext'; import { MBeanMetrics } from './MBeanMetricsChartController'; diff --git a/src/app/Dashboard/Dashboard.tsx b/src/app/Dashboard/Dashboard.tsx index bfcc7b0ef..302df7b5f 100644 --- a/src/app/Dashboard/Dashboard.tsx +++ b/src/app/Dashboard/Dashboard.tsx @@ -344,7 +344,7 @@ export const Dashboard: React.FC = (_) => { ); return ( - + {cardConfigs diff --git a/src/app/Events/Events.tsx b/src/app/Events/Events.tsx index 6c8333d0d..c424fb5dc 100644 --- a/src/app/Events/Events.tsx +++ b/src/app/Events/Events.tsx @@ -43,17 +43,29 @@ import { TargetView } from '@app/TargetView/TargetView'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Card, CardBody, Stack, StackItem, Tab, Tabs, Tooltip } from '@patternfly/react-core'; import * as React from 'react'; +import { StaticContext } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { concatMap, filter } from 'rxjs'; import { EventTemplates } from './EventTemplates'; import { EventTypes } from './EventTypes'; -export interface EventsProps {} +export type SupportedEventTab = 'templates' | 'types'; -export const Events: React.FC = (_) => { +export type SupportedAgentTab = 'templates' | 'probes'; + +export interface EventsProps { + eventTab?: SupportedEventTab; + agentTab?: SupportedAgentTab; +} + +export const Events: React.FC, StaticContext, EventsProps>> = ({ + location, + ...props +}) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); - const [eventActiveTab, setEventActiveTab] = React.useState(0); - const [probeActiveTab, setProbeActiveTab] = React.useState(0); + const [eventActiveTab, setEventActiveTab] = React.useState(location?.state?.eventTab || 'templates'); + const [probeActiveTab, setProbeActiveTab] = React.useState(location?.state?.agentTab || 'templates'); const [agentDetected, setAgentDetected] = React.useState(false); React.useEffect(() => { @@ -68,9 +80,15 @@ export const Events: React.FC = (_) => { ); }, [addSubscription, context.target, context.api, setAgentDetected]); - const handleEventTabSelect = React.useCallback((evt, idx) => setEventActiveTab(idx), [setEventActiveTab]); + const handleEventTabSelect = React.useCallback( + (evt, key: string | number) => setEventActiveTab(`${key}` as SupportedEventTab), + [setEventActiveTab] + ); - const handleProbeTabSelect = React.useCallback((evt, idx) => setProbeActiveTab(idx), [setProbeActiveTab]); + const handleProbeTabSelect = React.useCallback( + (evt, key: string | number) => setProbeActiveTab(`${key}` as SupportedAgentTab), + [setProbeActiveTab] + ); return ( <> @@ -80,10 +98,10 @@ export const Events: React.FC = (_) => { - + - + @@ -94,11 +112,11 @@ export const Events: React.FC = (_) => { - + = (_) => { ); }; -export default Events; +export default withRouter(Events); diff --git a/src/app/Recordings/Recordings.tsx b/src/app/Recordings/Recordings.tsx index 467685bbe..1290d8354 100644 --- a/src/app/Recordings/Recordings.tsx +++ b/src/app/Recordings/Recordings.tsx @@ -40,14 +40,23 @@ import { TargetView } from '@app/TargetView/TargetView'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { Card, CardBody, CardTitle, Tab, Tabs, TabTitleText } from '@patternfly/react-core'; import * as React from 'react'; +import { StaticContext } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; import { ActiveRecordingsTable } from './ActiveRecordingsTable'; import { ArchivedRecordingsTable } from './ArchivedRecordingsTable'; -export interface RecordingsProps {} +export type SupportedTab = 'active' | 'archived'; -export const Recordings: React.FunctionComponent = (_) => { +export interface RecordingsProps { + tab?: SupportedTab; +} + +export const Recordings: React.FC, StaticContext, RecordingsProps>> = ({ + location, + ..._props +}) => { const context = React.useContext(ServiceContext); - const [activeTab, setActiveTab] = React.useState(0); + const [activeTab, setActiveTab] = React.useState(location?.state?.tab || 'active'); const [archiveEnabled, setArchiveEnabled] = React.useState(false); const addSubscription = useSubscriptions(); @@ -55,17 +64,20 @@ export const Recordings: React.FunctionComponent = (_) => { addSubscription(context.api.isArchiveEnabled().subscribe(setArchiveEnabled)); }, [context.api, addSubscription, setArchiveEnabled]); - const onTabSelect = React.useCallback((_, idx) => setActiveTab(Number(idx)), [setActiveTab]); + const onTabSelect = React.useCallback( + (_, key: string | number) => setActiveTab(`${key}` as SupportedTab), + [setActiveTab] + ); const targetAsObs = React.useMemo(() => context.target.target(), [context.target]); const cardBody = React.useMemo(() => { return archiveEnabled ? ( - Active Recordings}> + Active Recordings}> - Archived Recordings}> + Archived Recordings}> @@ -86,4 +98,4 @@ export const Recordings: React.FunctionComponent = (_) => { ); }; -export default Recordings; +export default withRouter(Recordings); diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 9a6f6fcf4..631500859 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -43,7 +43,7 @@ import { MatchExpressionEvaluator } from '@app/Shared/MatchExpressionEvaluator'; import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; import { TemplateType } from '@app/Shared/Services/Api.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; +import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; import { SelectTemplateSelectorForm } from '@app/TemplateSelector/SelectTemplateSelectorForm'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { @@ -67,8 +67,8 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; import { useHistory, withRouter } from 'react-router-dom'; -import { iif } from 'rxjs'; -import { filter, first, mergeMap, toArray } from 'rxjs/operators'; +import { BehaviorSubject, iif } from 'rxjs'; +import { first, map, mergeMap } from 'rxjs/operators'; import { Rule } from './Rules'; // FIXME check if this is correct/matches backend name validation @@ -101,6 +101,15 @@ const Comp: React.FC = () => { const [errorMessage, setErrorMessage] = React.useState(''); const [loading, setLoading] = React.useState(false); + const targetSubject = React.useRef(new BehaviorSubject(NO_TARGET)).current; + + const handleTargetChange = React.useCallback( + (target: Target) => { + targetSubject.next(target); + }, + [targetSubject] + ); + const handleNameChange = React.useCallback( (name) => { setNameValid(RuleNamePattern.test(name) ? ValidatedOptions.success : ValidatedOptions.error); @@ -233,18 +242,19 @@ const Comp: React.FC = () => { const refreshTemplateList = React.useCallback(() => { addSubscription( - context.target - .target() + targetSubject .pipe( mergeMap((target) => iif( () => target !== NO_TARGET, context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/templates`), - context.api.doGet(`targets/localhost:0/templates`).pipe( - mergeMap((x) => x), - filter((template) => template.provider !== 'Cryostat' || template.name !== 'Cryostat'), - toArray() - ) + context.api + .doGet(`targets/localhost:0/templates`) + .pipe( + map((templates) => + templates.filter((template) => template.provider !== 'Cryostat' || template.name !== 'Cryostat') + ) + ) ) ), first() @@ -254,12 +264,14 @@ const Comp: React.FC = () => { error: handleError, }) ); - }, [addSubscription, context.api, context.target, handleError, handleTemplateList]); + }, [addSubscription, context.api, targetSubject, handleError, handleTemplateList]); React.useEffect(() => { - addSubscription(context.target.target().subscribe(refreshTemplateList)); - }, [addSubscription, context.target, refreshTemplateList]); + addSubscription(targetSubject.subscribe(refreshTemplateList)); + }, [addSubscription, targetSubject, refreshTemplateList]); + // Note: authFailure can be reused + // since no operation on global target selection is done here. React.useEffect(() => { addSubscription( context.target.authFailure().subscribe(() => { @@ -368,6 +380,11 @@ const Comp: React.FC = () => { type="text" id="rule-matchexpr" aria-describedby="rule-matchexpr-helper" + placeholder={ + targetSubject.value === NO_TARGET + ? undefined + : `target.connectUrl == '${targetSubject.value.connectUrl}'` + } onChange={setMatchExpression} validated={matchExpressionValid} /> @@ -580,6 +597,7 @@ const Comp: React.FC = () => { inlineHint matchExpression={matchExpression} onChange={setMatchExpressionValid} + onTargetChange={handleTargetChange} /> diff --git a/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx b/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx index d90cf8f51..e225bfcd9 100644 --- a/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx +++ b/src/app/SecurityPanel/Credentials/CreateJmxCredentialModal.tsx @@ -38,6 +38,7 @@ import { JmxAuthForm } from '@app/AppLayout/JmxAuthForm'; import { MatchExpressionEvaluator } from '@app/Shared/MatchExpressionEvaluator'; import { ServiceContext } from '@app/Shared/Services/Services'; +import { NO_TARGET } from '@app/Shared/Services/Target.service'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { FormGroup, @@ -66,6 +67,7 @@ export const CreateJmxCredentialModal: React.FunctionComponent { @@ -94,7 +96,11 @@ export const CreateJmxCredentialModal: React.FunctionComponent - + { + const [target, setTarget] = React.useState(NO_TARGET); + const _targetAsObs = React.useMemo(() => of(target), [target]); + return ( - + - + ); }; diff --git a/src/app/Shared/LinearDotSpinner.tsx b/src/app/Shared/LinearDotSpinner.tsx new file mode 100644 index 000000000..fef7bda87 --- /dev/null +++ b/src/app/Shared/LinearDotSpinner.tsx @@ -0,0 +1,48 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { css } from '@patternfly/react-styles'; +import * as React from 'react'; + +export interface LinearDotSpinnerProps { + className?: string; +} + +export const LinearDotSpinner: React.FC = ({ className, ..._ }) => { + return
; +}; diff --git a/src/app/Shared/MatchExpressionEvaluator.tsx b/src/app/Shared/MatchExpressionEvaluator.tsx index 043228bf7..79bdada0d 100644 --- a/src/app/Shared/MatchExpressionEvaluator.tsx +++ b/src/app/Shared/MatchExpressionEvaluator.tsx @@ -36,10 +36,9 @@ * SOFTWARE. */ import { SerializedTarget } from '@app/Shared/SerializedTarget'; -import { ServiceContext } from '@app/Shared/Services/Services'; -import { Target } from '@app/Shared/Services/Target.service'; +import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; import { TargetSelect } from '@app/TargetSelect/TargetSelect'; -import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { evaluateTargetWithExpr } from '@app/utils/utils'; import { ClipboardCopyButton, CodeBlock, @@ -61,37 +60,33 @@ import { InfoCircleIcon, WarningTriangleIcon, } from '@patternfly/react-icons'; +import _ from 'lodash'; import * as React from 'react'; export interface MatchExpressionEvaluatorProps { inlineHint?: boolean; matchExpression?: string; onChange?: (validated: ValidatedOptions) => void; + onTargetChange?: (target: Target) => void; } export const MatchExpressionEvaluator: React.FC = ({ inlineHint, matchExpression, onChange, + onTargetChange, }) => { - const context = React.useContext(ServiceContext); - const addSubscription = useSubscriptions(); - const [target, setTarget] = React.useState(undefined as Target | undefined); + const [target, setTarget] = React.useState(NO_TARGET); const [valid, setValid] = React.useState(ValidatedOptions.default); const [copied, setCopied] = React.useState(false); React.useEffect(() => { - addSubscription(context.target.target().subscribe(setTarget)); - }, [addSubscription, context.target, setTarget]); - - React.useEffect(() => { - if (!matchExpression || !target?.connectUrl) { + if (!matchExpression || !target.connectUrl) { setValid(ValidatedOptions.default); return; } try { - const f = new Function('target', `return ${matchExpression}`); - const res = f(target); + const res = evaluateTargetWithExpr(target, matchExpression); if (typeof res === 'boolean') { setValid(res ? ValidatedOptions.success : ValidatedOptions.warning); return; @@ -110,6 +105,10 @@ export const MatchExpressionEvaluator: React.FC = } }, [onChange, valid]); + React.useEffect(() => { + onTargetChange && onTargetChange(target); + }, [onTargetChange, target]); + const statusLabel = React.useMemo(() => { switch (valid) { case ValidatedOptions.success: @@ -131,7 +130,7 @@ export const MatchExpressionEvaluator: React.FC = ); default: - if (!target?.connectUrl) { + if (!target.connectUrl) { return ( ); } - }, [valid, target?.connectUrl]); + }, [valid, target.connectUrl]); const exampleExpression = React.useMemo(() => { let body: string; - if (!target || !target?.alias || !target?.connectUrl) { + if (!target || !target.alias || !target.connectUrl) { body = 'true'; } else { - body = `target.alias == '${target?.alias}' || target.annotations.cryostat['PORT'] == ${target?.annotations?.cryostat['PORT']}`; + body = `target.alias == '${target.alias}' || target.annotations.cryostat['PORT'] == ${target.annotations?.cryostat['PORT']}`; } body = JSON.stringify(body, null, 2); body = body.substring(1, body.length - 1); @@ -188,7 +187,7 @@ export const MatchExpressionEvaluator: React.FC = <> - + diff --git a/src/app/Shared/Redux/Configurations/TopologyConfigSlicer.tsx b/src/app/Shared/Redux/Configurations/TopologyConfigSlicer.tsx new file mode 100644 index 000000000..ebd48a906 --- /dev/null +++ b/src/app/Shared/Redux/Configurations/TopologyConfigSlicer.tsx @@ -0,0 +1,158 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { getDisplayFieldName } from '@app/utils/utils'; +import { createAction, createReducer } from '@reduxjs/toolkit'; +import { ReducerWithInitialState } from '@reduxjs/toolkit/dist/createReducer'; +import { getPersistedState } from '../utils'; + +const _version = '1'; + +export enum TopologyConfigAction { + VIEW_MODE_SET = 'topology-config/set-view-mode', + DISPLAY_OPTION_SET = 'topology-config/set-dislay-options', +} + +export const enumValues = new Set(Object.values(TopologyConfigAction)); + +export type ViewMode = 'graph' | 'list'; + +export interface DisplayOptions { + show: { + connectionUrl: boolean; + badge: boolean; + status: boolean; + icon: boolean; + }; + groupings: { + collapseSingles: boolean; + realmOnly: boolean; + }; +} + +export type OptionCategory = 'show' | 'groupings'; + +export interface TopologySetViewModeActionPayload { + viewMode: ViewMode; +} + +export interface TopologySetDisplayOptionsActionPayload { + category: string; + key: string; + value: boolean; +} + +export const topologyConfigSetViewModeIntent = createAction( + TopologyConfigAction.VIEW_MODE_SET, + (viewMode: ViewMode) => ({ + payload: { + viewMode, + } as TopologySetViewModeActionPayload, + }) +); + +export const topologyDisplayOpionsSetIntent = createAction( + TopologyConfigAction.DISPLAY_OPTION_SET, + (category: OptionCategory, key: string, value: boolean) => ({ + payload: { + category, + key, + value, + } as TopologySetDisplayOptionsActionPayload, + }) +); + +export interface TopologyConfig { + viewMode: ViewMode; + displayOptions: DisplayOptions; +} + +export const defaultDisplayOptions: DisplayOptions = { + show: { + connectionUrl: false, + badge: true, + status: true, + icon: true, + }, + groupings: { + realmOnly: false, + collapseSingles: false, + }, +}; + +export const showOptions: [string, string][] = Object.keys(defaultDisplayOptions.show).map((k) => { + return [getDisplayFieldName(k), k]; +}); + +export const groupingOptions: [string, string][] = Object.keys(defaultDisplayOptions.groupings).map((k) => { + return [getDisplayFieldName(k), k]; +}); + +const INITIAL_STATE: TopologyConfig = getPersistedState('TOPOLOGY_CONFIG', _version, { + viewMode: 'graph', + displayOptions: defaultDisplayOptions, +}); + +export const topologyConfigReducer: ReducerWithInitialState = createReducer( + INITIAL_STATE, + (builder) => { + builder.addCase(topologyConfigSetViewModeIntent, (state, { payload }) => { + state.viewMode = payload.viewMode; + }); + builder.addCase(topologyDisplayOpionsSetIntent, (state, { payload }) => { + const { category, key, value } = payload; + if (state.displayOptions[category]) { + state.displayOptions[category][key] = value; + } else { + state.displayOptions[category] = { + [key]: value, + }; + } + + // Special case for groupings + // If realmOnly is true, singleGroups should also be true + if (category === 'groupings' && key === 'realmOnly') { + if (value) { + state.displayOptions.groupings.collapseSingles = true; + } + } + }); + } +); + +export default topologyConfigReducer; diff --git a/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx b/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx new file mode 100644 index 000000000..adc3bbd41 --- /dev/null +++ b/src/app/Shared/Redux/Filters/TopologyFilterSlice.tsx @@ -0,0 +1,256 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { EnvironmentNode, NodeType, TargetNode } from '@app/Topology/typings'; +import { createAction, createReducer } from '@reduxjs/toolkit'; +import { ReducerWithInitialState } from '@reduxjs/toolkit/dist/createReducer'; +import { getPersistedState } from '../utils'; + +const _version = '1'; + +export enum TopologyFilterAction { + CATEGORY_TYPE_UPDATE = 'topology-category-type/update', + CATEGORY_UPDATE = 'topology-category/update', + FILTER_ADD = 'topology-filter/add', + FILTER_DELETE = 'topology-filter/delete', // Delete a filter in a category + FILTER_DELETE_ALL = 'topology-filter/delete-all', // Delete all filters in all categories + CATEGORY_FILTERS_DELETE = 'topology-filter/delete-category', // Delete all filters of the same category +} + +export const enumValues = new Set(Object.values(TopologyFilterAction)); + +export const topologyUpdateCategoryTypeIntent = createAction( + TopologyFilterAction.CATEGORY_TYPE_UPDATE, + (isGroup: boolean) => ({ + payload: { + isGroup, + }, + }) +); + +export const topologyUpdateCategoryIntent = createAction( + TopologyFilterAction.CATEGORY_UPDATE, + (isGroup: boolean, category: string) => ({ + payload: { + isGroup, + category, + }, + }) +); + +export const topologyAddFilterIntent = createAction( + TopologyFilterAction.FILTER_ADD, + (isGroup: boolean, nodeType: NodeType, category: string, value: string) => ({ + payload: { + isGroup, + nodeType, + category, + value, + }, + }) +); + +export const topologyDeleteFilterIntent = createAction( + TopologyFilterAction.FILTER_DELETE, + (isGroup: boolean, nodeType: string, category: string, value: string) => ({ + payload: { + isGroup, + nodeType, + category, + value, + }, + }) +); + +export const topologyDeleteAllFiltersIntent = createAction(TopologyFilterAction.FILTER_DELETE_ALL, () => ({ + payload: {}, +})); + +export const topologyDeleteCategoryFiltersIntent = createAction( + TopologyFilterAction.CATEGORY_FILTERS_DELETE, + (isGroup: boolean, nodeType: string, category: string) => ({ + payload: { + isGroup, + nodeType, + category, + }, + }) +); + +export interface TopologyFilters { + isGroup: boolean; + groupFilters: { + category: string; + filters: { + [nodeType: string]: { + Name: string[]; + Label: string[]; + }; + }; + }; + targetFilters: { + category: string; + filters: { + Alias: string[]; + ConnectionUrl: string[]; + JvmId: string[]; + Label: string[]; + Annotation: string[]; + }; + }; +} + +export const categoryToNodeField = (filterCategory: string): keyof EnvironmentNode | keyof TargetNode['target'] => { + switch (filterCategory) { + case 'Name': + return 'name'; + case 'Label': + return 'labels'; + case 'Annotation': + return 'annotations'; + case 'JvmId': + return 'jvmId'; + case 'Alias': + return 'alias'; + case 'ConnectionUrl': + return 'connectUrl'; + default: + throw new Error(`Unsupported ${filterCategory} for filters.`); + } +}; + +export const defaultEmptyGroupFilters = { + Name: [], + Label: [], +}; + +export const defaultEmptyTargetFilters = { + // Below will be taken from node.target + Alias: [], + ConnectionUrl: [], + JvmId: [], + Label: [], + Annotation: [], +}; + +export const defaultTopologyFilters: TopologyFilters = { + isGroup: false, + groupFilters: { + category: 'Name', + filters: {}, + }, + targetFilters: { + category: 'Alias', + filters: defaultEmptyTargetFilters, + }, +}; + +// Representing keys. Component can pipe it via getDisplayFieldName +export const allowedTargetFilters = Object.keys(defaultEmptyTargetFilters); + +export const allowedGroupFilters = Object.keys(defaultEmptyGroupFilters); + +const INITIAL_STATE: TopologyFilters = getPersistedState('TOPOLOGY_FILTERS', _version, defaultTopologyFilters); + +export const topologyFilterReducer: ReducerWithInitialState = createReducer( + INITIAL_STATE, + (builder) => { + builder.addCase(topologyUpdateCategoryTypeIntent, (state, { payload }) => { + state.isGroup = payload.isGroup; + }); + builder.addCase(topologyUpdateCategoryIntent, (state, { payload }) => { + const { isGroup, category } = payload; + if (isGroup) { + state.groupFilters.category = category; + } else { + state.targetFilters.category = category; + } + }); + builder.addCase(topologyAddFilterIntent, (state, { payload }) => { + const { isGroup, category, value, nodeType } = payload; + if (isGroup) { + const old = state.groupFilters.filters[nodeType] || defaultEmptyGroupFilters; + state.groupFilters.filters[nodeType] = { + ...old, + [category]: [...old[category], value], + }; + } else { + const old: string[] = state.targetFilters.filters[category]; + state.targetFilters.filters[category] = [...old.filter((val: string) => val !== value), value]; + } + }); + builder.addCase(topologyDeleteFilterIntent, (state, { payload }) => { + const { isGroup, category, value, nodeType } = payload; + if (isGroup) { + const old = state.groupFilters.filters[nodeType] || defaultEmptyGroupFilters; + state.groupFilters.filters[nodeType] = { + ...old, + [category]: old[category].filter((val: string) => val !== value), + }; + } else { + const old: string[] = state.targetFilters.filters[category]; + state.targetFilters.filters[category] = old.filter((val: string) => val !== value); + } + }); + builder.addCase(topologyDeleteCategoryFiltersIntent, (state, { payload }) => { + const { isGroup, category, nodeType } = payload; + if (isGroup) { + const old = state.groupFilters.filters[nodeType] || defaultEmptyGroupFilters; + state.groupFilters.filters[nodeType] = { + ...old, + [category]: [], + }; + } else { + state.targetFilters.filters[category] = []; + } + }); + builder.addCase(topologyDeleteAllFiltersIntent, (state, _) => { + state.groupFilters = { + category: state.groupFilters.category, + filters: {}, + }; + + state.targetFilters = { + category: state.targetFilters.category, + filters: defaultEmptyTargetFilters, + }; + }); + } +); + +export default topologyFilterReducer; diff --git a/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx b/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx index b8c66b1ec..f829f515a 100644 --- a/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx +++ b/src/app/Shared/Redux/Middlewares/PersistMiddleware.tsx @@ -38,8 +38,10 @@ import { saveToLocalStorage } from '@app/utils/LocalStorage'; import { Middleware } from '@reduxjs/toolkit'; import { enumValues as DashboardConfigActions } from '../Configurations/DashboardConfigSlicer'; +import { enumValues as TopologyConfigActions } from '../Configurations/TopologyConfigSlicer'; import { enumValues as AutomatedAnalysisFilterActions } from '../Filters/AutomatedAnalysisFilterSlice'; import { enumValues as RecordingFilterActions } from '../Filters/RecordingFilterSlice'; +import { enumValues as TopologyFilterActions } from '../Filters/TopologyFilterSlice'; import { RootState } from '../ReduxStore'; /* eslint-disable-next-line @typescript-eslint/ban-types*/ @@ -56,6 +58,10 @@ export const persistMiddleware: Middleware<{}, RootState> = saveToLocalStorage('TARGET_RECORDING_FILTERS', rootState.recordingFilters); } else if (DashboardConfigActions.has(action.type)) { saveToLocalStorage('DASHBOARD_CFG', rootState.dashboardConfigs); + } else if (TopologyConfigActions.has(action.type)) { + saveToLocalStorage('TOPOLOGY_CONFIG', rootState.topologyConfigs); + } else if (TopologyFilterActions.has(action.type)) { + saveToLocalStorage('TOPOLOGY_FILTERS', rootState.topologyFilters); } else { console.warn(`Action ${action.type} does not persist state.`); } diff --git a/src/app/Shared/Redux/ReduxStore.tsx b/src/app/Shared/Redux/ReduxStore.tsx index 5d7862bc4..1f2cf3506 100644 --- a/src/app/Shared/Redux/ReduxStore.tsx +++ b/src/app/Shared/Redux/ReduxStore.tsx @@ -38,8 +38,10 @@ import { combineReducers, configureStore, PreloadedState } from '@reduxjs/toolkit'; import dashboardConfigReducer, * as dashboardConfigSlice from './Configurations/DashboardConfigSlicer'; +import topologyConfigReducer, * as topologyConfigSlice from './Configurations/TopologyConfigSlicer'; import automatedAnalysisFilterReducer, * as automatedAnalysisFilterSlice from './Filters/AutomatedAnalysisFilterSlice'; import recordingFilterReducer, * as recordingFilterSlice from './Filters/RecordingFilterSlice'; +import topologyFilterReducer, * as topologyFilterSlice from './Filters/TopologyFilterSlice'; import { persistMiddleware } from './Middlewares/PersistMiddleware'; // Export actions @@ -69,10 +71,23 @@ export const { automatedAnalysisUpdateCategoryIntent, } = automatedAnalysisFilterSlice; +export const { topologyConfigSetViewModeIntent, topologyDisplayOpionsSetIntent } = topologyConfigSlice; + +export const { + topologyUpdateCategoryTypeIntent, + topologyUpdateCategoryIntent, + topologyAddFilterIntent, + topologyDeleteAllFiltersIntent, + topologyDeleteCategoryFiltersIntent, + topologyDeleteFilterIntent, +} = topologyFilterSlice; + export const rootReducer = combineReducers({ dashboardConfigs: dashboardConfigReducer, recordingFilters: recordingFilterReducer, automatedAnalysisFilters: automatedAnalysisFilterReducer, + topologyConfigs: topologyConfigReducer, + topologyFilters: topologyFilterReducer, }); export const setupStore = (preloadedState?: PreloadedState) => diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index d0df688fd..53def54dc 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -39,6 +39,7 @@ import { Notifications } from '@app/Notifications/Notifications'; import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; import { Rule } from '@app/Rules/Rules'; +import { EnvironmentNode } from '@app/Topology/typings'; import { createBlobURL } from '@app/utils/utils'; import _ from 'lodash'; import { EMPTY, forkJoin, from, Observable, ObservableInput, of, ReplaySubject, shareReplay, throwError } from 'rxjs'; @@ -46,7 +47,7 @@ import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, filter, first, map, mergeMap, tap } from 'rxjs/operators'; import { AuthMethod, LoginService, SessionState } from './Login.service'; import { NotificationCategory } from './NotificationChannel.service'; -import { NO_TARGET, Target, TargetService } from './Target.service'; +import { includesTarget, NO_TARGET, Target, TargetService } from './Target.service'; type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'v2.2' | 'beta'; @@ -192,7 +193,7 @@ export class ApiService { createTarget(target: Target): Observable { const form = new window.FormData(); form.append('connectUrl', target.connectUrl); - if (!!target.alias && !!target.alias.trim()) { + if (target.alias && target.alias.trim()) { form.append('alias', target.alias); } return this.sendRequest('v2', `targets`, { @@ -205,6 +206,44 @@ export class ApiService { ); } + testTarget( + target: Target, + credentials?: { username?: string; password?: string }, + abortSignal?: AbortSignal + ): Observable<{ status: number; body: object }> { + const form = new window.FormData(); + form.append('connectUrl', target.connectUrl); + if (!!target.alias && !!target.alias.trim()) { + form.append('alias', target.alias); + } + credentials?.username && form.append('username', credentials.username); + credentials?.password && form.append('password', credentials.password); + + return this.sendRequest( + 'v2', + `targets`, + { + method: 'POST', + body: form, + signal: abortSignal, + }, + new URLSearchParams({ dryrun: 'true' }), + true, + true + ).pipe( + first(), + concatMap((resp) => resp.json().then((body) => ({ status: resp.status, body: body as object }))), + catchError((err: Error) => { + if (isHttpError(err)) { + return from( + err.httpResponse.json().then((body) => ({ status: err.httpResponse.status, body: body as object })) + ); + } + return of({ status: 0, body: { data: { reason: err.message } } }); // Status 0 -> request is not completed + }) + ); + } + deleteTarget(target: Target): Observable { return this.sendRequest('v2', `targets/${encodeURIComponent(target.connectUrl)}`, { method: 'DELETE', @@ -669,8 +708,14 @@ export class ApiService { return this.grafanaDashboardUrlSubject.asObservable(); } - doGet(path: string, apiVersion: ApiVersion = 'v1'): Observable { - return this.sendRequest(apiVersion, path, { method: 'GET' }).pipe( + doGet( + path: string, + apiVersion: ApiVersion = 'v1', + params?: URLSearchParams, + suppressNotifications?: boolean, + skipStatusCheck?: boolean + ): Observable { + return this.sendRequest(apiVersion, path, { method: 'GET' }, params, suppressNotifications, skipStatusCheck).pipe( map((resp) => resp.json()), concatMap(from), first() @@ -705,17 +750,50 @@ export class ApiService { ); } - graphql(query: string, variables?: unknown): Observable { + getActiveProbesForTarget( + target: Target, + suppressNotifications = false, + skipStatusCheck = false + ): Observable { + return this.sendRequest( + 'v2', + `targets/${encodeURIComponent(target.connectUrl)}/probes`, + { + method: 'GET', + }, + undefined, + suppressNotifications, + skipStatusCheck + ).pipe( + concatMap((resp) => resp.json()), + map((response: EventProbesResponse) => response.data.result), + first() + ); + } + + graphql( + query: string, + variables?: unknown, + suppressNotifications?: boolean, + skipStatusCheck?: boolean + ): Observable { const headers = new Headers(); headers.set('Content-Type', 'application/json'); - return this.sendRequest('v2.2', 'graphql', { - method: 'POST', - body: JSON.stringify({ - query: query.replace(/[\s]+/g, ' '), - variables, - }), - headers, - }).pipe( + return this.sendRequest( + 'v2.2', + 'graphql', + { + method: 'POST', + body: JSON.stringify({ + query: query.replace(/[\s]+/g, ' '), + variables, + }), + headers, + }, + undefined, + suppressNotifications, + skipStatusCheck + ).pipe( map((resp) => resp.json()), concatMap(from), first() @@ -978,10 +1056,17 @@ export class ApiService { ); } - getCredentials(): Observable { - return this.sendRequest('v2.2', `credentials`, { - method: 'GET', - }).pipe( + getCredentials(suppressNotifications = false, skipStatusCheck = false): Observable { + return this.sendRequest( + 'v2.2', + `credentials`, + { + method: 'GET', + }, + undefined, + suppressNotifications, + skipStatusCheck + ).pipe( concatMap((resp) => resp.json()), map((response: CredentialsResponse) => response.data.result), first() @@ -997,12 +1082,54 @@ export class ApiService { ); } - getRules(): Observable { - return this.sendRequest('v2', 'rules', { + getRules(suppressNotifications = false, skipStatusCheck = false): Observable { + return this.sendRequest( + 'v2', + 'rules', + { + method: 'GET', + }, + undefined, + suppressNotifications, + skipStatusCheck + ).pipe( + concatMap((resp) => resp.json()), + map((response: RulesResponse) => response.data.result), + first() + ); + } + + getDiscoveryTree(): Observable { + return this.sendRequest('v2.1', 'discovery', { method: 'GET', }).pipe( concatMap((resp) => resp.json()), - map((response: RulesResponse) => response.data.result), + map((body: DiscoveryResponse) => body.data.result as EnvironmentNode), + first() + ); + } + + isTargetMatched(matchExpression: string, target: Target): Observable { + const body = new window.FormData(); + body.append('matchExpression', matchExpression); + body.append('targets', JSON.stringify([target])); + + return this.sendRequest( + 'beta', + 'matchExpressions', + { + method: 'POST', + body: body, + }, + undefined, + true, + true + ).pipe( + concatMap((resp: Response) => resp.json()), + map((body) => { + const matchedTargets: Target[] = body.data.result.targets || []; + return includesTarget(matchedTargets, target); + }), first() ); } @@ -1258,6 +1385,12 @@ interface RulesResponse extends ApiV2Response { }; } +interface DiscoveryResponse extends ApiV2Response { + data: { + result: object; + }; +} + interface XMLHttpResponse { body: any; headers: object; diff --git a/src/app/Shared/Services/Target.service.tsx b/src/app/Shared/Services/Target.service.tsx index 0fa6ec9ec..1db2f5fe7 100644 --- a/src/app/Shared/Services/Target.service.tsx +++ b/src/app/Shared/Services/Target.service.tsx @@ -57,6 +57,9 @@ export const indexOfTarget = (arr: Target[], target: Target): number => { return index; }; +export const getTargetRepresentation = (t: Target) => + !t.alias || t.alias === t.connectUrl ? `${t.connectUrl}` : `${t.alias} (${t.connectUrl})`; + export interface Target { jvmId?: string; // present in responses, but we do not need to provide it in requests connectUrl: string; diff --git a/src/app/TargetSelect/TargetSelect.tsx b/src/app/TargetSelect/TargetSelect.tsx index 16389ac17..9d4142895 100644 --- a/src/app/TargetSelect/TargetSelect.tsx +++ b/src/app/TargetSelect/TargetSelect.tsx @@ -35,211 +35,149 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import { DeleteWarningModal } from '@app/Modal/DeleteWarningModal'; -import { DeleteOrDisableWarningType } from '@app/Modal/DeleteWarningUtils'; -import { NotificationsContext } from '@app/Notifications/Notifications'; -import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { LoadingView } from '@app/LoadingView/LoadingView'; import { SerializedTarget } from '@app/Shared/SerializedTarget'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { isEqualTarget, NO_TARGET, Target } from '@app/Shared/Services/Target.service'; +import { includesTarget, NO_TARGET, Target } from '@app/Shared/Services/Target.service'; import { NoTargetSelected } from '@app/TargetView/NoTargetSelected'; -import { getFromLocalStorage, removeFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; +import { getFromLocalStorage } from '@app/utils/LocalStorage'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { - Button, Card, - CardActions, CardBody, CardExpandableContent, CardHeader, CardTitle, + Divider, Select, + SelectGroup, SelectOption, SelectVariant, } from '@patternfly/react-core'; -import { ContainerNodeIcon, PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; +import { ContainerNodeIcon } from '@patternfly/react-icons'; import * as React from 'react'; -import { CreateTargetModal } from './CreateTargetModal'; - -export const CUSTOM_TARGETS_REALM = 'Custom Targets'; export interface TargetSelectProps { // display a simple, non-expandable component. set this if the view elsewhere // contains a or other repeated components simple?: boolean; + onSelect?: (target: Target) => void; } -export const TargetSelect: React.FunctionComponent = (props) => { - const notifications = React.useContext(NotificationsContext); +export const TargetSelect: React.FunctionComponent = ({ onSelect, simple, ...props }) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); + const firstLoadRef = React.useRef(false); const [isExpanded, setExpanded] = React.useState(false); - const [selected, setSelected] = React.useState(NO_TARGET); + const [selected, setSelected] = React.useState(NO_TARGET); const [targets, setTargets] = React.useState([] as Target[]); const [isDropdownOpen, setDropdownOpen] = React.useState(false); const [isLoading, setLoading] = React.useState(false); - const [isModalOpen, setModalOpen] = React.useState(false); - const [warningModalOpen, setWarningModalOpen] = React.useState(false); - - const setCachedTargetSelection = React.useCallback( - (targetConnectUrl: string, errorCallback?: () => void) => - saveToLocalStorage('TARGET', targetConnectUrl, errorCallback), - [] - ); - - const removeCachedTargetSelection = React.useCallback(() => removeFromLocalStorage('TARGET'), []); - - const getCachedTargetSelection = React.useCallback(() => getFromLocalStorage('TARGET', NO_TARGET.connectUrl), []); - - const resetTargetSelection = React.useCallback(() => { - context.target.setTarget(NO_TARGET); - removeCachedTargetSelection(); - }, [context.target, removeCachedTargetSelection]); const onExpand = React.useCallback(() => { setExpanded((v) => !v); }, [setExpanded]); - const onSelect = React.useCallback( - // ATTENTION: do not add onSelect as deps for effect hook as it updates with selected states + const _refreshTargetList = React.useCallback(() => { + setLoading(true); + addSubscription( + context.targets.queryForTargets().subscribe(() => { + // Reset loading and context.targets.targets will emit + setLoading(false); + }) + ); + }, [addSubscription, context.targets, setLoading]); + + const handleSelect = React.useCallback( (_, selection, isPlaceholder) => { - if (isPlaceholder) { - resetTargetSelection(); - } else { - if (!isEqualTarget(selection as Target, selected)) { - context.target.setTarget(selection); - setCachedTargetSelection((selection as Target).connectUrl, () => { - notifications.danger('Cannot set target'); - context.target.setTarget(NO_TARGET); - }); - } - } setDropdownOpen(false); + const toSelect: Target = isPlaceholder ? NO_TARGET : selection; + onSelect && onSelect(toSelect); + setSelected(toSelect); }, - [context.target, notifications, setDropdownOpen, setCachedTargetSelection, resetTargetSelection, selected] - ); - - const selectTargetFromCache = React.useCallback( - (targets: Target[]) => { - if (!targets.length) { - // Ignore first emitted value - return; - } - const cachedTargetConnectUrl = getCachedTargetSelection(); - const matchedTarget = targets.find((t) => t.connectUrl === cachedTargetConnectUrl); - if (matchedTarget) { - context.target.setTarget(matchedTarget); - } else { - resetTargetSelection(); - } - }, - [context.target, getCachedTargetSelection, resetTargetSelection] + [setDropdownOpen, onSelect, setSelected] ); React.useEffect(() => { - addSubscription( - context.targets.targets().subscribe((targets) => { - // Target Discovery notifications will trigger an event here. - setTargets(targets); - selectTargetFromCache(targets); - }) - ); - }, [addSubscription, context.targets, setTargets, selectTargetFromCache]); - - React.useEffect(() => { - addSubscription(context.target.target().subscribe(setSelected)); - }, [addSubscription, context.target, setSelected]); - - const refreshTargetList = React.useCallback(() => { - setLoading(true); - addSubscription(context.targets.queryForTargets().subscribe(() => setLoading(false))); - }, [addSubscription, context.targets, setLoading]); + addSubscription(context.targets.targets().subscribe(setTargets)); + }, [addSubscription, context.targets, setTargets]); React.useEffect(() => { if (!context.settings.autoRefreshEnabled()) { return; } const id = window.setInterval( - () => refreshTargetList(), + () => _refreshTargetList(), context.settings.autoRefreshPeriod() * context.settings.autoRefreshUnits() ); return () => window.clearInterval(id); - }, [context.settings, refreshTargetList]); - - const showCreateTargetModal = React.useCallback(() => { - setModalOpen(true); - }, [setModalOpen]); + }, [context.settings, _refreshTargetList]); - const closeCreateTargetModal = React.useCallback(() => { - setModalOpen(false); - }, [setModalOpen]); - - const deleteTarget = React.useCallback(() => { - setLoading(true); - addSubscription( - context.api.deleteTarget(selected).subscribe((ok) => { - setLoading(false); - if (!ok) { - const id = - !selected.alias || selected.alias === selected.connectUrl - ? selected.connectUrl - : `${selected.alias} [${selected.connectUrl}]`; - notifications.danger('Target Deletion Failed', `The selected target (${id}) could not be deleted`); - } - }) - ); - }, [addSubscription, context.api, notifications, selected, setLoading]); - - const deletionDialogsEnabled = React.useMemo( - () => context.settings.deletionDialogsEnabledFor(DeleteOrDisableWarningType.DeleteCustomTargets), - [context.settings] - ); - - const handleDeleteButton = React.useCallback(() => { - if (deletionDialogsEnabled) { - setWarningModalOpen(true); - } else { - deleteTarget(); + React.useEffect(() => { + if (selected !== NO_TARGET && !includesTarget(targets, selected)) { + handleSelect(undefined, undefined, true); } - }, [deletionDialogsEnabled, setWarningModalOpen, deleteTarget]); - - const handleWarningModalClose = React.useCallback(() => { - setWarningModalOpen(false); - }, [setWarningModalOpen]); - - const handleCreateModalClose = React.useCallback(() => { - setModalOpen(false); - }, [setModalOpen]); - - const deleteArchivedWarningModal = React.useMemo(() => { - return ( - + if (targets.length && !firstLoadRef.current) { + firstLoadRef.current = true; + const cachedUrl = getFromLocalStorage('TARGET', undefined); + const matched = targets.find((tn) => tn.connectUrl === cachedUrl); + if (matched) { + handleSelect(undefined, matched, false); + } + } + }, [handleSelect, targets, selected, firstLoadRef]); + + const selectOptions = React.useMemo(() => { + let options = [ + , + , + ]; + + const groupNames = new Set(); + targets.forEach((t) => groupNames.add(t.annotations?.cryostat['REALM'] || 'Others')); + + options = options.concat( + Array.from(groupNames) + .map((name) => ( + + {targets + .filter((t) => (t.annotations?.cryostat['REALM'] || 'Others') === name) + .map((t: Target) => ( + + {!t.alias || t.alias === t.connectUrl ? `${t.connectUrl}` : `${t.alias} (${t.connectUrl})`} + + ))} + + )) + .sort((a, b) => `${a.props['label']}`.localeCompare(`${b.props['label']}`)) ); - }, [warningModalOpen, deleteTarget, handleWarningModalClose]); + return options; + }, [targets]); - const selectOptions = React.useMemo( - () => - [ - , - ].concat( - targets.map((t: Target) => ( - - {!t.alias || t.alias === t.connectUrl ? `${t.connectUrl}` : `${t.alias} (${t.connectUrl})`} - - )) - ), - [targets] + const handleTargetFilter = React.useCallback( + (_, value: string) => { + if (!value) { + return selectOptions; + } + const matchExp = new RegExp(value, 'i'); + return selectOptions + .filter((grp) => grp.props.children) + .map((grp) => + React.cloneElement(grp, { + children: grp.props.children.filter( + (option) => matchExp.test(option.props.value.connectUrl) || matchExp.test(option.props.value.alias) + ), + }) + ) + .filter((grp) => grp.props.children.length > 0); + }, + [selectOptions] ); const cardHeaderProps = React.useMemo( () => - props.simple + simple ? {} : { onExpand: onExpand, @@ -250,71 +188,45 @@ export const TargetSelect: React.FunctionComponent = (props) 'aria-expanded': isExpanded, }, }, - [props.simple, onExpand, isExpanded] - ); - - const deleteButtonLoadingProps = React.useMemo( - () => - ({ - spinnerAriaValueText: 'Deleting', - spinnerAriaLabel: 'deleting-custom-target', - isLoading: isLoading, - } as LoadingPropsType), - [isLoading] + [simple, onExpand, isExpanded] ); return ( - <> - - - Target JVM - - + + ), + [] + ); + + return ( + <> +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + ); +}; diff --git a/src/app/TargetView/TargetView.tsx b/src/app/TargetView/TargetView.tsx index 706d39cce..3daae364a 100644 --- a/src/app/TargetView/TargetView.tsx +++ b/src/app/TargetView/TargetView.tsx @@ -38,15 +38,14 @@ import { BreadcrumbPage, BreadcrumbTrail } from '@app/BreadcrumbPage/BreadcrumbPage'; import { ServiceContext } from '@app/Shared/Services/Services'; import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { TargetSelect } from '@app/TargetSelect/TargetSelect'; import { useSubscriptions } from '@app/utils/useSubscriptions'; -import { Grid, GridItem, gridSpans } from '@patternfly/react-core'; import * as React from 'react'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import { NoTargetSelected } from './NoTargetSelected'; +import { TargetContextSelector } from './TargetContextSelector'; interface TargetViewProps { pageTitle: string; - compactSelect?: boolean; breadcrumbs?: BreadcrumbTrail[]; children: React.ReactNode; } @@ -58,38 +57,21 @@ export const TargetView: React.FunctionComponent = (props) => { React.useEffect(() => { addSubscription( - context.target.target().subscribe((target) => { - setHasSelection(target !== NO_TARGET); - }) + context.target + .target() + .pipe( + map((target) => target !== NO_TARGET), + distinctUntilChanged() + ) + .subscribe(setHasSelection) ); }, [context.target, addSubscription, setHasSelection]); - const compact = React.useMemo( - () => (props.compactSelect == null ? true : props.compactSelect), - [props.compactSelect] - ); - - const responsiveSpans = React.useMemo( - () => - ({ - sm: 12, - md: 12, - lg: compact ? 6 : 12, - xl: compact ? 6 : 12, - xl2: compact ? 6 : 12, - } as Record<'sm' | 'md' | 'lg' | 'xl' | 'xl2', gridSpans>), - [compact] - ); - return ( <> + - - - - - {hasSelection ? props.children : } - + {hasSelection ? props.children : } ); diff --git a/src/app/Topology/Actions/CreateTarget.tsx b/src/app/Topology/Actions/CreateTarget.tsx new file mode 100644 index 000000000..09e393bc0 --- /dev/null +++ b/src/app/Topology/Actions/CreateTarget.tsx @@ -0,0 +1,522 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import openjdkSvg from '@app/assets/openjdk.svg'; +import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; +import { isHttpOk } from '@app/Shared/Services/Api.service'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { Target } from '@app/Shared/Services/Target.service'; +import '@app/Topology/styles/base.css'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + ActionGroup, + Alert, + Bullseye, + Button, + Card, + CardBody, + CardTitle, + ClipboardCopy, + Flex, + FlexItem, + Form, + FormAlert, + FormGroup, + FormHelperText, + Grid, + GridItem, + gridSpans, + HelperText, + HelperTextItem, + TextInput, + Tooltip, + ValidatedOptions, +} from '@patternfly/react-core'; +import { CheckCircleIcon, ExclamationCircleIcon, PendingIcon, SyncAltIcon } from '@patternfly/react-icons'; +import { css } from '@patternfly/react-styles'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +export const isValidTargetConnectURL = (connectUrl?: string) => connectUrl && !connectUrl.match(/\s+/); + +export interface CreateTargetProps { + prefilled?: { + connectUrl: string; + alias?: string; + username?: string; + password?: string; + }; +} + +export const CreateTarget: React.FC = ({ prefilled, ..._props }) => { + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const history = useHistory(); + const [t] = useTranslation(); + + const [example, setExample] = React.useState(''); + const [{ connectUrl, alias, validConnectUrl, username, password }, setFormData] = React.useState({ + connectUrl: '', + alias: '', + validConnectUrl: ValidatedOptions.default, + username: '', + password: '', + }); + const [loading, setLoading] = React.useState(false); + const [testing, setTesting] = React.useState(false); + const [validation, setValidation] = React.useState({ + option: ValidatedOptions.default, + errorMessage: '', + }); + const [expandedSections, setExpandedSections] = React.useState([]); // Array of ids + + const target = React.useMemo(() => ({ connectUrl, alias }), [connectUrl, alias]); + + const credentials = React.useMemo(() => ({ username, password }), [username, password]); + + const createButtonLoadingProps = React.useMemo( + () => + ({ + spinnerAriaValueText: 'Creating', + spinnerAriaLabel: 'creating-custom-target', + isLoading: loading, + } as LoadingPropsType), + [loading] + ); + + const toggleCredentialForm = React.useCallback( + (toggleId: string) => + setExpandedSections((old) => { + const matched = old.find((id) => id === toggleId); + if (matched) { + return old.filter((id) => id !== matched); + } else { + return [...old, toggleId]; + } + }), + [setExpandedSections] + ); + + const resetTestState = React.useCallback( + () => setValidation({ option: ValidatedOptions.default, errorMessage: '' }), + [setValidation] + ); + + const handleConnectUrlChange = React.useCallback( + (connectUrl: string) => { + setFormData((old) => ({ + ...old, + connectUrl, + validConnectUrl: + connectUrl === '' + ? ValidatedOptions.default + : isValidTargetConnectURL(connectUrl) + ? ValidatedOptions.success + : ValidatedOptions.error, + })); + resetTestState(); + }, + [setFormData, resetTestState] + ); + + const handleAliasChange = React.useCallback( + (alias: string) => { + setFormData((old) => ({ ...old, alias })); + resetTestState(); + }, + [setFormData, resetTestState] + ); + + const handleUsernameChange = React.useCallback( + (username: string) => { + setFormData((old) => ({ ...old, username })); + resetTestState(); + }, + [setFormData, resetTestState] + ); + + const handlePasswordChange = React.useCallback( + (password: string) => { + setFormData((old) => ({ ...old, password })); + resetTestState(); + }, + [setFormData, resetTestState] + ); + + const handleSubmit = React.useCallback(() => { + setLoading(true); + addSubscription( + context.api + .createTarget({ + connectUrl: connectUrl, + alias: alias.trim() || connectUrl, + }) + .subscribe((success) => { + setLoading(false); + if (success) { + history.push('/topology'); + } + }) + ); + }, [setLoading, addSubscription, context.api, connectUrl, alias, history]); + + const testTarget = React.useCallback(() => { + if (!isValidTargetConnectURL(connectUrl)) { + return; + } + addSubscription( + context.api + .testTarget( + { + connectUrl: connectUrl, + alias: alias.trim() || connectUrl, + }, + credentials + ) + .subscribe(({ status, body }) => { + setTesting(false); + const option = isHttpOk(status) ? ValidatedOptions.success : ValidatedOptions.error; + setValidation({ + option: option, + errorMessage: option !== ValidatedOptions.success ? body['data']['reason'] : '', + }); + }) + ); + setTesting(true); + resetTestState(); + }, [connectUrl, alias, credentials, addSubscription, context.api, resetTestState, setTesting]); + + const handleCancel = React.useCallback(() => history.goBack(), [history]); + + React.useEffect(() => { + if (prefilled) { + const { connectUrl, alias, username, password } = prefilled; + setFormData({ + connectUrl: connectUrl, + alias: alias || '', + validConnectUrl: isValidTargetConnectURL(connectUrl) ? ValidatedOptions.success : ValidatedOptions.error, + username: username || '', + password: password || '', + }); + } + }, [prefilled]); + + React.useEffect(() => { + addSubscription( + context.targets.targets().subscribe((ts) => { + const discoveredTargets = ts.filter((t) => t.annotations?.cryostat['REALM'] !== 'Custom Targets'); + if (discoveredTargets.length) { + setExample(discoveredTargets[0].connectUrl); + } + }) + ); + }, [addSubscription, context.targets, setExample]); + + const responsiveSpans = React.useMemo( + () => [ + { + xl2: 7 as gridSpans, + xl: 7 as gridSpans, + lg: 7 as gridSpans, + md: 12 as gridSpans, + sm: 12 as gridSpans, + } as Record<'xl2' | 'xl' | 'lg' | 'md' | 'sm', gridSpans>, + { + xl2: 5 as gridSpans, + xl: 5 as gridSpans, + lg: 5 as gridSpans, + md: 12 as gridSpans, + sm: 12 as gridSpans, + } as Record<'xl2' | 'xl' | 'lg' | 'md' | 'sm', gridSpans>, + ], + [] + ); + + return ( + + + Create Custom Target + +
+ + + + + + + JMX Service URL.{' '} + {example && ( + <> + For example, + + {example} + + + )} + + } + helperTextInvalid={'JMX Service URL must not contain empty spaces.'} + validated={validConnectUrl} + > + + + + Connection Nickname (same as Connection URL if not specified). + + } + > + + + + + + toggleCredentialForm('jmx-credential-option')} + type={'button'} + > + JMX Credential Options + +
+ Creates credentials that Cryostat uses to connect to target JVMs over JMX. +
+ + Username for JMX connection.} + > + + + Password for JMX connection.} + > + + + +
+
+
+
+ + + +
+ + + + +
+
+
+ <> +
+ ); +}; + +export interface SampleNodeDonutProps { + target: Target; + testing?: boolean; + validation: { + option: ValidatedOptions; + errorMessage: string; + }; + onClick?: () => void; + className?: string; +} + +export const SampleNodeDonut: React.FC = ({ + target, + className, + testing, + validation, + onClick, +}) => { + const _transformedTarget = React.useMemo( + () => ({ connectUrl: target.connectUrl, alias: target.alias.trim() || target.connectUrl }), + [target] + ); + + const _actionEnabled = React.useMemo(() => isValidTargetConnectURL(target.connectUrl), [target]); + + const statusIcon = React.useMemo(() => { + if (testing) { + return { icon: , message: 'Testing custom target definition.' }; + } + return validation.option === ValidatedOptions.success + ? { + icon: , + message: 'Target definition is valid.', + } + : validation.option === ValidatedOptions.error + ? { + icon: , + message: validation.errorMessage, + } + : { icon: , message: '' }; + }, [validation, testing]); + + return ( + <> + {validation.option === ValidatedOptions.error && ( + + + {'Please check if the Connection URL is valid. Provide any credentials if needed.'} + + + )} + + + +
+
+ {testing ? : Cryostat Logo} +
+
{statusIcon.icon}
+
+
+
+ +
+ + {'CT'} + + {_transformedTarget.alias || ''} +
+
+ + + Click on the sample node above to test custom target definition. + + +
+ + ); +}; + +export default CreateTarget; diff --git a/src/app/Topology/Actions/NodeActions.tsx b/src/app/Topology/Actions/NodeActions.tsx new file mode 100644 index 000000000..3f6aa08b0 --- /dev/null +++ b/src/app/Topology/Actions/NodeActions.tsx @@ -0,0 +1,149 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { NotificationsContext } from '@app/Notifications/Notifications'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { DropdownItem, DropdownItemProps } from '@patternfly/react-core'; +import { ContextMenuItem as PFContextMenuItem, GraphElement } from '@patternfly/react-topology'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; +import { ListElement } from '../Shared/utils'; +import { NodeType, TargetNode } from '../typings'; +import { ActionUtils } from './utils'; + +export type NodeActionFunction = ( + element: GraphElement | ListElement, + actionUtils: ActionUtils, + track: ReturnType +) => void; + +export type MenuItemVariant = 'dropdownItem' | 'contextMenuItem'; + +export interface ContextMenuItemProps + extends Omit & React.ComponentProps, 'onClick'> { + onClick?: NodeActionFunction; + element: GraphElement | ListElement; + variant: MenuItemVariant; +} + +export const ContextMenuItem: React.FC = ({ children, element, onClick, variant, ...props }) => { + const history = useHistory(); + const services = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + const addSubscription = useSubscriptions(); + + const handleOnclick = React.useCallback(() => { + onClick && onClick(element, { history, services, notifications }, addSubscription); + }, [onClick, history, services, notifications, addSubscription, element]); + + let Comp: React.FC | React.ComponentProps; + switch (variant) { + case 'contextMenuItem': + Comp = PFContextMenuItem; + break; + case 'dropdownItem': + Comp = DropdownItem; + break; + default: + throw new Error('unknown variant'); + } + + return ( + + {children} + + ); +}; + +export interface NodeAction { + action?: NodeActionFunction; + title?: React.ReactNode; + isSeparator?: boolean; + includeList?: NodeType[]; // Empty means all + blockList?: NodeType[]; // Empty means none +} + +export const nodeActions: NodeAction[] = [ + { + action: (element, { history, services }, _) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/'); + }, + title: 'View Dashboard', + }, + { + action: (element, { history, services }, _) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/recordings'); + }, + title: 'View Recordings', + }, + { isSeparator: true }, + { + action: (element, { history, services }, _) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/recordings/create'); + }, + title: 'Create Recordings', + }, + { + action: (element, { history, services }, _) => { + const targetNode: TargetNode = element.getData(); + + services.target.setTarget(targetNode.target); + history.push('/rules/create'); + }, + title: 'Create Automated Rules', + }, + { isSeparator: true }, + { + action: (element, { services }, track) => { + const targetNode: TargetNode = element.getData(); + track(services.api.deleteTarget(targetNode.target).subscribe(() => undefined)); + }, + title: 'Delete Target', + includeList: [NodeType.CUSTOM_TARGET], + }, +]; diff --git a/src/app/Topology/Actions/QuickSearchPanel.tsx b/src/app/Topology/Actions/QuickSearchPanel.tsx new file mode 100644 index 000000000..2580fd48e --- /dev/null +++ b/src/app/Topology/Actions/QuickSearchPanel.tsx @@ -0,0 +1,341 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { NotificationsContext } from '@app/Notifications/Notifications'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { useFeatureLevel } from '@app/utils/useFeatureLevel'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateIcon, + Flex, + FlexItem, + Label, + LabelGroup, + Menu, + MenuContent, + MenuItem, + MenuList, + Modal, + ModalProps, + SearchInput, + Sidebar, + SidebarContent, + SidebarPanel, + Stack, + StackItem, + Tab, + Tabs, + TabTitleText, + Title, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import { css } from '@patternfly/react-styles'; +import { useHover } from '@patternfly/react-topology'; +import React from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import QuickSearchIcon from '../Shared/QuickSearchIcon'; +import quickSearches, { QuickSearchId, quickSearchIds } from './quicksearches/all-quick-searches'; +import { QuickSearchItem } from './utils'; + +export const QuickSearchTabContent: React.FC<{ item?: QuickSearchItem }> = ({ item, ...props }) => { + const history = useHistory(); + const services = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + const addSubscription = useSubscriptions(); + + const handleActionClick = React.useCallback(() => { + item?.createAction && item.createAction({ history, services, notifications }, addSubscription); + }, [item, history, services, notifications, addSubscription]); + + return item ? ( + + +

{item.name}

+
+ + {item.descriptionShort} + + + + + {item.descriptionFull} +
+ ) : null; +}; + +export const QuickSearchTabTitle: React.FC<{ item: QuickSearchItem }> = ({ item, ...props }) => { + return ( + + +
+ {item.icon} +
+
+ + + {item.name} + + + + {item.labels + ? item.labels.map(({ content, color, icon }) => ( + + )) + : null} + + + +
+ ); +}; + +export interface QuickSearchPanelProps {} + +export const QuickSearchPanel: React.FC = ({ ...props }) => { + const [activeTab, setActiveTab] = React.useState(quickSearchIds[0] || ''); + const [isExpanded, setIsExpanded] = React.useState(false); + const [searchText, setSearchText] = React.useState(''); + + const activeLevel = useFeatureLevel(); + + const handleTabChange = React.useCallback( + (_, eventKey: string | number) => setActiveTab(`${eventKey}`), + [setActiveTab] + ); + + const handleSearch = React.useCallback( + (input: string) => { + setSearchText(input); + }, + [setSearchText] + ); + + const filteredItems = React.useMemo(() => { + if (searchText === '') { + return quickSearches; + } + + const regex = new RegExp(searchText, 'i'); + return quickSearches.filter(({ name, descriptionFull = '', descriptionShort = '', labels = [] }) => { + let matchResult = regex.test(name) || regex.test(descriptionFull) || regex.test(descriptionShort); + + matchResult = matchResult || labels.reduce((acc, curr) => acc || regex.test(curr.content), false); + + return matchResult; + }); + }, [searchText]); + + const matchedItem = React.useMemo(() => { + return filteredItems.find((qs) => qs.id === activeTab); + }, [filteredItems, activeTab]); + + React.useEffect(() => { + if (!matchedItem && filteredItems.length) { + setActiveTab(filteredItems[0].id); + } + }, [filteredItems, matchedItem]); + + return ( + + + handleSearch('')} + /> + + {filteredItems.length ? ( + + + + + {filteredItems + .filter((qs) => activeLevel <= qs.featureLevel) + .map((qs, index) => ( + } + /> + ))} + + + + + + + + ) : ( + + + + + No Results + + + + )} + + ); +}; + +export interface QuickSearchModalProps extends Partial {} + +export const QuickSearchModal: React.FC = ({ + isOpen, + onClose, + variant = 'medium', + ..._props +}) => { + const activeLevel = useFeatureLevel(); + + const guide = React.useMemo(() => { + if (activeLevel === FeatureLevel.PRODUCTION) { + return null; + } + return ( + + For quickstarts on how to create these entities, visit Quick Starts. + + ); + }, [activeLevel]); + return ( + Select an entity to add to view. {guide}} + > + + + ); +}; + +export interface QuickSearchContextMenuProps { + id: string; +} + +// A fly-out menu when right-click on visualization area +export const QuickSearchContextMenu: React.FC = ({ id, ...props }) => { + const [hover, hoverRef] = useHover(0, 100); // delay 100s to allow mouse moving to flyout menu + + return ( +
+ + + + } + > + Add to View + + + + +
+ ); +}; + +export interface QuickSearchFlyoutMenuProps { + isShow?: boolean; + quicksearches: QuickSearchItem[]; +} + +export const QuickSearchFlyoutMenu: React.FC = ({ isShow, quicksearches, ...props }) => { + const history = useHistory(); + const services = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + const addSubscription = useSubscriptions(); + + const [hover, hoverRef] = useHover(0, 0); + + const items = React.useMemo(() => { + return quicksearches.map(({ id, icon, name, createAction = () => undefined }) => ( + + {icon} + + } + onClick={() => createAction({ history, services, notifications }, addSubscription)} + > + {name} + + )); + }, [quicksearches, history, services, notifications, addSubscription]); + + return isShow || hover ? ( + + + {items} + + + ) : null; +}; diff --git a/src/app/Topology/Actions/WarningResolver.tsx b/src/app/Topology/Actions/WarningResolver.tsx new file mode 100644 index 000000000..9a46b1df3 --- /dev/null +++ b/src/app/Topology/Actions/WarningResolver.tsx @@ -0,0 +1,113 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { NotificationsContext } from '@app/Notifications/Notifications'; +import { CreateJmxCredentialModal } from '@app/SecurityPanel/Credentials/CreateJmxCredentialModal'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { Button, ButtonProps } from '@patternfly/react-core'; +import * as React from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import { TargetNode } from '../typings'; +import { ActionUtils } from './utils'; + +export interface WarningResolverAsLinkProps extends React.ComponentProps {} + +export const WarningResolverAsLink: React.FC = ({ to, children, ...props }) => { + return ( + + {children} + + ); +}; + +export interface WarningResolverAsActionButtonProps extends Omit { + targetNode: TargetNode; + onClick?: (targetNode: TargetNode, actionUtils: ActionUtils, track: ReturnType) => void; +} + +export const WarningResolverAsActionButton: React.FC = ({ + targetNode, + onClick, + children, + ...props +}) => { + const history = useHistory(); + const services = React.useContext(ServiceContext); + const notifications = React.useContext(NotificationsContext); + const addSubscription = useSubscriptions(); + + const handleClick = React.useCallback(() => { + onClick && onClick(targetNode, { history, services, notifications }, addSubscription); + }, [onClick, targetNode, history, services, notifications, addSubscription]); + + return ( + + ); +}; + +export type ModalComponent = typeof CreateJmxCredentialModal; + +export interface WarningResolverAsCredModalProps { + children?: React.ReactNode; +} + +export const WarningResolverAsCredModal: React.FC = ({ children, ...props }) => { + const [showAuthModal, setShowAuthModal] = React.useState(false); + + const handleAuthModalClose = React.useCallback(() => { + setShowAuthModal(false); + }, [setShowAuthModal]); + + const handleAuthModalOpen = React.useCallback(() => { + setShowAuthModal(true); + }, [setShowAuthModal]); + + return ( + <> + +
{children}
+ + ); +}; diff --git a/src/app/Topology/Actions/quicksearches/all-quick-searches.ts b/src/app/Topology/Actions/quicksearches/all-quick-searches.ts new file mode 100644 index 000000000..95d6b04a4 --- /dev/null +++ b/src/app/Topology/Actions/quicksearches/all-quick-searches.ts @@ -0,0 +1,47 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import ctSearchItem from './custom-target'; +import devSampleItem from './dev-sample'; + +export const quickSearches = [ctSearchItem, devSampleItem]; + +export const quickSearchIds = [ctSearchItem.id, devSampleItem.id] as const; + +export type QuickSearchId = (typeof quickSearchIds)[number]; + +export default quickSearches; diff --git a/src/app/Topology/Actions/quicksearches/custom-target.tsx b/src/app/Topology/Actions/quicksearches/custom-target.tsx new file mode 100644 index 000000000..64690180e --- /dev/null +++ b/src/app/Topology/Actions/quicksearches/custom-target.tsx @@ -0,0 +1,61 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import openjdkSvg from '@app/assets/openjdk.svg'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import * as React from 'react'; +import { QuickSearchItem } from '../utils'; + +const _CustomTargetSearchItem: QuickSearchItem = { + id: 'custom-target', + name: 'Custom Target', + icon: , + labels: [ + { + content: 'Discovery', + color: 'green', + }, + ], + descriptionShort: 'Define a custom target definition.', + descriptionFull: 'Provide a JMX Service URL along with necessary credentials to point to a target JVM.', + featureLevel: FeatureLevel.PRODUCTION, + createAction: ({ history }) => { + history.push('/topology/create-custom-target'); + }, +}; + +export default _CustomTargetSearchItem; diff --git a/src/app/Topology/Actions/quicksearches/dev-sample.tsx b/src/app/Topology/Actions/quicksearches/dev-sample.tsx new file mode 100644 index 000000000..85d1af5a1 --- /dev/null +++ b/src/app/Topology/Actions/quicksearches/dev-sample.tsx @@ -0,0 +1,59 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { ContainerNodeIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { QuickSearchItem } from '../utils'; + +const _DevSampleSearchItem: QuickSearchItem = { + id: 'dev-sample', + name: 'Sample', + icon: , + labels: [ + { + content: 'Sample', + color: 'blue', + }, + ], + descriptionShort: 'This is a sample template to create a search item.', + descriptionFull: 'Put the full description of the item here.', + featureLevel: FeatureLevel.DEVELOPMENT, + createAction: () => undefined, +}; + +export default _DevSampleSearchItem; diff --git a/src/app/Topology/Actions/utils.ts b/src/app/Topology/Actions/utils.ts new file mode 100644 index 000000000..a6c77e2d0 --- /dev/null +++ b/src/app/Topology/Actions/utils.ts @@ -0,0 +1,67 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Notifications } from '@app/Notifications/Notifications'; +import { Services } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { LabelProps } from '@patternfly/react-core'; +import * as React from 'react'; +import { useHistory } from 'react-router-dom'; +import { Subscription } from 'rxjs'; + +export interface ActionUtils { + history: ReturnType; + services: Services; + notifications: Notifications; +} + +export interface QuickSearchItem { + id: string; + name: string; + icon?: React.ReactNode; + labels?: { + content: string; + color?: LabelProps['color']; + icon?: React.ReactNode; + }[]; + descriptionShort?: string; + descriptionFull?: string; + featureLevel: FeatureLevel; + disabled?: boolean; + actionText?: React.ReactNode; + createAction?: (utils: ActionUtils, track: (sub: Subscription) => void) => void; +} diff --git a/src/app/Topology/GraphView/CustomGroup.tsx b/src/app/Topology/GraphView/CustomGroup.tsx new file mode 100644 index 000000000..cb645694d --- /dev/null +++ b/src/app/Topology/GraphView/CustomGroup.tsx @@ -0,0 +1,124 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import openjdkSvg from '@app/assets/openjdk.svg'; +import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { DefaultGroup, Node, observer, WithDragNodeProps, WithSelectionProps } from '@patternfly/react-topology'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { EnvironmentNode, NodeType } from '../typings'; +import { NODE_ICON_PADDING } from './CustomNode'; + +const DEFAULT_NODE_COLLAPSED_DIAMETER = 100; + +export const renderIcon = (width: number, height: number): React.ReactNode => { + const contentSize = Math.min(width, height) - NODE_ICON_PADDING * 2; + const mainContentSize = contentSize * 0.8; + const [cx, cy] = [width / 2, height / 2]; + + return ( + <> + + + + ); +}; + +export interface CustomGroupProps extends Partial { + element: Node; + collapsedWidth?: number; + collapsedHeight?: number; +} + +const CustomGroup: React.FC = ({ + element, + onSelect, + selected, + dragNodeRef, + collapsedHeight = DEFAULT_NODE_COLLAPSED_DIAMETER, + collapsedWidth = DEFAULT_NODE_COLLAPSED_DIAMETER, + ...props +}) => { + const positionRef = React.useRef(element.getPosition()); + const data: EnvironmentNode = element.getData(); + + const displayOptions = useSelector((state: RootState) => state.topologyConfigs.displayOptions); + const { badge: showBadge } = displayOptions.show; + + const collapsedContent = React.useMemo( + () => {renderIcon(collapsedWidth, collapsedHeight)}, + [collapsedWidth, collapsedHeight] + ); + + React.useEffect(() => { + positionRef.current = element.getPosition(); + }); + + return ( + + {React.createElement( + DefaultGroup, + { + ...props, + element: element, + selected: selected, + onSelect: onSelect, + className: data.nodeType === NodeType.REALM ? 'topology__realm-group' : undefined, + dragNodeRef: dragNodeRef, + collapsible: true, + // Workaround to keep group positions between collapses + onCollapseChange: (group, _) => { + group.setPosition(positionRef.current); + }, + collapsedHeight: collapsedHeight, + collapsedWidth: collapsedWidth, + badge: showBadge ? data.nodeType : undefined, + showLabel: true, + } as React.ComponentProps, + element.isCollapsed() ? collapsedContent : null + )} + + ); +}; + +export default observer(CustomGroup); diff --git a/src/app/Topology/GraphView/CustomNode.tsx b/src/app/Topology/GraphView/CustomNode.tsx new file mode 100644 index 000000000..b651b94b0 --- /dev/null +++ b/src/app/Topology/GraphView/CustomNode.tsx @@ -0,0 +1,153 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import cryostatSvg from '@app/assets/cryostat_icon_rgb_default.svg'; +import openjdkSvg from '@app/assets/openjdk.svg'; +import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { ContainerNodeIcon } from '@patternfly/react-icons'; +import { css } from '@patternfly/react-styles'; +import { + DefaultNode, + DEFAULT_LAYER, + EllipseAnchor, + Layer, + Node, + observer, + ScaleDetailsLevel, + TOP_LAYER, + useAnchor, + useHover, + WithContextMenuProps, + WithDragNodeProps, + WithSelectionProps, +} from '@patternfly/react-topology'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { getStatusTargetNode, isTargetMatched, nodeTypeToAbbr, useSearchExpression } from '../Shared/utils'; +import { TargetNode } from '../typings'; +import { RESOURCE_NAME_TRUNCATE_LENGTH } from './UtilsFactory'; + +export const NODE_ICON_PADDING = 5; + +export const renderIcon = (data: TargetNode, element: Node, useAlt: boolean): React.ReactNode => { + const { width, height } = element.getDimensions(); + + const contentSize = Math.min(width, height) - NODE_ICON_PADDING * 2; + const mainContentSize = contentSize * (useAlt ? 0.5 : 0.8); + const [cx, cy] = [width / 2, height / 2]; + const [trueCx, trueCy] = [cx - mainContentSize / 2, cy - mainContentSize / 2]; + + return ( + <> + + {useAlt ? ( + + + + ) : ( + + )} + + ); +}; + +export interface CustomNodeProps extends Partial { + element: Node; +} + +export const NODE_BADGE_COLOR = 'var(--pf-global--palette--blue-500)'; + +const CustomNode: React.FC = ({ + element, + onSelect, + selected, + dragNodeRef, + contextMenuOpen, + onContextMenu, + ...props +}) => { + useAnchor(EllipseAnchor); // For edges + const [hover, hoverRef] = useHover(200, 200); + const [expression] = useSearchExpression(); + + const displayOptions = useSelector((state: RootState) => state.topologyConfigs.displayOptions); + const { badge: showBadge, connectionUrl: showConnectUrl, icon: showIcon, status: showStatus } = displayOptions.show; + + const detailsLevel = element.getController().getGraph().getDetailsLevel(); + const labelIcon = React.useMemo(() => , []); + + const data: TargetNode = element.getData(); + const [nodeStatus, extra] = getStatusTargetNode(data); + + const classNames = React.useMemo(() => { + const additional = expression === '' || isTargetMatched(data, expression) ? '' : 'search-inactive'; + return css('topology__target-node', additional); + }, [data, expression]); + + return ( + + }> + + {renderIcon(data, element, !showIcon)} + + + + ); +}; + +export default observer(CustomNode); diff --git a/src/app/Topology/GraphView/TopologyControlBar.tsx b/src/app/Topology/GraphView/TopologyControlBar.tsx new file mode 100644 index 000000000..1e5783af0 --- /dev/null +++ b/src/app/Topology/GraphView/TopologyControlBar.tsx @@ -0,0 +1,101 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { css } from '@patternfly/react-styles'; +import { + action, + createTopologyControlButtons, + defaultControlButtonsOptions, + TopologyControlBar as PFTopologyControlBar, + Visualization, +} from '@patternfly/react-topology'; +import * as React from 'react'; +import { CollapseIcon } from '../Shared/CollapseIcon'; + +export interface TopologyControlBarProps { + visualization: Visualization; + className?: string; +} + +export const TopologyControlBar: React.FC = ({ visualization, className, ...props }) => { + return ( +
+ { + visualization.getGraph().scaleBy(4 / 3); + }), + zoomInTip: 'Zoom in', + zoomInAriaLabel: 'Zoom in', + zoomOutCallback: action(() => { + visualization.getGraph().scaleBy(3 / 4); + }), + zoomOutTip: 'Zoom out', + zoomOutAriaLabel: 'Zoom out', + fitToScreenCallback: action(() => { + visualization.getGraph().fit(120); + }), + fitToScreenTip: 'Fit to screen', + fitToScreenAriaLabel: 'Fit to screen', + resetViewCallback: action(() => { + visualization.getGraph().reset(); + visualization.getGraph().layout(); + }), + resetViewTip: 'Reset view', + resetViewAriaLabel: 'Reset view', + legend: false, + }), + { + id: 'collapse-all-group', + icon: , + tooltip: 'Collapse all groups', + callback: action(() => { + // Close top-level groups + visualization + .getGraph() + .getNodes() + .forEach((n) => n.setCollapsed(true)); + }), + }, + ]} + /> +
+ ); +}; diff --git a/src/app/Topology/GraphView/TopologyGraphView.tsx b/src/app/Topology/GraphView/TopologyGraphView.tsx new file mode 100644 index 000000000..bfa9f04bc --- /dev/null +++ b/src/app/Topology/GraphView/TopologyGraphView.tsx @@ -0,0 +1,299 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; +import { Divider, Stack, StackItem } from '@patternfly/react-core'; +import { + BOTTOM_LAYER, + DEFAULT_LAYER, + GraphElement, + GRAPH_POSITION_CHANGE_EVENT, + GROUPS_LAYER, + Model, + NODE_POSITIONED_EVENT, + ScaleExtent, + SelectionEventListener, + SELECTION_EVENT, + TopologyView, + TOP_LAYER, + Visualization, + VisualizationProvider, + VisualizationSurface, +} from '@patternfly/react-topology'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { QuickSearchContextMenu } from '../Actions/QuickSearchPanel'; +import EntityDetails from '../Shared/Entity/EntityDetails'; +import { TopologyEmptyState } from '../Shared/TopologyEmptyState'; +import { TopologyExceedLimitState } from '../Shared/TopologyExceedLimitState'; +import { DiscoveryTreeContext, TransformConfig } from '../Shared/utils'; +import { TopologySideBar } from '../SideBar/TopologySideBar'; +import { TopologyToolbar, TopologyToolbarVariant } from '../Toolbar/TopologyToolbar'; +import { TopologyControlBar } from './TopologyControlBar'; +import { componentFactory, getNodeById, layoutFactory, transformData } from './UtilsFactory'; + +export const MAX_NODE_LIMIT = 100; + +export const DEFAULT_SIZEBAR_SIZE = 500; +export const MIN_SIZEBAR_SIZE = 400; + +export type SavedGraphPosition = { + id?: string; + type?: string; + x?: number; + y?: number; + scale?: number; + scaleExtent?: ScaleExtent; +}; + +export type SavedNodePosition = { + id?: string; + x?: number; + y?: number; + collapsed?: boolean; +}; + +export interface TopologyGraphViewProps { + transformConfig?: TransformConfig; +} + +export const TopologyGraphView: React.FC = ({ transformConfig, ...props }) => { + const [selectedIds, setSelectedIds] = React.useState([]); // selectedIds is exactly matched by VisualizationSurface + const [selectedEntity, setSelectedEntity] = React.useState(); + const [showGraphAnyway, setShowGraphAnyway] = React.useState(false); + + const filters = useSelector((state: RootState) => state.topologyFilters); + + const handleDrawerClose = React.useCallback(() => setSelectedIds([]), [setSelectedIds]); + + const discoveryTree = React.useContext(DiscoveryTreeContext); + const _transformData = React.useMemo( + () => transformData(discoveryTree, transformConfig, filters), + [discoveryTree, transformConfig, filters] + ); + + const exceedLimit = React.useMemo(() => _transformData.nodes.length > MAX_NODE_LIMIT, [_transformData]); + + const isEmptyGraph = React.useMemo( + () => !_transformData.nodes.some((node) => node.type === 'node'), + [_transformData] + ); + + const _createVisualization = React.useCallback(() => { + const _newVisualization = new Visualization(); + + // Register factory for a layout variant + _newVisualization.registerLayoutFactory(layoutFactory); + + // Register factory for each node variant + _newVisualization.registerComponentFactory(componentFactory); + + // Selection event + _newVisualization.addEventListener(SELECTION_EVENT, (ids) => { + setSelectedIds(ids); + setSelectedEntity(ids[0] ? _newVisualization.getElementById(ids[0]) : undefined); + }); + + _newVisualization.addEventListener( + GRAPH_POSITION_CHANGE_EVENT, + _.debounce(() => { + const { graph } = _newVisualization.toModel(); + if (graph) { + const saved: SavedGraphPosition = { + id: graph.id, + type: graph.type, + x: graph.x, + y: graph.y, + scale: graph.scale, + scaleExtent: graph.scaleExtent, + }; + saveToLocalStorage('TOPOLOGY_GRAPH_POSITONS', saved); + } + }, 200) + ); + + _newVisualization.addEventListener( + NODE_POSITIONED_EVENT, + _.debounce(() => { + const { nodes } = _newVisualization.toModel(); + if (nodes) { + const savedPos: SavedNodePosition[] = nodes.map((n) => ({ + id: n.id, + x: n.x, + y: n.y, + collapsed: n.collapsed, + })); + saveToLocalStorage('TOPOLOGY_NODE_POSITIONS', savedPos); + } + }, 200) + ); + return _newVisualization; + }, [setSelectedIds, setSelectedEntity]); + + const visualizationRef = React.useRef(_createVisualization()); + const visualization = visualizationRef.current; + + React.useEffect(() => { + const graphData: SavedGraphPosition = getFromLocalStorage('TOPOLOGY_GRAPH_POSITONS', {}); + const nodePositions: SavedNodePosition[] = getFromLocalStorage('TOPOLOGY_NODE_POSITIONS', []); + + const model: Model = { + nodes: _transformData.nodes.map((n) => { + const savedData = nodePositions.find((ps) => ps.id === n.id); + if (savedData) { + n = { + ...n, + x: savedData.x, + y: savedData.y, + collapsed: savedData.collapsed, + }; + } + return n; + }), + edges: _transformData.edges, + graph: { + id: 'cryostat-target-topology-graph', + type: 'graph', + layout: 'Cola', + layers: [BOTTOM_LAYER, GROUPS_LAYER, DEFAULT_LAYER, TOP_LAYER], + data: { ...discoveryTree }, + x: graphData.x, + y: graphData.y, + scale: graphData.scale, + scaleExtent: graphData.scaleExtent, + }, + }; + + // Initialize the controller with model to create nodes + visualization.fromModel(model, false); + }, [_transformData, visualization, discoveryTree]); + + // Note: Do not reorder. Must be called after registering model + React.useEffect(() => { + // Clear selection when discovery tree is updated and entity (target) is lost + setSelectedIds((old) => { + if (!getNodeById(_transformData.nodes, old[0])) { + setSelectedEntity(undefined); + return []; + } + setSelectedEntity(old[0] ? visualization.getElementById(old[0]) : undefined); + return old; + }); + }, [setSelectedIds, setSelectedEntity, _transformData, visualization]); + + React.useEffect(() => { + const hideMenu = (_: MouseEvent) => { + const contextMenu = document.getElementById('topology-context-menu'); + if (contextMenu) { + contextMenu.style.display = 'none'; + } + }; + const showMenu = (e: MouseEvent) => { + e.preventDefault(); + + const contextMenu = document.getElementById('topology-context-menu'); + if (contextMenu) { + contextMenu.style.display = 'block'; + contextMenu.style.top = `${e.clientY}px`; + contextMenu.style.left = `${e.clientX}px`; + } + }; + + // Visualize surface needs time to intialize. + // Workaround: find drawer body which is already ready and tightly wraps the surface. + const container: HTMLElement | null = document.querySelector( + '#topology__visualization-container .pf-c-drawer__content' + ); + if (container) { + container.addEventListener('contextmenu', showMenu); + } + document.addEventListener('click', hideMenu); + return () => document.removeEventListener('click', hideMenu); + }, []); + + const sidebar = React.useMemo( + () => ( + + + + ), + [handleDrawerClose, selectedEntity] + ); + + return ( + <> + + + + + + + + + {isEmptyGraph ? ( + + ) : exceedLimit && !showGraphAnyway ? ( + setShowGraphAnyway(true)} /> + ) : ( + } + sideBar={sidebar} + sideBarOpen={selectedIds.length > 0} + sideBarResizable={true} + minSideBarSize={`${MIN_SIZEBAR_SIZE}px`} + defaultSideBarSize={`${DEFAULT_SIZEBAR_SIZE}px`} + > + + + + + )} + + + + + ); +}; diff --git a/src/app/Topology/GraphView/UtilsFactory.tsx b/src/app/Topology/GraphView/UtilsFactory.tsx new file mode 100644 index 000000000..f9a9dca6a --- /dev/null +++ b/src/app/Topology/GraphView/UtilsFactory.tsx @@ -0,0 +1,334 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; +import { + ColaLayout, + ComponentFactory, + DefaultEdge, + EdgeModel, + Graph, + GraphComponent, + GraphElement, + isNode, + Layout, + LayoutFactory, + ModelKind, + nodeDragSourceSpec, + Node, + NodeModel, + NodeShape, + Rect, + Visualization, + withContextMenu, + withDragNode, + withPanZoom, + withSelection, +} from '@patternfly/react-topology'; +import { + actionFactory, + COLLAPSE_EXEMPTS, + getAllLeaves, + getUniqueGroupId, + getUniqueTargetId, + isGraphElement, + isGroupNodeFiltered, + isTargetNodeFiltered, + ListElement, + TransformConfig, +} from '../Shared/utils'; +import { EnvironmentNode, isTargetNode, NodeType, TargetNode } from '../typings'; +import CustomGroup from './CustomGroup'; +import CustomNode from './CustomNode'; + +// Unit: px +export const DEFAULT_NODE_DIAMETER = 80; + +export const DEFAULT_GROUP_PADDING = 30; +export const DEFAULT_NODE_PADDING = 60; + +export const DEFAULT_NODE_PADDINGS = [0, DEFAULT_NODE_PADDING]; +export const DEFAULT_GROUP_PADDINGS = [ + DEFAULT_GROUP_PADDING, + DEFAULT_GROUP_PADDING, + DEFAULT_GROUP_PADDING + 15, + DEFAULT_GROUP_PADDING, +]; + +export const RESOURCE_NAME_TRUNCATE_LENGTH = 20; + +const _buildFullNodeModel = ( + node: EnvironmentNode | TargetNode, + expandMode = true, + filters?: TopologyFilters +): NodeModel[] => { + if (isTargetNode(node)) { + if (!isTargetNodeFiltered(node, filters?.targetFilters.filters)) { + return []; + } + return [ + { + id: getUniqueTargetId(node), + type: 'node', + label: node.target.alias || node.name, + shape: NodeShape.ellipse, + width: DEFAULT_NODE_DIAMETER, + height: DEFAULT_NODE_DIAMETER, + style: { + padding: DEFAULT_NODE_PADDINGS, + }, + data: { + ...node, + }, + }, + ]; + } + + const INIT: NodeModel[] = []; + const directChildNodes: NodeModel[] = []; + const allChildNodes = node.children.reduce((prev, curr) => { + const next = _buildFullNodeModel(curr, expandMode, filters); + if (next.length) { + // First nodes are always direct children + // If direct child is collapsed (i.e. single grandchild node), + // that single grandchild is used as if it is direct + directChildNodes.push(next[0]); + } + return prev.concat(next); + }, INIT); + + // Do show empty or filtered-out groups + // Note: Do not filter universe node + if ( + !allChildNodes.length || + (node.nodeType !== NodeType.UNIVERSE && !isGroupNodeFiltered(node, filters?.groupFilters.filters)) + ) { + return []; + } + + // Collapse single-child internal nodes (realms and namespaces are exempt) + if (!COLLAPSE_EXEMPTS.includes(node.nodeType) && !expandMode && directChildNodes.length === 1) { + return [...allChildNodes]; + } + + const groupNode: NodeModel = { + id: getUniqueGroupId(node), + type: 'group', + group: true, + label: node.name, // Name of the node + children: directChildNodes.map((childNode) => childNode.id), + style: { + padding: DEFAULT_GROUP_PADDINGS, + }, + data: { + ...node, + }, + }; + + return [groupNode, ...allChildNodes]; +}; + +const _transformDataGroupedByTopLevel = (root: EnvironmentNode, filters?: TopologyFilters) => { + let nodes: NodeModel[] = []; + const edges: EdgeModel[] = []; + + // First layer of internal nodes + const groupNodes = root.children + .filter((realm: EnvironmentNode) => isGroupNodeFiltered(realm, filters?.groupFilters.filters)) // Do not show filtered-out groups + .map((group) => { + const realmNode: NodeModel = { + id: getUniqueGroupId(group as EnvironmentNode), + type: 'group', + group: true, + label: group.name, // Name of the node + children: [], + style: { + padding: DEFAULT_GROUP_PADDINGS, + }, + data: { + ...group, + }, + }; + return realmNode; + }); + + // Extract all leaves + let leafNodes: NodeModel[] = []; + groupNodes.forEach((groupNode) => { + const _tNodes: NodeModel[] = getAllLeaves(groupNode.data) + .filter((tn) => isTargetNodeFiltered(tn, filters?.targetFilters.filters)) + .map((leaf: TargetNode) => { + return { + id: getUniqueTargetId(leaf), + type: 'node', + label: leaf.target.alias || leaf.name, + shape: NodeShape.ellipse, + width: DEFAULT_NODE_DIAMETER, + height: DEFAULT_NODE_DIAMETER, + style: { + padding: DEFAULT_NODE_PADDINGS, + }, + data: { + ...leaf, + }, + }; + }); + + groupNode.children = _tNodes.map((n) => n.id); // Add nodes id to group + leafNodes = leafNodes.concat(_tNodes); + }); + + nodes = nodes.concat(groupNodes.filter((gn) => gn.children && gn.children.length)); // Do not empty groups + nodes = nodes.concat(leafNodes); + + return { + nodes: nodes, + edges: edges, + }; +}; + +export const _transformDataFull = (root: EnvironmentNode, expandMode = true, filters?: TopologyFilters) => { + const edges: EdgeModel[] = []; + const nodes = _buildFullNodeModel(root, expandMode, filters).slice(1); // Remove universe node + return { + nodes: nodes, + edges: edges, + }; +}; + +export const transformData = ( + universe: EnvironmentNode, + { showOnlyTopGroup = false, expandMode = true }: TransformConfig = {}, + filters?: TopologyFilters +): { + nodes: NodeModel[]; + edges: EdgeModel[]; +} => { + return showOnlyTopGroup + ? _transformDataGroupedByTopLevel(universe, filters) + : _transformDataFull(universe, expandMode, filters); +}; + +export const getNodeById = (nodes: NodeModel[], id?: string) => { + if (id === undefined) return undefined; + return nodes.find((node) => node.id === id); +}; + +// This method sets the layout of your topology view (e.g. Force, Dagre, Cola, etc.). +// OCP is supporting only Cola +export const layoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => { + switch (type) { + case 'Cola': + return new ColaLayout(graph, { layoutOnDrag: false }); + default: + console.warn(`${type} layout is not supported`); + return undefined; + } +}; + +// This method lets you customize the components in your topology view (e.g. nodes, groups, and edges) +export const componentFactory: ComponentFactory = (kind: ModelKind, type: string) => { + switch (type) { + case 'group': + return withDragNode(nodeDragSourceSpec('group', false, false))( + withSelection({ multiSelect: false, controlled: true })(CustomGroup) + ); + default: + switch (kind) { + case ModelKind.graph: + return withPanZoom()(GraphComponent); + case ModelKind.node: + return withContextMenu(actionFactory)( + withDragNode(nodeDragSourceSpec('node', false, false))( + withSelection({ multiSelect: false, controlled: true })(CustomNode) + ) + ); + case ModelKind.edge: + return DefaultEdge; + default: + return undefined; + } + } +}; + +// Support only node details +export const isRenderable = (entity: GraphElement | ListElement) => { + if (isGraphElement(entity)) { + return isNode(entity); + } + return entity.getData() !== undefined; +}; + +export const nodeDistanceToBounds = (node: Node, bounds: Rect): number => { + const nodeBounds = node.getBounds(); + const nodeX = nodeBounds.x + nodeBounds.width / 2; + const nodeY = nodeBounds.y + nodeBounds.height / 2; + + const dx = Math.max(bounds.x - nodeX, 0, nodeX - (bounds.x + bounds.width)); + const dy = Math.max(bounds.y - nodeY, 0, nodeY - (bounds.y + bounds.height)); + return Math.sqrt(dx * dx + dy * dy); +}; + +// Ensure some nodes are within views in case stored locations are off screen +// FIXME: Seems to always pan into view +export const ensureGraphVisible = (visualization: Visualization) => { + if (visualization.hasGraph()) { + const graph = visualization.getGraph(); + const nodes = visualization.getElements().filter(isNode); + + if (nodes.length) { + const anyVisible = nodes.find((n) => graph.isNodeInView(n, { padding: 0 })); + if (!anyVisible) { + const graphBounds = graph.getBounds(); + + const [toPanNode, _] = nodes.reduce( + ([closestNode, closestDistance], nextNode) => { + const distance = nodeDistanceToBounds(nextNode, graphBounds); + if (distance < closestDistance) { + return [nextNode, distance]; + } + return [closestNode, closestDistance]; + }, + [nodes[0], nodeDistanceToBounds(nodes[0], graphBounds)] + ); + + graph.panIntoView(toPanNode); + } + } + } +}; diff --git a/src/app/Topology/ListView/TopologyListView.tsx b/src/app/Topology/ListView/TopologyListView.tsx new file mode 100644 index 000000000..924f5655f --- /dev/null +++ b/src/app/Topology/ListView/TopologyListView.tsx @@ -0,0 +1,88 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { Divider, Stack, StackItem, TreeView, TreeViewDataItem } from '@patternfly/react-core'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { TopologyEmptyState } from '../Shared/TopologyEmptyState'; +import { DiscoveryTreeContext, TransformConfig, useSearchExpression } from '../Shared/utils'; +import { TopologyToolbar, TopologyToolbarVariant } from '../Toolbar/TopologyToolbar'; +import { transformData } from './UtilsFactory'; + +export interface TopologyListViewProps { + transformConfig?: TransformConfig; +} + +export const TopologyListView: React.FC = ({ transformConfig, ...props }) => { + const discoveryTree = React.useContext(DiscoveryTreeContext); + + const filters = useSelector((state: RootState) => state.topologyFilters); + + const [expression] = useSearchExpression(100); + + const _treeViewData: TreeViewDataItem[] = React.useMemo( + () => transformData(discoveryTree, transformConfig, filters, expression), + [discoveryTree, transformConfig, filters, expression] + ); + + const isEmptyList = React.useMemo(() => !_treeViewData.length, [_treeViewData]); + + return ( + + + + + + + + + {isEmptyList ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/app/Topology/ListView/UtilsFactory.tsx b/src/app/Topology/ListView/UtilsFactory.tsx new file mode 100644 index 000000000..d1f8822f0 --- /dev/null +++ b/src/app/Topology/ListView/UtilsFactory.tsx @@ -0,0 +1,226 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; +import { Badge, Label, LabelGroup, TreeViewDataItem } from '@patternfly/react-core'; +import * as React from 'react'; +import EntityDetails from '../Shared/Entity/EntityDetails'; +import { + COLLAPSE_EXEMPTS, + getAllLeaves, + getUniqueGroupId, + getUniqueTargetId, + isGroupNodeFiltered, + isTargetMatched, + isTargetNodeFiltered, + TransformConfig, +} from '../Shared/utils'; +import { EnvironmentNode, isTargetNode, NodeType, TargetNode } from '../typings'; + +const _transformDataGroupedByTopLevel = ( + universe: EnvironmentNode, + filters?: TopologyFilters, + searchExpression = '' +): TreeViewDataItem[] => { + return universe.children + .filter((realm: EnvironmentNode) => isGroupNodeFiltered(realm, filters?.groupFilters.filters)) + .map((realm: EnvironmentNode) => { + const base = { + id: getUniqueGroupId(realm), + name: ( + + {Object.keys(realm.labels) + .map((k) => `${k}=${realm.labels[k]}`) + .map((l) => ( + + ))} + + ), + children: getAllLeaves(realm) + .filter( + (child: TargetNode) => + isTargetNodeFiltered(child, filters?.targetFilters.filters) && + (searchExpression === '' || isTargetMatched(child, searchExpression)) + ) + .map((child: TargetNode) => ({ + id: `${child.name}-wrapper`, + name: null, + children: [ + { + id: child.name, + name: ( + child }} + columnModifier={{ default: '3Col' }} + /> + ), + }, + ], + title: ( + <> + {child.target.alias} + {child.nodeType} + + ), + })), + }; + + return { + ...base, + title: ( + <> + + {realm.nodeType}: {realm.name} + + {base.children.length} + + ), + }; + }) + .filter((_transformRealm) => _transformRealm.children && _transformRealm.children.length); +}; + +const _buildFullData = ( + node: EnvironmentNode | TargetNode, + expandMode = true, + filters?: TopologyFilters, + searchExpression = '' +): TreeViewDataItem[] => { + if (isTargetNode(node)) { + if ( + !isTargetNodeFiltered(node, filters?.targetFilters.filters) || + (searchExpression !== '' && !isTargetMatched(node, searchExpression)) + ) { + return []; + } + + return [ + { + id: `${node.name}-wrapper`, + name: null, + // Break target details to another level to allow expand/collapse + children: [ + { + id: getUniqueTargetId(node), + name: ( + node }} + columnModifier={{ default: '3Col' }} + /> + ), + }, + ], + title: ( + <> + {node.target.alias} + {node.nodeType} + + ), + }, + ]; + } + + const INIT: TreeViewDataItem[] = []; + const children = node.children.reduce( + (prev, curr) => prev.concat(_buildFullData(curr, expandMode, filters, searchExpression)), + INIT + ); + + // Do show empty or filtered-out groups + if ( + !children.length || + (node.nodeType !== NodeType.UNIVERSE && !isGroupNodeFiltered(node, filters?.groupFilters.filters)) + ) { + return []; + } + + // Collapse single-child internal nodes (realms and namespaces are exempt) + if (!COLLAPSE_EXEMPTS.includes(node.nodeType) && !expandMode && children.length === 1) { + return [...children]; + } + + return [ + { + id: getUniqueGroupId(node), + title: ( + <> + + {node.nodeType}: {node.name} + + {children.length} + + ), + name: ( + + {Object.keys(node.labels) + .map((k) => `${k}=${node.labels[k]}`) + .map((l) => ( + + ))} + + ), + children: children, + }, + ]; +}; + +const _transformDataFull = ( + root: EnvironmentNode, + expandMode = true, + filters?: TopologyFilters, + searchExpression = '' +): TreeViewDataItem[] => { + const _transformedRoot = _buildFullData(root, expandMode, filters, searchExpression)[0]; + return _transformedRoot && _transformedRoot.children ? _transformedRoot.children : []; +}; + +export const transformData = ( + universe: EnvironmentNode, + { showOnlyTopGroup = false, expandMode = true }: TransformConfig = {}, + filters?: TopologyFilters, + searchExpression = '' +): TreeViewDataItem[] => { + return showOnlyTopGroup + ? _transformDataGroupedByTopLevel(universe, filters, searchExpression) + : _transformDataFull(universe, expandMode, filters, searchExpression); +}; diff --git a/src/app/Topology/Shared/CollapseIcon.tsx b/src/app/Topology/Shared/CollapseIcon.tsx new file mode 100644 index 000000000..82058e286 --- /dev/null +++ b/src/app/Topology/Shared/CollapseIcon.tsx @@ -0,0 +1,57 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; + +export const CollapseIcon: React.FC> = ({ style }) => { + return ( + + ); +}; diff --git a/src/app/Topology/Shared/EmptyText.tsx b/src/app/Topology/Shared/EmptyText.tsx new file mode 100644 index 000000000..d7bc7a48d --- /dev/null +++ b/src/app/Topology/Shared/EmptyText.tsx @@ -0,0 +1,47 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { css } from '@patternfly/react-styles'; +import * as React from 'react'; + +export const EmptyText: React.FC<{ text: string; className?: string }> = ({ text, className, ...props }) => { + return ( + + {text} + + ); +}; diff --git a/src/app/Topology/Shared/Entity/EntityAnnotations.tsx b/src/app/Topology/Shared/Entity/EntityAnnotations.tsx new file mode 100644 index 000000000..e1f367916 --- /dev/null +++ b/src/app/Topology/Shared/Entity/EntityAnnotations.tsx @@ -0,0 +1,73 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Label, LabelGroup } from '@patternfly/react-core'; +import * as React from 'react'; +import { EmptyText } from '../EmptyText'; + +export const EntityAnnotations: React.FC<{ annotations?: object; maxDisplay?: number }> = ({ + annotations, + maxDisplay, + ...props +}) => { + const _transformedAnnotationGroups = React.useMemo(() => { + return annotations + ? Object.keys(annotations).map((groupK) => ({ + groupLabel: groupK, + annotations: Object.keys(annotations[groupK]).map((k) => `${k}=${annotations[groupK][k]}`), + })) + : []; + }, [annotations]); + + return _transformedAnnotationGroups.length ? ( +
+ {_transformedAnnotationGroups.map((group) => ( +
+ + {group.annotations.map((a) => ( + + ))} + +
+ ))} +
+ ) : ( + + ); +}; diff --git a/src/app/Topology/Shared/Entity/EntityDetails.tsx b/src/app/Topology/Shared/Entity/EntityDetails.tsx new file mode 100644 index 000000000..a444c752f --- /dev/null +++ b/src/app/Topology/Shared/Entity/EntityDetails.tsx @@ -0,0 +1,564 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { splitWordsOnUppercase } from '@app/utils/utils'; +import { + Alert, + AlertActionCloseButton, + Badge, + Bullseye, + Card, + CardBody, + CardHeader, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTermHelpText, + DescriptionListTermHelpTextButton, + Divider, + Dropdown, + DropdownToggle, + Flex, + FlexItem, + Popover, + Stack, + StackItem, + Tab, + Tabs, + TabTitleText, + Tooltip, +} from '@patternfly/react-core'; +import { WarningTriangleIcon } from '@patternfly/react-icons'; +import { css } from '@patternfly/react-styles'; +import { TableComposable, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; +import { GraphElement, NodeStatus } from '@patternfly/react-topology'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { catchError, combineLatest, concatMap, merge, of, Subject, switchMap } from 'rxjs'; +import { isRenderable } from '../../GraphView/UtilsFactory'; +import { EnvironmentNode, isTargetNode, TargetNode } from '../../typings'; +import { EmptyText } from '../EmptyText'; +import { actionFactory, getStatusTargetNode, ListElement, nodeTypeToAbbr, StatusExtra } from '../utils'; +import { EntityAnnotations } from './EntityAnnotations'; +import { EntityLabels } from './EntityLabels'; +import { EntityTitle } from './EntityTitle'; +import { + extraTargetConnectUrlFromEvent, + getLinkPropsForTargetResource, + getResourceAddedEvent as getResourceAddedEvents, + getResourceListPatchFn, + getResourceRemovedEvent as getResourceRemovedEvents, + getTargetOwnedResources, + isOwnedResource, + ResourceTypes, + TargetOwnedResourceType, + TargetOwnedResourceTypeAsArray, + TargetRelatedResourceType, + TargetRelatedResourceTypeAsArray, +} from './utils'; + +export interface EntityDetailsProps { + entity?: GraphElement | ListElement; + columnModifier?: React.ComponentProps['columnModifier']; + className?: string; +} + +type _supportedTab = 'details' | 'resources'; + +export const EntityDetails: React.FC = ({ entity, className, columnModifier, ...props }) => { + const [activeTab, setActiveTab] = React.useState<_supportedTab>('details'); + const [actionOpen, setActionOpen] = React.useState(false); + + const viewContent = React.useMemo(() => { + if (entity && isRenderable(entity)) { + const data: EnvironmentNode | TargetNode = entity.getData(); + const isTarget = isTargetNode(data); + const titleContent = isTarget ? data.target.alias : data.name; + + const _actions = actionFactory(entity, 'dropdownItem'); + + return ( +
+ setActionOpen(false)} + isOpen={actionOpen} + toggle={Actions} + dropdownItems={_actions} + /> + ) : null + } + /> + + setActiveTab(`${tab}` as _supportedTab)} + className={css('entity-overview')} + > + Details}> +
+ {isTarget ? ( + + ) : ( + + )} +
+
+ {'Resources'}}> +
+ {isTarget ? : } +
+
+
+
+ ); + } + return null; + }, [entity, setActiveTab, activeTab, actionOpen, setActionOpen, props, columnModifier]); + return
{viewContent}
; +}; + +export const TargetDetails: React.FC<{ + targetNode: TargetNode; + columnModifier?: React.ComponentProps['columnModifier']; +}> = ({ targetNode, columnModifier, ...props }) => { + const serviceRef = React.useMemo(() => targetNode.target, [targetNode]); + + const _transformedData = React.useMemo(() => { + return [ + { + key: 'Connection URL', + title: 'Connection URL', + helperTitle: 'Connection URL', + helperDescription: 'JMX Service URL', + content: serviceRef.connectUrl, + }, + { + key: 'Alias', + title: 'Alias', + helperTitle: 'Alias', + helperDescription: 'Connection Nickname (same as Connection URL if not specified).', + content: serviceRef.alias, + }, + { + key: 'JVM ID', + title: 'JVM ID', + helperTitle: 'JVM ID', + helperDescription: 'The ID of the current JVM.', + content: serviceRef.jvmId || , + }, + { + key: 'Labels', + title: 'Labels', + helperTitle: 'Labels', + helperDescription: 'Map of string keys and values that can be used to organize and categorize targets.', + content: , + }, + { + key: 'Annotations', + title: 'Annotations', + helperTitle: 'Annotations', + helperDescription: + 'Annotations is an unstructured key value map stored with a target that may be set by external tools.', + content: , + }, + ]; + }, [serviceRef]); + + return ( + + {_transformedData.map((d) => ( + + + + {d.title} + + + + {d.content} + + + ))} + + ); +}; + +export const GroupDetails: React.FC<{ + envNode: EnvironmentNode; + columnModifier?: React.ComponentProps['columnModifier']; +}> = ({ envNode, columnModifier, ...props }) => { + const _transformedData = React.useMemo(() => { + return [ + { + key: 'Name', + title: 'Name', + helperTitle: 'Name', + helperDescription: 'Name of Realm (group) representing a target discovery mechanism.', + content: envNode.name, + }, + { + key: 'Labels', + title: 'Labels', + helperTitle: 'Labels', + helperDescription: 'Map of string keys and values that can be used to organize and categorize targets.', + content: , + }, + ]; + }, [envNode]); + + return ( + + {_transformedData.map((d) => ( + + + + {d.title} + + + {d.content} + + ))} + + ); +}; + +export const TargetResources: React.FC<{ targetNode: TargetNode }> = ({ targetNode, ...props }) => { + const context = React.useContext(ServiceContext); + const addSubscription = useSubscriptions(); + const target = targetNode.target; + + const [agentDetected, setAgentDetected] = React.useState(false); + + const checkIfAgentDetected = React.useCallback(() => { + addSubscription( + context.api + .doGet(`targets/${encodeURIComponent(target.connectUrl)}/probes`, 'v2', undefined, true, true) + .pipe( + concatMap(() => of(true)), + catchError(() => of(false)) + ) + .subscribe(setAgentDetected) + ); + }, [addSubscription, context.api, setAgentDetected, target]); + + React.useEffect(checkIfAgentDetected, [checkIfAgentDetected]); + + const tableConfigs = React.useMemo( + () => [ + { + title: 'Owned Resources', + columns: ['Resource', 'Total'], + rowData: TargetOwnedResourceTypeAsArray.filter((r) => agentDetected || r !== 'agentProbes'), + }, + { + title: 'Related Resources', + columns: ['Resource', 'Matching Total'], + rowData: TargetRelatedResourceTypeAsArray, + }, + ], + [agentDetected] + ); + + return ( + + {tableConfigs.map(({ title, columns, rowData }) => ( + + + + {title} + + + + + + {columns.map((col, idx) => ( + 0}> + {col} + + ))} + + + + {rowData.map((val) => ( + + ))} + + + + + + ))} + + ); +}; + +export const TargetResourceItem: React.FC<{ + targetNode: TargetNode; + resourceType: TargetOwnedResourceType | TargetRelatedResourceType; +}> = ({ targetNode, resourceType, ...props }) => { + const services = React.useContext(ServiceContext); + const addSubscription = useSubscriptions(); + const targetSubjectRef = React.useRef(new Subject()); + const targetSubject = targetSubjectRef.current; + + const [resources, setResources] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + + const isOwned = React.useMemo(() => isOwnedResource(resourceType), [resourceType]); + + React.useEffect(() => { + addSubscription( + targetSubject.pipe(switchMap((tn) => getTargetOwnedResources(resourceType, tn, services.api))).subscribe({ + next: (rs) => { + setLoading(false); + setError(undefined); + setResources(rs); + }, + error: (error) => { + setLoading(false); + setError(error); + }, + }) + ); + }, [setLoading, addSubscription, setResources, resourceType, services.api, targetSubject]); + + React.useEffect(() => { + const patchEventConfig = [ + { + categories: getResourceAddedEvents(resourceType), + }, + { + categories: getResourceRemovedEvents(resourceType), + deleted: true, + }, + ]; + + patchEventConfig.forEach(({ categories, deleted }) => { + addSubscription( + targetSubject + .pipe( + switchMap((tn) => + combineLatest([of(tn), merge(...categories.map((cat) => services.notificationChannel.messages(cat)))]) + ) + ) + .subscribe(([targetNode, event]) => { + const extractedUrl = extraTargetConnectUrlFromEvent(event); + if (!isOwned || (extractedUrl && extractedUrl === targetNode.target.connectUrl)) { + setLoading(true); + setResources((old) => { + // Avoid accessing state directly, which + // causes the effect to run every time + addSubscription( + getResourceListPatchFn(resourceType, targetNode, services.api)(old, event, deleted).subscribe({ + next: (rs) => { + setLoading(false); + setError(undefined); + setResources(rs); + }, + error: (error) => { + setLoading(false); + setError(error); + }, + }) + ); + return old; + }); + } + }) + ); + }); + }, [ + addSubscription, + setLoading, + services.api, + isOwned, + targetSubject, + resourceType, + services.notificationChannel, + setResources, + setError, + ]); + + React.useEffect(() => { + targetSubject.next(targetNode); + }, [targetNode, targetSubject]); + + const switchTarget = React.useCallback( + () => services.target.setTarget(targetNode.target), + [targetNode.target, services.target] + ); + + return ( + + + { + + {splitWordsOnUppercase(resourceType, true).join(' ')} + + } + + + {loading ? ( + + + + ) : error ? ( + + + + ) : ( + {resources.length} + )} + + + ); +}; + +export const GroupResources: React.FC<{ envNode: EnvironmentNode }> = ({ envNode, ...props }) => { + const contents = React.useMemo(() => { + return envNode.children.map((child) => { + const isTarget = isTargetNode(child); + const [status, extra] = getStatusTargetNode(child); + + return ( + + + + + +
+ {nodeTypeToAbbr(child.nodeType)} + {isTarget ? child.target.alias : child.name} +
+
+
+ {status === NodeStatus.warning ? ( + + + + + + ) : null} +
+
+
+ ); + }); + }, [envNode]); + + return ( + + + Number of children of this group: + {envNode.children.length} + + {contents.map((content, idx) => ( + {content} + ))} + + ); +}; + +export interface EntityDetailHeaderProps { + titleContent: React.ReactNode; + badgeTooltipContent?: React.ReactNode; + badge?: ReturnType; + actionDropdown?: React.ReactNode; + status: [NodeStatus?, StatusExtra?]; +} + +export const EntityDetailHeader: React.FC = ({ + titleContent, + badge, + badgeTooltipContent, + actionDropdown, + status: statusContent, + ...props +}) => { + const [status, extra] = statusContent; + const [showBanner, setShowBanner] = React.useState(true); + return ( +
+ + + + + {actionDropdown} + + {status && showBanner ? ( + setShowBanner(false)} />} + > + + {extra?.description} + {extra?.callForAction ? ( + + + {extra.callForAction.map((action, index) => ( + {action} + ))} + + + ) : null} + + + ) : null} +
+ ); +}; + +export default EntityDetails; diff --git a/src/app/Topology/Shared/Entity/EntityLabels.tsx b/src/app/Topology/Shared/Entity/EntityLabels.tsx new file mode 100644 index 000000000..918bbd98c --- /dev/null +++ b/src/app/Topology/Shared/Entity/EntityLabels.tsx @@ -0,0 +1,60 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Label, LabelGroup } from '@patternfly/react-core'; +import * as React from 'react'; +import { EmptyText } from '../EmptyText'; + +export const EntityLabels: React.FC<{ labels?: object; maxDisplay?: number }> = ({ labels, maxDisplay, ...props }) => { + const _transformedLabels = React.useMemo(() => { + return labels ? Object.keys(labels).map((k) => `${k}=${labels[k]}`) : []; + }, [labels]); + + return _transformedLabels.length ? ( +
+ + {_transformedLabels.map((l) => ( + + ))} + +
+ ) : ( + + ); +}; diff --git a/src/app/Topology/Shared/Entity/EntityTitle.tsx b/src/app/Topology/Shared/Entity/EntityTitle.tsx new file mode 100644 index 000000000..26dca3561 --- /dev/null +++ b/src/app/Topology/Shared/Entity/EntityTitle.tsx @@ -0,0 +1,59 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Tooltip } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import * as React from 'react'; + +export const EntityTitle: React.FC<{ + content: React.ReactNode; + badge?: React.ReactNode; + badgeTooltipContent?: React.ReactNode; +}> = ({ content, badge, badgeTooltipContent, ...props }) => { + return ( +
+ {badge ? ( + + {badge} + + ) : ( + <> + )} + {content} +
+ ); +}; diff --git a/src/app/Topology/Shared/Entity/utils.ts b/src/app/Topology/Shared/Entity/utils.ts new file mode 100644 index 000000000..78323c61b --- /dev/null +++ b/src/app/Topology/Shared/Entity/utils.ts @@ -0,0 +1,336 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { EventTemplate } from '@app/CreateRecording/CreateRecording'; +import { EventType } from '@app/Events/EventTypes'; +import { Rule } from '@app/Rules/Rules'; +import { + ActiveRecording, + ApiService, + ArchivedRecording, + EventProbe, + Recording, + StoredCredential, + UPLOADS_SUBDIRECTORY, +} from '@app/Shared/Services/Api.service'; +import { NotificationCategory, NotificationMessage } from '@app/Shared/Services/NotificationChannel.service'; +import { Link } from 'react-router-dom'; +import { concatMap, defaultIfEmpty, forkJoin, map, Observable, of } from 'rxjs'; +import { TargetNode } from '../../typings'; + +export type ResourceTypes = Recording | EventTemplate | EventType | EventProbe | Rule | StoredCredential; + +// Note: Values will be word split to used as display names +export const TargetOwnedResourceTypeAsArray = [ + 'activeRecordings', + 'archivedRecordings', + 'archivedUploadRecordings', + 'eventTemplates', + 'eventTypes', + 'agentProbes', +] as const; + +export const TargetRelatedResourceTypeAsArray = ['automatedRules', 'credentials'] as const; + +export type TargetOwnedResourceType = (typeof TargetOwnedResourceTypeAsArray)[number]; + +export type TargetRelatedResourceType = (typeof TargetRelatedResourceTypeAsArray)[number]; + +export const isOwnedResource = (resourceType: TargetOwnedResourceType | TargetRelatedResourceType) => { + return resourceType !== 'automatedRules' && resourceType !== 'credentials'; +}; + +export const getTargetOwnedResources = ( + resourceType: TargetOwnedResourceType | TargetRelatedResourceType, + { target }: TargetNode, + apiService: ApiService +): Observable => { + switch (resourceType) { + case 'activeRecordings': + return apiService.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/recordings`, + 'v1', + undefined, + true, + true + ); + case 'archivedRecordings': + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + 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': + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + 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[])); + case 'eventTemplates': + return apiService.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/templates`, + 'v1', + undefined, + true, + true + ); + case 'eventTypes': + return apiService.doGet( + `targets/${encodeURIComponent(target.connectUrl)}/events`, + 'v1', + undefined, + true, + true + ); + case 'agentProbes': + return apiService.getActiveProbesForTarget(target, true, true); + case 'automatedRules': + return apiService.getRules(true, true).pipe( + concatMap((rules) => { + const tasks = rules.map((r) => + apiService.isTargetMatched(r.matchExpression, target).pipe(map((ok) => (ok ? [r] : []))) + ); + return forkJoin(tasks).pipe( + defaultIfEmpty([[] as Rule[]]), + map((rules) => rules.reduce((prev, curr) => prev.concat(curr))) + ); + }) + ); + case 'credentials': + return apiService.getCredentials(true, true).pipe( + concatMap((credentials) => { + const tasks = credentials.map((crd) => + apiService.isTargetMatched(crd.matchExpression, target).pipe(map((ok) => (ok ? [crd] : []))) + ); + return forkJoin(tasks).pipe( + defaultIfEmpty([[] as StoredCredential[]]), + map((rules) => rules.reduce((prev, curr) => prev.concat(curr))) + ); + }) + ); + default: + throw new Error(`Unsupported resource: ${resourceType}`); + } +}; + +export const getResourceAddedEvent = (resourceType: TargetOwnedResourceType | TargetRelatedResourceType) => { + switch (resourceType) { + case 'activeRecordings': + return [NotificationCategory.ActiveRecordingCreated, NotificationCategory.SnapshotCreated]; + case 'archivedRecordings': + return [NotificationCategory.ArchivedRecordingCreated, NotificationCategory.ActiveRecordingSaved]; + case 'archivedUploadRecordings': + return [NotificationCategory.ArchivedRecordingCreated]; + case 'eventTemplates': + return [NotificationCategory.TemplateUploaded]; + case 'eventTypes': + return []; + case 'agentProbes': + return [NotificationCategory.ProbeTemplateApplied]; + case 'automatedRules': + return [NotificationCategory.RuleCreated, NotificationCategory.RuleUpdated]; + case 'credentials': + return [NotificationCategory.CredentialsStored, NotificationCategory.TargetCredentialsStored]; + default: + throw new Error(`Unsupported resource: ${resourceType}`); + } +}; + +export const getResourceRemovedEvent = (resourceType: TargetOwnedResourceType | TargetRelatedResourceType) => { + switch (resourceType) { + case 'activeRecordings': + return [NotificationCategory.ActiveRecordingDeleted, NotificationCategory.SnapshotDeleted]; + case 'archivedRecordings': + return [NotificationCategory.ArchivedRecordingDeleted]; + case 'archivedUploadRecordings': + return [NotificationCategory.ArchivedRecordingDeleted]; + case 'eventTemplates': + return [NotificationCategory.TemplateDeleted]; + case 'eventTypes': + return []; + case 'agentProbes': + return [NotificationCategory.ProbesRemoved]; + case 'automatedRules': + return [NotificationCategory.RuleDeleted]; + case 'credentials': + return [NotificationCategory.CredentialsDeleted, NotificationCategory.TargetCredentialsDeleted]; + default: + throw new Error(`Unsupported resource: ${resourceType}`); + } +}; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type PatchFn = (arr: ResourceTypes[], eventData: any, removed?: boolean) => Observable; + +export const getResourceListPatchFn = ( + resourceType: TargetOwnedResourceType | TargetRelatedResourceType, + { target }: TargetNode, + apiService: ApiService +): PatchFn => { + switch (resourceType) { + case 'activeRecordings': + case 'archivedRecordings': + case 'archivedUploadRecordings': + return (arr: Recording[], eventData: any, removed?: boolean) => { + const recording: Recording = eventData.message.recording; + let newArr = arr.filter((r) => r.name !== recording.name); + if (!removed) { + newArr = newArr.concat([recording]); + } + return of(newArr); + }; + case 'eventTemplates': + return (arr: EventTemplate[], eventData: any, removed?: boolean) => { + const template: EventTemplate = eventData.message.template; + let newArr = arr.filter((r) => r.name !== template.name); + if (!removed) { + newArr = newArr.concat([template]); + } + return of(newArr); + }; + case 'agentProbes': + return (arr: EventProbe[], eventData: any, removed?: boolean) => { + // Only support remove all + if (removed) { + return of([]); + } + const probes = (eventData.message.events as EventProbe[]) || []; + const probeIds = probes.map((p) => p.id); + return of([...arr.filter((probe) => !probeIds.includes(probe.id)), ...probes]); + }; + case 'automatedRules': + return (arr: Rule[], eventData: any, removed?: boolean) => { + const rule: Rule = eventData.message; + + return apiService.isTargetMatched(rule.matchExpression, target).pipe( + map((ok) => { + if (ok) { + let newArr = arr.filter((r) => r.name !== rule.name); + if (!removed) { + newArr = newArr.concat([rule]); + } + return newArr; + } + return arr; + }) + ); + }; + case 'credentials': + return (arr: StoredCredential[], eventData: any, removed?: boolean) => { + const credential: StoredCredential = eventData.message; + + return apiService.isTargetMatched(credential.matchExpression, target).pipe( + map((ok) => { + if (ok) { + // return apiService.isTargetMatched(credential.matchExpression, ) + let newArr = arr.filter((r) => r.id !== credential.id); + if (!removed) { + newArr = newArr.concat([credential]); + } + return newArr; + } + return arr; + }) + ); + }; + default: + throw new Error(`Unsupported resource: ${resourceType}`); + } +}; + +// TODO: Revisit when updating to react-router v6 +export const getLinkPropsForTargetResource = ( + resourceType: TargetOwnedResourceType | TargetRelatedResourceType +): React.ComponentProps => { + switch (resourceType) { + case 'activeRecordings': + return { to: { pathname: '/recordings', state: { tab: 'active' } } }; + case 'archivedRecordings': + return { to: { pathname: '/recordings', state: { tab: 'archived' } } }; + case 'archivedUploadRecordings': + return { to: { pathname: '/archives', state: { tab: 'uploads' } } }; + case 'eventTemplates': + return { to: { pathname: '/events', state: { eventTab: 'templates' } } }; + case 'eventTypes': + return { to: { pathname: '/events', state: { eventTab: 'types' } } }; + case 'agentProbes': + return { to: { pathname: '/events', state: { agentTab: 'probes' } } }; + case 'automatedRules': + return { to: { pathname: '/rules' } }; + case 'credentials': + return { to: { pathname: '/security' } }; + default: + throw new Error(`Unsupported resource: ${resourceType}`); + } +}; + +export const extraTargetConnectUrlFromEvent = (event: NotificationMessage): string | undefined => { + return event.message.target || event.message.targetId; +}; diff --git a/src/app/Topology/Shared/HintBanner.tsx b/src/app/Topology/Shared/HintBanner.tsx new file mode 100644 index 000000000..38628852b --- /dev/null +++ b/src/app/Topology/Shared/HintBanner.tsx @@ -0,0 +1,60 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Tooltip } from '@patternfly/react-core'; +import { CloseIcon } from '@patternfly/react-icons'; +import { css } from '@patternfly/react-styles'; +import * as React from 'react'; + +export interface HintBannerProps { + className?: string; + style?: React.CSSProperties; + show?: boolean; + onClose?: () => void; + children?: React.ReactNode; +} + +export const HintBanner: React.FC = ({ className, style, children, onClose, show, ...props }) => { + return show ? ( +
+ {children} + + + +
+ ) : null; +}; diff --git a/src/app/Topology/Shared/QuickSearchIcon.tsx b/src/app/Topology/Shared/QuickSearchIcon.tsx new file mode 100644 index 000000000..9128f5988 --- /dev/null +++ b/src/app/Topology/Shared/QuickSearchIcon.tsx @@ -0,0 +1,83 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import * as React from 'react'; + +const QuickSearchIcon: React.FC> = ({ style }) => { + return ( + + ); +}; + +export default QuickSearchIcon; diff --git a/src/app/Topology/Shared/Shortcuts.tsx b/src/app/Topology/Shared/Shortcuts.tsx new file mode 100644 index 000000000..bc5ceda23 --- /dev/null +++ b/src/app/Topology/Shared/Shortcuts.tsx @@ -0,0 +1,101 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { TableComposable, Tbody, Td, Tr } from '@patternfly/react-table'; +import _ from 'lodash'; +import * as React from 'react'; + +export interface IShortcut { + id: string; + shortcut: React.ReactNode; + description: React.ReactNode; +} + +export interface ShortcutsProps { + shortcuts: IShortcut[]; +} + +export const Shortcuts: React.FC = ({ shortcuts, ...props }) => { + return ( + + + {shortcuts.map((sc) => ( + + {sc.shortcut} + {sc.description} + + ))} + + + ); +}; + +export interface IShortcutCommand { + id: string; + icon?: React.ReactNode; + command: string; +} + +export const ShortcutCommand: React.FC<{ commands: IShortcutCommand[] }> = ({ commands, ...props }) => { + const content = React.useMemo(() => { + const _content = commands.map((command) => ( + + {command.icon ? ( + + {command.icon} + + ) : null} + {command.command} + + )); + // Put + in between + return _.flatMap(_content, (val, index) => { + if (index < _content.length - 1) { + return [ + val, + + + + , + ]; + } + return [val]; + }); + }, [commands]); + return
{content}
; +}; + +export default Shortcuts; diff --git a/src/app/Topology/Shared/TopologyEmptyState.tsx b/src/app/Topology/Shared/TopologyEmptyState.tsx new file mode 100644 index 000000000..6eac4d065 --- /dev/null +++ b/src/app/Topology/Shared/TopologyEmptyState.tsx @@ -0,0 +1,93 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateSecondaryActions, + EmptyStateVariant, + Title, +} from '@patternfly/react-core'; +import { TopologyIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { DiscoveryTreeContext, getAllLeaves } from './utils'; + +export interface TopologyEmptyStateProps {} + +export const TopologyEmptyState: React.FC = ({ ...props }) => { + const discoveryTree = React.useContext(DiscoveryTreeContext); + + const isTruelyEmpty = React.useMemo(() => { + return !getAllLeaves(discoveryTree).length; + }, [discoveryTree]); + + const emptyStateContent = React.useMemo(() => { + if (isTruelyEmpty) { + return ( + + Start launching a Java application or define a{' '} + Custom Target. + + ); + } + return ( + <> + Adjust your filters/searches and try again. + + + + + + ); + }, [isTruelyEmpty]); + + return ( + + + + + No Targets Found + + {emptyStateContent} + + + ); +}; diff --git a/src/app/Topology/Shared/TopologyExceedLimitState.tsx b/src/app/Topology/Shared/TopologyExceedLimitState.tsx new file mode 100644 index 000000000..a8bf532d6 --- /dev/null +++ b/src/app/Topology/Shared/TopologyExceedLimitState.tsx @@ -0,0 +1,75 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateSecondaryActions, + Title, +} from '@patternfly/react-core'; +import { TopologyIcon } from '@patternfly/react-icons'; +import * as React from 'react'; + +export interface TopologyExceedLimitStateProps { + onShowTopologyAnyway: () => void; +} + +export const TopologyExceedLimitState: React.FC = ({ + onShowTopologyAnyway, + ...props +}) => { + return ( + + + + Loading is taking longer than expected + + We noticed that it is taking a long time to visualize your application Topology. You can use filters to select + a smaller subset of target or click Continue to keep waiting. + + + + + + + ); +}; diff --git a/src/app/Topology/Shared/TranslatedText.tsx b/src/app/Topology/Shared/TranslatedText.tsx new file mode 100644 index 000000000..7b1170d9d --- /dev/null +++ b/src/app/Topology/Shared/TranslatedText.tsx @@ -0,0 +1,49 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Text, TextProps } from '@patternfly/react-core'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface TranslatedTextProps extends TextProps { + transKey: string; +} + +export const TranslatedText: React.FunctionComponent = ({ transKey, ...props }) => { + const { t } = useTranslation(); + return {t(transKey)}; +}; diff --git a/src/app/Topology/Shared/utils.tsx b/src/app/Topology/Shared/utils.tsx new file mode 100644 index 000000000..5b6c1fa37 --- /dev/null +++ b/src/app/Topology/Shared/utils.tsx @@ -0,0 +1,257 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { TopologyFilters } from '@app/Shared/Redux/Filters/TopologyFilterSlice'; +import { evaluateTargetWithExpr, hashCode } from '@app/utils/utils'; +import { Button } from '@patternfly/react-core'; +import { ContextMenuSeparator, GraphElement, NodeStatus } from '@patternfly/react-topology'; +import _ from 'lodash'; +import * as React from 'react'; +import { BehaviorSubject, debounceTime, Observable, Subscription } from 'rxjs'; +import { ContextMenuItem, MenuItemVariant, nodeActions } from '../Actions/NodeActions'; +import { WarningResolverAsCredModal } from '../Actions/WarningResolver'; +import { EnvironmentNode, TargetNode, isTargetNode, NodeType, DEFAULT_EMPTY_UNIVERSE } from '../typings'; + +export const DiscoveryTreeContext = React.createContext(DEFAULT_EMPTY_UNIVERSE); + +export const nodeTypeToAbbr = (type: NodeType): string => { + // Keep uppercases (or uppercase whole word if none) and retain first 4 charaters. + return (type.replace(/[^A-Z]/g, '') || type.toUpperCase()).slice(0, 4); +}; + +export const getAllLeaves = (root: EnvironmentNode | TargetNode): TargetNode[] => { + if (isTargetNode(root)) { + return [root]; + } + const INIT: TargetNode[] = []; + return root.children.reduce((prev, curr) => prev.concat(getAllLeaves(curr)), INIT); +}; + +export const flattenTree = ( + node: EnvironmentNode | TargetNode, + includeUniverse?: boolean +): (EnvironmentNode | TargetNode)[] => { + if (isTargetNode(node)) { + return [node]; + } + + const INIT: (EnvironmentNode | TargetNode)[] = []; + const allChildren = node.children.reduce((prev, curr) => prev.concat(flattenTree(curr)), INIT); + + if (node.nodeType === NodeType.UNIVERSE && !includeUniverse) { + return [...allChildren]; + } + + return [node, ...allChildren]; +}; + +export const getUniqueNodeTypes = (nodes: (EnvironmentNode | TargetNode)[]): NodeType[] => { + return Array.from(new Set(nodes.map((n) => n.nodeType))); +}; + +export interface TransformConfig { + showOnlyTopGroup?: boolean; + expandMode?: boolean; +} + +export const getUniqueGroupId = (group: EnvironmentNode) => { + return `${group.name}-${hashCode(JSON.stringify(group.labels))}-${hashCode(JSON.stringify(group.children))}`; +}; + +export const getUniqueTargetId = (target: TargetNode) => { + return target.name; +}; + +export type StatusExtra = { title?: string; description?: string; callForAction?: React.ReactNode[] }; + +export const getStatusTargetNode = (node: TargetNode | EnvironmentNode): [NodeStatus?, StatusExtra?] => { + if (isTargetNode(node)) { + return node.target.jvmId + ? [] + : [ + NodeStatus.warning, + { + title: 'Failed to compute JVM ID', + description: `Target ${node.target.alias} might be missing credentials.`, + callForAction: [ + + + , + ], + }, + ]; + } + return []; +}; + +export const actionFactory = (element: GraphElement | ListElement, variant: MenuItemVariant = 'contextMenuItem') => { + const data: TargetNode = element.getData(); + let filtered = nodeActions.filter((action) => { + return ( + (!action.includeList || action.includeList.includes(data.nodeType)) && + (!action.blockList || !action.blockList.includes(data.nodeType)) + ); + }); + + // Remove trailing separator + let stop: number = filtered.length - 1; + for (; stop >= 0; stop--) { + if (!filtered[stop].isSeparator) { + break; + } + } + filtered = stop >= 0 ? filtered.slice(0, stop + 1) : []; + + return filtered.map(({ isSeparator, title, action }, index) => { + if (isSeparator) { + return ; + } + return ( + + {title} + + ); + }); +}; + +export type ListElement = { + getData: GraphElement['getData']; +}; + +export const isGraphElement = (element: GraphElement | ListElement): element is GraphElement => { + return (element as GraphElement).getGraph !== undefined; +}; + +export const COLLAPSE_EXEMPTS = [NodeType.NAMESPACE, NodeType.REALM, NodeType.UNIVERSE]; + +// For searching +export const isGroupNodeFiltered = ( + groupNode: EnvironmentNode, + filters?: TopologyFilters['groupFilters']['filters'] +) => { + if (!filters || !filters[groupNode.nodeType]) { + return true; + } + const filter = filters[groupNode.nodeType]; + let matched = true; + if (filter.Name && filter.Name.length) { + matched = matched && filter.Name.includes(groupNode.name); + } + if (filter.Label && filter.Label.length) { + matched = + matched && Object.entries(groupNode.labels).filter(([k, v]) => filter.Label.includes(`${k}=${v}`)).length > 0; + } + return matched; +}; + +export const isTargetNodeFiltered = ({ target }: TargetNode, filters?: TopologyFilters['targetFilters']['filters']) => { + if (!filters) { + return true; + } + let matched = true; + if (filters.Alias && filters.Alias.length) { + matched = matched && filters.Alias.includes(target.alias); + } + if (filters.ConnectionUrl && filters.ConnectionUrl.length) { + matched = matched && filters.ConnectionUrl.includes(target.connectUrl); + } + if (filters.JvmId && filters.JvmId.length) { + matched = matched && target.jvmId !== undefined && filters.JvmId.includes(target.jvmId); + } + if (filters.Label && filters.Label.length) { + matched = + matched && Object.entries(target.labels || {}).filter(([k, v]) => filters.Label.includes(`${k}=${v}`)).length > 0; + } + if (filters.Annotation && filters.Annotation.length) { + const annotations = target.annotations; + matched = + matched && + [...Object.entries(annotations?.cryostat || {}), ...Object.entries(annotations?.platform || {})].filter( + ([k, v]) => filters.Annotation.includes(`${k}=${v}`) + ).length > 0; + } + return matched; +}; + +class SearchExprService { + private readonly _state$ = new BehaviorSubject(''); + + searchExpression(): Observable { + return this._state$.asObservable(); + } + + setSearchExpression(expr: string): void { + this._state$.next(expr); + } +} + +export const defaultSearchExpression = new SearchExprService(); + +export const SearchExprServiceContext = React.createContext(defaultSearchExpression); + +export const useSearchExpression = (debounceMs = 0): [string, (expr: string) => void] => { + const [expr, setExpr] = React.useState(''); + const exprSvc = React.useContext(SearchExprServiceContext); + const _subRef = React.useRef(); + + React.useEffect(() => { + _subRef.current = exprSvc.searchExpression().pipe(debounceTime(debounceMs)).subscribe(setExpr); + return () => _subRef.current?.unsubscribe(); + }, [_subRef, setExpr, exprSvc, debounceMs]); + + const handleChange = React.useCallback( + (value: string) => { + exprSvc.setSearchExpression(value); + }, + [exprSvc] + ); + return [expr, handleChange]; +}; + +export const isTargetMatched = ({ target }: TargetNode, matchExpression: string): boolean => { + try { + const res = evaluateTargetWithExpr(target, matchExpression); + if (typeof res === 'boolean') { + return res; + } + return false; + } catch (err) { + return false; + } +}; diff --git a/src/app/Topology/SideBar/TopologySideBar.tsx b/src/app/Topology/SideBar/TopologySideBar.tsx new file mode 100644 index 000000000..bd004f2cc --- /dev/null +++ b/src/app/Topology/SideBar/TopologySideBar.tsx @@ -0,0 +1,62 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DrawerActions, DrawerCloseButton, DrawerHead, DrawerPanelBody } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import { TopologySideBar as PFTopologySideBar } from '@patternfly/react-topology'; +import * as React from 'react'; + +export interface TopologySideBarProps { + onClose: () => void; + children: React.ReactNode; + className?: string; +} + +// Parent will wrap this element in +export const TopologySideBar: React.FC = ({ children, onClose, className, ...props }) => { + return ( + + + + + + + {children} + + ); +}; diff --git a/src/app/Topology/Toolbar/DisplayOptions.tsx b/src/app/Topology/Toolbar/DisplayOptions.tsx new file mode 100644 index 000000000..dcf22a391 --- /dev/null +++ b/src/app/Topology/Toolbar/DisplayOptions.tsx @@ -0,0 +1,132 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { groupingOptions, OptionCategory, showOptions } from '@app/Shared/Redux/Configurations/TopologyConfigSlicer'; +import { RootState, topologyDisplayOpionsSetIntent } from '@app/Shared/Redux/ReduxStore'; +import { Checkbox, Divider, Select, Stack, StackItem, Switch } from '@patternfly/react-core'; +import * as React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +export interface DisplayOptionsProps { + isDisabled?: boolean; + isGraph?: boolean; +} + +export const DisplayOptions: React.FC = ({ + isDisabled = false, + isGraph: isGraphView = true, + ...props +}) => { + const [open, setOpen] = React.useState(false); + const { show, groupings } = useSelector((state: RootState) => state.topologyConfigs.displayOptions); + const dispatch = useDispatch(); + const handleToggle = React.useCallback(() => setOpen((old) => !old), [setOpen]); + + const getChangeHandler = React.useCallback( + (group: OptionCategory, key: string) => { + return (checked: boolean, _) => { + dispatch(topologyDisplayOpionsSetIntent(group, key, checked)); + }; + }, + [dispatch] + ); + + const checkBoxContents = React.useMemo((): [string, JSX.Element][] => { + return showOptions.map(([option, key]) => [ + key, + , + ]); + }, [show, isGraphView, getChangeHandler]); + + const switchContents = React.useMemo((): [string, JSX.Element][] => { + return groupingOptions.map(([option, key]) => [ + key, + , + ]); + }, [groupings, getChangeHandler]); + + const menuContent = React.useMemo(() => { + return ( + + + Groupings + + {switchContents.map(([key, children]) => ( + {children} + ))} + + + + + Show + + {checkBoxContents.map(([key, children]) => ( + {children} + ))} + + ); + }, [checkBoxContents, switchContents]); + + return ( + `${isGroup ? 'Group' : 'Target'}: ${getDisplayFieldName(selected)}`, + compareTo: (other) => other.category === selected, + ...{ + category: selected, + }, + }} + aria-label={'Filter Categories'} + placeholderText={'Select a category'} + isGrouped + > + {options} + + ); +}; + +export const TopologyFilter: React.FC<{ isDisabled?: boolean }> = ({ isDisabled, ...props }) => { + const dispatch = useDispatch(); + const { isGroup, groupFilters, targetFilters } = useSelector((state: RootState) => state.topologyFilters); + const discoveryTree = React.useContext(DiscoveryTreeContext); + + const flattenedTree = React.useMemo(() => flattenTree(discoveryTree), [discoveryTree]); + + const groupNodeTypes = React.useMemo( + () => getUniqueNodeTypes(flattenedTree.filter((n) => !isTargetNode(n))), + [flattenedTree] + ); + + const generateOnSelect = React.useCallback( + (isGroup: boolean) => { + return (_, { value, nodeType, category }) => { + dispatch(topologyAddFilterIntent(isGroup, nodeType, category, value)); + }; + }, + [dispatch] + ); + + const groupInputs = React.useMemo(() => { + return allowedGroupFilters.map((cat) => { + const isShown = isGroup && groupFilters.category === cat; + const ariaLabel = `Filter by ${getDisplayFieldName(cat)}...`; + + const optionGroup = groupNodeTypes + .map((type) => ({ + groupLabel: type, + options: Array.from( + new Set( + flattenedTree + .filter((n) => n.nodeType === type) + .map((groupNode: EnvironmentNode) => fieldValueToStrings(groupNode[categoryToNodeField(cat)])) + .reduce((prev, curr) => prev.concat(curr)) + .filter((val) => { + const filters = groupFilters.filters[type] || {}; + if (filters) { + const criteria = filters[cat] || []; + return !criteria || !criteria.includes(val); + } + return true; + }) + ) + ), + })) + .filter((group) => group.options && group.options.length); // Do show show empty groups + + const selectOptions = optionGroup.map(({ options, groupLabel }) => { + return ( + + {options.map((opt) => ( + opt, + compareTo: (other) => other.value === opt, + ...{ + nodeType: groupLabel, + value: opt, + category: cat, + }, + }} + > + {isLabelOrAnnotation(cat) ? : opt} + + ))} + + ); + }); + + return ( + + + {selectOptions} + + + ); + }); + }, [isGroup, groupFilters, flattenedTree, groupNodeTypes, isDisabled, generateOnSelect]); + + const targetInputs = React.useMemo(() => { + return allowedTargetFilters.map((cat) => { + const isShown = !isGroup && targetFilters.category === cat; + const ariaLabel = `Filter by ${getDisplayFieldName(cat)}...`; + + const options = Array.from( + new Set( + flattenedTree + .filter((n) => isTargetNode(n)) + .map(({ target }: TargetNode) => { + const value = target[categoryToNodeField(cat)]; + if (isAnnotation(cat)) { + return [...fieldValueToStrings(value['platform']), ...fieldValueToStrings(value['cryostat'])]; + } + return fieldValueToStrings(value); + }) + .reduce((prev, curr) => prev.concat(curr)) + .filter((val) => { + const criteria: string[] = targetFilters.filters[cat]; + return !criteria || !criteria.includes(val); + }) + ) + ); + + const selectOptions = options.map((opt) => { + return ( + opt, + compareTo: (other) => { + const regex = new RegExp(typeof other === 'string' ? other : other.value, 'i'); + return regex.test(opt); + }, + ...{ + nodeType: 'Target', // Ignored by reducer + value: opt, + category: cat, + }, + }} + > + {isLabelOrAnnotation(cat) ? : opt} + + ); + }); + + return ( + + + {selectOptions} + + + ); + }); + }, [isGroup, targetFilters, flattenedTree, isDisabled, generateOnSelect]); + + return ( +
+ {groupInputs} + {targetInputs} +
+ ); +}; + +export const TopologyFilterSelect: React.FC> = ({ + children: options, + onSelect, + isDisabled, + placeholderText, + ...props +}) => { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + ); +}; + +export const fieldValueToStrings = (value: unknown): string[] => { + if (value === undefined || value === null) { + return []; + } + if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.map((v) => `${v}`); + } else { + return Object.entries(value as object).map(([k, v]) => `${k}=${v}`); + } + } else { + return [`${value}`]; + } +}; + +export const isLabelOrAnnotation = (category: string) => /(label|annotation)/i.test(category); + +export const isAnnotation = (category: string) => /annotation/i.test(category); diff --git a/src/app/Topology/Toolbar/TopologyToolbar.tsx b/src/app/Topology/Toolbar/TopologyToolbar.tsx new file mode 100644 index 000000000..c7b12a53c --- /dev/null +++ b/src/app/Topology/Toolbar/TopologyToolbar.tsx @@ -0,0 +1,195 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { topologyConfigSetViewModeIntent, topologyDeleteAllFiltersIntent } from '@app/Shared/Redux/ReduxStore'; +import { Button, Popover, Toolbar, ToolbarContent, ToolbarItem, Tooltip } from '@patternfly/react-core'; +import { TopologyIcon, ListIcon, MouseIcon, QuestionCircleIcon } from '@patternfly/react-icons'; +import { Visualization } from '@patternfly/react-topology'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; +import { QuickSearchModal } from '../Actions/QuickSearchPanel'; +import Shortcuts, { ShortcutCommand } from '../Shared/Shortcuts'; +import { DisplayOptions } from './DisplayOptions'; +import { FindByMatchExpression } from './FindByMatchExpression'; +import { HelpButton } from './HelpButton'; +import { QuickSearchButton } from './QuickSearchButton'; +import { TopologyFilterChips } from './TopologyFilterChips'; +import { TopologyFilters } from './TopologyFilters'; + +export enum TopologyToolbarVariant { + Graph = 'graph', + List = 'list', +} + +export interface TopologyToolbarProps { + variant: TopologyToolbarVariant; + visualization?: Visualization; // Required when variant is graph + isDisabled?: boolean; +} + +export const TopologyToolbar: React.FC = ({ variant, visualization, isDisabled, ...props }) => { + const isGraphView = variant === TopologyToolbarVariant.Graph; + const dispatch = useDispatch(); + + const [quicksearchOpen, setQuicksearchOpen] = React.useState(false); + + const toggleView = React.useCallback(() => { + dispatch(topologyConfigSetViewModeIntent(isGraphView ? 'list' : 'graph')); + }, [dispatch, isGraphView]); + + const handleClearAllFilters = React.useCallback(() => { + dispatch(topologyDeleteAllFiltersIntent()); + }, [dispatch]); + + const handleQuickSearch = React.useCallback(() => { + setQuicksearchOpen(true); + // Close the mini menu if open + const contextMenu = document.getElementById('topology-context-menu'); + if (contextMenu) { + contextMenu.style.display = 'none'; + } + }, [setQuicksearchOpen]); + + const actionIcon = React.useMemo( + () => ( + + + + ), + [isGraphView, toggleView] + ); + + const shortcuts = React.useMemo(() => { + return isGraphView ? ( + }]} />, + }, + { + id: 'click-shortcut', + description: 'View details in side panel', + shortcut: ( + }]} /> + ), + }, + { + id: 'right-click-shortcut', + description: 'Access context menu', + shortcut: ( + }]} + /> + ), + }, + { + id: 'ctrl-space-shortcut', + description: 'Open quick search modal', + shortcut: ( + + ), + }, + ]} + /> + } + position="left" + > + + + ) : null; + }, [isGraphView]); + + React.useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.ctrlKey && event.code === 'Space') { + setQuicksearchOpen(true); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [setQuicksearchOpen]); + + return ( + <> + + + + + + + + + + + + + + + {isGraphView && !isDisabled ? ( + + + + ) : null} + {!isDisabled ? {shortcuts} : null} + {actionIcon} + + + + setQuicksearchOpen(false)} /> + + ); +}; diff --git a/src/app/Topology/Topology.tsx b/src/app/Topology/Topology.tsx new file mode 100644 index 000000000..d5aa7ac1b --- /dev/null +++ b/src/app/Topology/Topology.tsx @@ -0,0 +1,183 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage'; +import { ErrorView } from '@app/ErrorView/ErrorView'; +import { FeatureFlag } from '@app/Shared/FeatureFlag/FeatureFlag'; +import { LinearDotSpinner } from '@app/Shared/LinearDotSpinner'; +import { ViewMode } from '@app/Shared/Redux/Configurations/TopologyConfigSlicer'; +import { RootState } from '@app/Shared/Redux/ReduxStore'; +import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import '@app/Topology/styles/base.css'; +import { getFromLocalStorage, saveToLocalStorage } from '@app/utils/LocalStorage'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { Bullseye, Card, CardBody } from '@patternfly/react-core'; +import * as React from 'react'; +import { useSelector } from 'react-redux'; +import { Link, withRouter } from 'react-router-dom'; +import { TopologyGraphView } from './GraphView/TopologyGraphView'; +import { TopologyListView } from './ListView/TopologyListView'; +import { HintBanner } from './Shared/HintBanner'; +import { + defaultSearchExpression as defaultSearchExprService, + DiscoveryTreeContext, + SearchExprServiceContext, +} from './Shared/utils'; +import { DEFAULT_EMPTY_UNIVERSE } from './typings'; + +export interface TopologyProps {} + +export const Topology: React.FC = ({ ..._props }) => { + const addSubscription = useSubscriptions(); + const context = React.useContext(ServiceContext); + const firstFetchRef = React.useRef(false); + const firstFetched = firstFetchRef.current; + + const displayOptions = useSelector((state: RootState) => state.topologyConfigs.displayOptions); + const { groupings } = displayOptions; + const transformConfig = React.useMemo( + () => ({ showOnlyTopGroup: groupings.realmOnly, expandMode: !groupings.collapseSingles }), + [groupings] + ); + + const [discoveryTree, setDiscoveryTree] = React.useState(DEFAULT_EMPTY_UNIVERSE); + + const [shouldShowBanner, setShouldShowBanner] = React.useState(getFromLocalStorage('TOPOLOGY_SHOW_BANNER', true)); + const isGraphView = useSelector((state: RootState) => { + const _currentMode: ViewMode = state.topologyConfigs.viewMode; + return _currentMode === 'graph'; + }); + + const [error, setError] = React.useState(); + + const closeBanner = React.useCallback(() => { + setShouldShowBanner(false); + saveToLocalStorage('TOPOLOGY_SHOW_BANNER', false); + }, [setShouldShowBanner]); + + const _refreshDiscoveryTree = React.useCallback( + (onSuccess?: () => void) => { + addSubscription( + context.api.getDiscoveryTree().subscribe({ + next: (tree) => { + onSuccess && onSuccess(); + setError(undefined); + setDiscoveryTree(tree); + }, + error: (err) => { + setError(err); + console.log(err); + }, + }) + ); + }, + [addSubscription, context.api, setDiscoveryTree, setError] + ); + + React.useEffect(() => { + addSubscription( + // Credentials will trigger modifed target event if any + context.notificationChannel + .messages(NotificationCategory.TargetJvmDiscovery) + .subscribe((_) => _refreshDiscoveryTree()) + ); + }, [addSubscription, context.notificationChannel, _refreshDiscoveryTree]); + + React.useEffect(() => { + _refreshDiscoveryTree(() => (firstFetchRef.current = true)); + }, [_refreshDiscoveryTree, firstFetchRef]); + + const content = React.useMemo(() => { + if (error) { + return ( + + { + // Start from initial state + firstFetchRef.current = false; + setError(undefined); + _refreshDiscoveryTree(() => (firstFetchRef.current = true)); + }} + /> + + ); + } + + if (!firstFetched) { + return ( + + + + ); + } + + return ( + + {isGraphView ? ( + + ) : ( + + )} + + ); + }, [isGraphView, transformConfig, firstFetched, error, firstFetchRef, setError, _refreshDiscoveryTree]); + + return ( + <> + + + For topology guides, see Quickstarts. + + + + + + {content} + + + <> + + + ); +}; + +export default withRouter(Topology); diff --git a/src/app/Topology/styles/base.css b/src/app/Topology/styles/base.css new file mode 100644 index 000000000..86d62af42 --- /dev/null +++ b/src/app/Topology/styles/base.css @@ -0,0 +1,354 @@ +/* +Copyright The Cryostat Authors + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +/* +Below CSS rules only apply to Topology components +*/ + +.topology__view-switcher { + font-size: 1.8em !important; +} + +.sample-node-donut__node-wrapper { + position: relative; + width: 12em; + aspect-ratio: 1; + border-radius: 50%; + border: 1px solid var(--pf-global--palette--black-300); + background-color: #fff; + margin: 1em; +} + +.sample-node-donut__node-wrapper .sample-node-donut__node-icon { + position: absolute; + width: 90%; + aspect-ratio: 1; + top: 50%; + left: 50%; + margin: -45% 0 0 -45%; /* Ensure true center */ + border-radius: 50%; + padding: 1em; + border: 0.8em solid var(--pf-global--palette--blue-400); +} + +.sample-node-donut__node-wrapper.active:hover { + cursor: pointer; + box-shadow: 0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.25), 0 0 0.25rem 0 rgba(3, 3, 3, 0.06); +} + + +.sample-node-donut__node-wrapper .sample-node-donut__node-icon.success { + border: 0.8em solid var(--pf-global--success-color--100); +} + +.sample-node-donut__node-wrapper .sample-node-donut__node-icon.error { + border: 0.8em solid var(--pf-global--danger-color--100); +} + +.sample-node-donut__node-wrapper .sample-node-donut__status-indicator { + position: absolute; + width: 25%; + aspect-ratio: 1; + height: fit-content; + border-radius: 50%; + top: 75%; + left: 10%; + border: 1px solid var(--pf-global--palette--black-300); + background-color: #fff; +} + +.sample-node-donut__node-wrapper .sample-node-donut__status-indicator svg { + position: absolute; + font-size: 1.8em; + top: 18.35%; + left: 18.35%; +} + +.sample-node-donut__node-label { + width: fit-content; + padding: 8px; + border-radius: 4px; + box-shadow: 0 0.25rem 0.5rem 0rem rgba(3, 3, 3, 0.16), 0 0 0.25rem 0 rgba(3, 3, 3, 0.06); + font-weight: 700; + word-break: break-all; +} + +.sample-node-donut__node-label-badge { + position: relative; + background-color: var(--pf-global--palette--blue-500); + color: #fff; + padding: 2px 12px 2px 12px; + margin-right: 8px; + text-align: center; + border-radius: 500px; +} + +.entity-overview__wrapper { + padding: 1.6em; + padding-right: 0.6em; +} + +.entity-overview .pf-c-tabs__item-text { + font-size: 1.1em; +} + +.entity-overview .pf-c-tabs__item.pf-m-current .pf-c-tabs__item-text { + color: var(--pf-global--palette--blue-400); +} + +.entity-overview__displayed-labels-wrapper { + border: 1px solid var(--pf-global--palette--black-200); + padding: 4px; + width: fit-content; +} + +.entity-overview__displayed-annotations { + margin-bottom: 0.5em; +} + +.empty-text { + color: var(--pf-global--palette--black-400) +} + +.entity-overview__entity-title-wrapper { + font-weight: 700; + font-size: 1.2em; + color: var(--pf-global--palette--blue-500); +} + +.entity-overview__entity-title-badge { + background-color: var(--pf-global--palette--blue-500); + color: #fff; + padding: 2px 12px 2px 12px; + margin-right: 8px; + text-align: center; + border-radius: 500px; +} + +.entity-overview__entity-close-button { + margin-bottom: 0.6em; +} + +.entity-overview__entity-close-button svg { + transform: translate(1em, 0); +} + +.entity-overview__alert-banner { + margin-top: 1em; +} + +.entity-overview__header { + margin: 0em 0em 1em 1em; +} + +.topology-listview__realm-title { + font-size: 1.2em; + color: var(--pf-global--palette--blue-500); + margin-right: 0.5em; +} + +.topology__help-icon { + color: var(--pf-global--palette--blue-400); +} + +.topology__help-icon-button { + margin-left: -1em; +} + +.topology__shortcut-command { + border: 1px solid var(--pf-global--palette--black-300); + padding: 2px 4px; + border-radius: 4px; + color: var(--pf-global--palette--black-600); +} + +.topology__shortcut-command-icon { + margin-right: 0.5em; +} + +.topology__shortcut-command-plus { + margin: 0 0.5em +} + +.topology__node-badge text { + fill: #fff; + font-weight: 700; +} + +.topology__hint-banner { + position: relative; + box-sizing: border-box; + text-align: center; + padding: 0.2em 0 0.2em 0; + background-color: var(--pf-global--palette--blue-400) ; + color: #fff; + font-size: 0.9em; + margin-bottom: -1em; +} + +.topology__hint-banner a { + color: #fff; + text-decoration: solid underline #fff 1px; +} + +.topology__hint-banner .close-icon { + position: relative; + float: right; + font-size: 1em; + margin-right: 1em; + transform: translateY(calc(50% - 0.25em)); + cursor: pointer; +} + +.topology__main-container .topology__target-node { + cursor: pointer; +} + +.topology__main-container .topology__target-node.search-inactive { + opacity: 0.4; +} + +.topology__main-container .pf-topology__group { + cursor: pointer; +} + +.topology__main-container .topology__realm-group path { + fill: #d2d2d2; + fill-opacity: 0.5; +} + +.topology__display-option-menu { + width: max-content; +} + +.topology__display-option-menu-item { + margin-top: var(--pf-c-select__menu-group-title--PaddingTop); + margin-right: var(--pf-c-select__menu-group-title--PaddingRight); + margin-bottom: var(--pf-c-select__menu-group-title--PaddingBottom); + margin-left: var(--pf-c-select__menu-group-title--PaddingLeft); +} + +.topology__display-option_badge { + background-color: var(--pf-global--palette--blue-500); + color: #fff; + padding: 2px 12px 2px 12px; + margin-right: 8px; + text-align: center; + border-radius: 500px; + font-size: 0.9em; +} + +.topology__visualization-container { + padding: 1em; +} + +.topology__toolbar-container { + padding: 0.4em 0 0.2em 0 !important; +} + +.topology__toolbar-chip-content { + padding: 0 0.5em 0.4em 1em; /* Subtract 0.5em chip group right margin */ +} + +.topology__filter-chip-group { + margin-right: 0.5em; +} + +.topology__quicksearch__tab-icon { + width: 2.5em; + height: 2.5em; + box-sizing: border-box; + border-radius: 8px; + padding: 4px; + background-color: #fff; +} + +.topology__quicksearch__tab-text { + font-weight: 700; +} + +.topology__quicksearch__tab { + margin: 0 !important; +} + +.topology__quicksearch__tab.pf-m-current { + background-color: var(--pf-global--BackgroundColor--200); +} + +.topology__quicksearch__tab-content-title { + margin-bottom: -1em; + font-weight: 700; + font-size: 1.5em; +} + +.topology__quicksearch__tab-content-description-short { + color: #737373; + font-size: 1em; +} + +.topology__quick-search__context-menu { + position: absolute; + display: none; + z-index: 9999; +} + +.topology__quick-search-modal .quick-search-icon { + font-size: 0.8em !important; +} + +.quick-search-icon { + font-size: 1.1em !important; +} + +.topology__quick-search-button { + padding: 0; +} + +.topology__list-view__entity-details { + background-color: #fff; + padding: 1em; +} + +.topology__treeview-container { + padding: 1em; +} + +/* tree node container should span entire width */ +.topology__treeview-container .pf-c-tree-view__node-content { + width: 100%; +} diff --git a/src/app/Topology/typings.ts b/src/app/Topology/typings.ts new file mode 100644 index 000000000..3575cb37c --- /dev/null +++ b/src/app/Topology/typings.ts @@ -0,0 +1,91 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { Target } from '@app/Shared/Services/Target.service'; + +export enum NodeType { + // The entire deployment scenario Cryostat finds itself in. + UNIVERSE = 'Universe', + // A division of the deployment scenario (i.e. Kubernetes, JDP, Custom Target, CryostatAgent) + REALM = 'Realm', + // A plain target JVM, connectable over JMX. + JVM = 'JVM', + // A target JVM using the Cryostat Agent, *not* connectable over JMX. Agent instances + // that do publish a JMX Service URL should publish themselves with the JVM NodeType. + AGENT = 'CryostatAgent', + // Custom target defined via Custom Target Creation Form. + CUSTOM_TARGET = 'CustomTarget', + // Kubernetes platform. + NAMESPACE = 'Namespace', + STATEFULSET = 'StatefulSet', + DAEMONSET = 'DaemonSet', + DEPLOYMENT = 'Deployment', + DEPLOYMENTCONFIG = 'DeploymentConfig', // OpenShift specific + REPLICASET = 'ReplicaSet', + REPLICATIONCONTROLLER = 'ReplicationController', + POD = 'Pod', + ENDPOINT = 'Endpoint', +} + +export interface NodeLabels { + readonly [key: string]: any; +} + +interface _AbstractNode { + name: string; + nodeType: NodeType; + labels: NodeLabels; +} + +export interface EnvironmentNode extends _AbstractNode { + children: (EnvironmentNode | TargetNode)[]; +} + +export interface TargetNode extends _AbstractNode { + target: Target; +} + +export const DEFAULT_EMPTY_UNIVERSE: EnvironmentNode = { + name: 'Universe', + nodeType: NodeType.UNIVERSE, + labels: {}, + children: [], +}; + +export const isTargetNode = (node: EnvironmentNode | TargetNode): node is TargetNode => { + return node['target'] !== undefined && node['children'] === undefined; +}; diff --git a/src/app/app.css b/src/app/app.css index 290de76d6..24c179a2d 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -442,3 +442,67 @@ input[type=number].datetime-picker__number-input { .settings__content.active { display: block; } + +.expandable-form__accordion-toggle-block { + padding-left: 0 !important; +} + +.expandable-form__accordion-toggle-block.pf-c-accordion__toggle.pf-m-expanded { + --pf-c-accordion__toggle--before--BackgroundColor: transparent; +} + +.expandable-form__title { + font-weight: 600 !important; +} + +.expandable-form__help-block { + font-size: var(--pf-global--FontSize--sm); + white-space: pre-line; + color: #737373; + margin-bottom: 8px; + margin-top: 0; +} + +.console-form-group .pf-c-form__group { + margin: 2vh 0 2vh 0; +} + +.console-form-group .pf-c-form__group:first-child { + margin: 0 0 2vh 0; +} + +.console-form-group .pf-c-form__group:last-child { + margin: 2vh 0 0 0; +} + +.expandable-form__form-group.pf-c-form__group .pf-c-form__label-text { + color: #000000; +} + +.target-context-selector__wrapper { + padding: 0.3em 0.3em 0.3em 1em; + max-width: fit-content; +} + +.target-context-selector__search-input { + padding: 0.3em 1em 0.3em 1em; +} + +.linear-dot-spinner{ + width: 2em; + aspect-ratio: 4; + background: radial-gradient(circle closest-side, var(--pf-global--palette--blue-200) 90%,#0000) 0/calc(100%/3) 100% space; + clip-path: inset(0 100% 0 0); + animation: linear-dot-spinner-animate 1s steps(4) infinite; +} + +@keyframes linear-dot-spinner-animate {to {clip-path: inset(0 -34% 0 0)}} + +.target-context-selector__linear-dot-spinner { + width: 2em; +} + +/* TODO: Remove when css orders are fixed */ +.pf-c-skip-to-content { + position: absolute !important; +} diff --git a/src/app/assets/openjdk.svg b/src/app/assets/openjdk.svg new file mode 100644 index 000000000..db17c7545 --- /dev/null +++ b/src/app/assets/openjdk.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/routes.tsx b/src/app/routes.tsx index eed2fcfdc..71b1d2454 100644 --- a/src/app/routes.tsx +++ b/src/app/routes.tsx @@ -59,6 +59,8 @@ const QuickStarts = lazy(() => import('@app/QuickStarts/QuickStarts')); const Rules = lazy(() => import('@app/Rules/Rules')); const Settings = lazy(() => import('@app/Settings/Settings')); const SecurityPanel = lazy(() => import('@app/SecurityPanel/SecurityPanel')); +const Topology = lazy(() => import('@app/Topology/Topology')); +const CreateTarget = lazy(() => import('@app/Topology/Actions/CreateTarget')); let routeFocusTimer: number; const OVERVIEW = 'Overview'; @@ -82,6 +84,15 @@ export interface IAppRoute { } const routes: IAppRoute[] = [ + { + component: About, + exact: true, + label: 'About', + path: '/about', + title: 'About', + description: 'Get information, help, or support for Cryostat.', + navGroup: OVERVIEW, + }, { component: Dashboard, exact: true, @@ -100,13 +111,20 @@ const routes: IAppRoute[] = [ featureLevel: FeatureLevel.BETA, }, { - component: About, + component: Topology, exact: true, - label: 'About', - path: '/about', - title: 'About', - description: 'Get information, help, or support for Cryostat.', + label: 'Topology', + path: '/topology', + title: 'Topology', navGroup: OVERVIEW, + children: [ + { + component: CreateTarget, + exact: true, + path: '/topology/create-custom-target', + title: 'Create Custom Target', + }, + ], }, { component: Rules, diff --git a/src/app/utils/LocalStorage.ts b/src/app/utils/LocalStorage.ts index 6e7720865..3c0ccb683 100644 --- a/src/app/utils/LocalStorage.ts +++ b/src/app/utils/LocalStorage.ts @@ -44,6 +44,12 @@ export enum LocalStorageKey { JMX_CREDENTIAL_LOCATION, JMX_CREDENTIALS, TARGET, + TARGET_FAVORITES, + TOPOLOGY_SHOW_BANNER, + TOPOLOGY_GRAPH_POSITONS, + TOPOLOGY_NODE_POSITIONS, + TOPOLOGY_CONFIG, + TOPOLOGY_FILTERS, AUTO_REFRESH_ENABLED, AUTO_REFRESH_PERIOD, AUTO_REFRESH_UNITS, diff --git a/src/app/utils/useFeatureLevel.ts b/src/app/utils/useFeatureLevel.ts new file mode 100644 index 000000000..bb0766ca0 --- /dev/null +++ b/src/app/utils/useFeatureLevel.ts @@ -0,0 +1,54 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { ServiceContext } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import * as React from 'react'; +import { Subscription } from 'rxjs'; + +export function useFeatureLevel() { + const [featureLevel, setFeatureLevel] = React.useState(FeatureLevel.PRODUCTION); + const subRef = React.useRef(); + const services = React.useContext(ServiceContext); + + React.useEffect(() => { + subRef.current = services.settings.featureLevel().subscribe(setFeatureLevel); + return () => subRef.current && subRef.current.unsubscribe(); + }, [subRef, services.settings]); + + return featureLevel; +} diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 9f09545fc..53e11d68f 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -37,6 +37,7 @@ */ import { CardConfig } from '@app/Shared/Redux/Configurations/DashboardConfigSlicer'; +import _ from 'lodash'; const SECOND_MILLIS = 1000; const MINUTE_MILLIS = 60 * SECOND_MILLIS; @@ -140,3 +141,30 @@ export const calculateAnalysisTimer = (reportTime: number): AutomatedAnalysisTim interval: interval, } as AutomatedAnalysisTimerObject; }; + +export const splitWordsOnUppercase = (str: string, capitalizeFirst?: boolean): string[] => { + const words = str.split(/(?=[A-Z])/); + if (capitalizeFirst && words.length) { + const first = words[0]; + words[0] = first.substring(0, 1).toUpperCase() + first.slice(1); + } + return words; +}; + +const needUppercase = /(url|id|jvm)/i; + +export const getDisplayFieldName = (fieldName: string) => { + return splitWordsOnUppercase(fieldName) + .map((word) => { + if (needUppercase.test(word)) { + return _.upperCase(word); + } + return _.capitalize(word); + }) + .join(' '); +}; + +export const evaluateTargetWithExpr = (target: unknown, matchExpression: string) => { + const f = new Function('target', `return ${matchExpression}`); + return f(_.cloneDeep(target)); +}; diff --git a/src/test/Archives/__snapshots__/Archives.test.tsx.snap b/src/test/Archives/__snapshots__/Archives.test.tsx.snap index 3c5328b6b..cbe951692 100644 --- a/src/test/Archives/__snapshots__/Archives.test.tsx.snap +++ b/src/test/Archives/__snapshots__/Archives.test.tsx.snap @@ -59,12 +59,12 @@ exports[` renders correctly 1`] = ` role="presentation" >