diff --git a/src/app/AppLayout/AuthModal.tsx b/src/app/AppLayout/AuthModal.tsx index c73dbb29b..31b132841 100644 --- a/src/app/AppLayout/AuthModal.tsx +++ b/src/app/AppLayout/AuthModal.tsx @@ -43,7 +43,7 @@ import { Modal, ModalVariant, Text } from '@patternfly/react-core'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { filter, first, map, mergeMap } from 'rxjs'; -import { JmxAuthForm } from './JmxAuthForm'; +import { CredentialAuthForm } from './CredentialAuthForm'; export interface AuthModalProps { visible: boolean; @@ -103,7 +103,7 @@ export const AuthModal: React.FC = ({ onDismiss, onSave: onProps } > - + ); }; diff --git a/src/app/AppLayout/JmxAuthForm.tsx b/src/app/AppLayout/CredentialAuthForm.tsx similarity index 96% rename from src/app/AppLayout/JmxAuthForm.tsx rename to src/app/AppLayout/CredentialAuthForm.tsx index 45430abc4..99262694e 100644 --- a/src/app/AppLayout/JmxAuthForm.tsx +++ b/src/app/AppLayout/CredentialAuthForm.tsx @@ -39,7 +39,7 @@ import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; import { ActionGroup, Button, Form, FormGroup, TextInput } from '@patternfly/react-core'; import * as React from 'react'; -export interface JmxAuthFormProps { +export interface CredentialAuthFormProps { onDismiss: () => void; onSave: (username: string, password: string) => void; focus?: boolean; @@ -47,7 +47,7 @@ export interface JmxAuthFormProps { children?: React.ReactNode; } -export const JmxAuthForm: React.FC = ({ onDismiss, onSave, ...props }) => { +export const CredentialAuthForm: React.FC = ({ onDismiss, onSave, ...props }) => { const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); diff --git a/src/app/BreadcrumbPage/BreadcrumbPage.tsx b/src/app/BreadcrumbPage/BreadcrumbPage.tsx index bb683ed51..b166819dd 100644 --- a/src/app/BreadcrumbPage/BreadcrumbPage.tsx +++ b/src/app/BreadcrumbPage/BreadcrumbPage.tsx @@ -56,14 +56,9 @@ export const BreadcrumbPage: React.FC = (props) => { {(props.breadcrumbs || []).map(({ title, path }) => ( - ( - <> - {title} - - )} - > + + {title} + ))} {props.pageTitle} diff --git a/src/app/Modal/DeleteWarningUtils.tsx b/src/app/Modal/DeleteWarningUtils.tsx index 214a5689e..e5c8b1281 100644 --- a/src/app/Modal/DeleteWarningUtils.tsx +++ b/src/app/Modal/DeleteWarningUtils.tsx @@ -43,7 +43,7 @@ export enum DeleteOrDisableWarningType { DeleteEventTemplates = 'DeleteEventTemplates', DeleteProbeTemplates = 'DeleteProbeTemplates', DeleteActiveProbes = 'DeleteActiveProbes', - DeleteJMXCredentials = 'DeleteJMXCredentials', + DeleteCredentials = 'DeleteCredentials', DeleteCustomTargets = 'DeleteCustomTargets', DeleteDashboardLayout = 'DeleteDashboardLayout', } @@ -112,12 +112,12 @@ export const DeleteActiveProbes: DeleteOrDisableWarning = { ariaLabel: 'Active Probes remove warning', }; -export const DeleteJMXCredentials: DeleteOrDisableWarning = { - id: DeleteOrDisableWarningType.DeleteJMXCredentials, - title: 'Permanently delete JMX Credentials?', - label: 'Delete JMX Credentials', +export const DeleteCredentials: DeleteOrDisableWarning = { + id: DeleteOrDisableWarningType.DeleteCredentials, + title: 'Permanently delete Credentials?', + label: 'Delete Credentials', description: `Credential data for this target will be lost.`, - ariaLabel: 'JMX Credentials delete warning', + ariaLabel: 'Credentials delete warning', }; export const DeleteCustomTargets: DeleteOrDisableWarning = { @@ -144,7 +144,7 @@ export const DeleteWarningKinds: DeleteOrDisableWarning[] = [ DeleteEventTemplates, DeleteProbeTemplates, DeleteActiveProbes, - DeleteJMXCredentials, + DeleteCredentials, DeleteCustomTargets, DeleteDashboardLayout, ]; diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index 631500859..a68819ef1 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -37,15 +37,17 @@ */ import { BreadcrumbPage, BreadcrumbTrail } from '@app/BreadcrumbPage/BreadcrumbPage'; import { EventTemplate } from '@app/CreateRecording/CreateRecording'; -import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; import { NotificationsContext } from '@app/Notifications/Notifications'; -import { MatchExpressionEvaluator } from '@app/Shared/MatchExpressionEvaluator'; +import { MatchExpressionHint } from '@app/Shared/MatchExpression/MatchExpressionHint'; +import { MatchExpressionVisualizer } from '@app/Shared/MatchExpression/MatchExpressionVisualizer'; import { LoadingPropsType } from '@app/Shared/ProgressIndicator'; import { TemplateType } from '@app/Shared/Services/Api.service'; import { ServiceContext } from '@app/Shared/Services/Services'; -import { NO_TARGET, Target } from '@app/Shared/Services/Target.service'; +import { Target } from '@app/Shared/Services/Target.service'; import { SelectTemplateSelectorForm } from '@app/TemplateSelector/SelectTemplateSelectorForm'; +import { SearchExprService, SearchExprServiceContext } from '@app/Topology/Shared/utils'; import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { evaluateTargetWithExpr } from '@app/utils/utils'; import { ActionGroup, Button, @@ -57,34 +59,42 @@ import { FormSelectOption, Grid, GridItem, + Popover, Split, SplitItem, Switch, Text, + TextArea, TextInput, TextVariants, ValidatedOptions, } from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; +import _ from 'lodash'; import * as React from 'react'; import { useHistory, withRouter } from 'react-router-dom'; -import { BehaviorSubject, iif } from 'rxjs'; -import { first, map, mergeMap } from 'rxjs/operators'; +import { forkJoin, iif, of, Subject } from 'rxjs'; +import { catchError, debounceTime, map, switchMap } from 'rxjs/operators'; import { Rule } from './Rules'; // FIXME check if this is correct/matches backend name validation export const RuleNamePattern = /^[\w_]+$/; -const Comp: React.FC = () => { +interface CreateRuleFormProps {} + +const CreateRuleForm: React.FC = ({ ...props }) => { const context = React.useContext(ServiceContext); const notifications = React.useContext(NotificationsContext); const history = useHistory(); + // Note: Do not use useSearchExpression(). This causes the cursor to jump to the end due to async updates. + const matchExprService = React.useContext(SearchExprServiceContext); + const [matchExpression, setMatchExpression] = React.useState(''); const addSubscription = useSubscriptions(); const [name, setName] = React.useState(''); const [nameValid, setNameValid] = React.useState(ValidatedOptions.default); const [description, setDescription] = React.useState(''); const [enabled, setEnabled] = React.useState(true); - const [matchExpression, setMatchExpression] = React.useState(''); const [matchExpressionValid, setMatchExpressionValid] = React.useState(ValidatedOptions.default); const [templates, setTemplates] = React.useState([] as EventTemplate[]); const [templateName, setTemplateName] = React.useState(undefined); @@ -98,17 +108,11 @@ const Comp: React.FC = () => { const [initialDelay, setInitialDelay] = React.useState(0); const [initialDelayUnits, setInitialDelayUnits] = React.useState(1); const [preservedArchives, setPreservedArchives] = React.useState(0); - const [errorMessage, setErrorMessage] = React.useState(''); const [loading, setLoading] = React.useState(false); + const [targets, setTargets] = React.useState([]); - const targetSubject = React.useRef(new BehaviorSubject(NO_TARGET)).current; - - const handleTargetChange = React.useCallback( - (target: Target) => { - targetSubject.next(target); - }, - [targetSubject] - ); + const matchedTargetsRef = React.useRef(new Subject()); + const matchedTargets = matchedTargetsRef.current; const handleNameChange = React.useCallback( (name) => { @@ -176,8 +180,6 @@ const Comp: React.FC = () => { [setPreservedArchives] ); - const handleError = React.useCallback((error) => setErrorMessage(error.message), [setErrorMessage]); - const handleSubmit = React.useCallback((): void => { setLoading(true); const notificationMessages: string[] = []; @@ -232,60 +234,69 @@ const Comp: React.FC = () => { maxSizeUnits, ]); - const handleTemplateList = React.useCallback( - (templates: EventTemplate[]) => { - setTemplates(templates); - setErrorMessage(''); - }, - [setTemplates, setErrorMessage] - ); - - const refreshTemplateList = React.useCallback(() => { + React.useEffect(() => { addSubscription( - targetSubject + matchedTargets .pipe( - mergeMap((target) => + debounceTime(100), + switchMap((targets) => iif( - () => target !== NO_TARGET, - context.api.doGet(`targets/${encodeURIComponent(target.connectUrl)}/templates`), - context.api - .doGet(`targets/localhost:0/templates`) - .pipe( - map((templates) => - templates.filter((template) => template.provider !== 'Cryostat' || template.name !== 'Cryostat') - ) + () => targets.length > 0, + forkJoin( + targets.map((t) => + context.api + .doGet( + `targets/${encodeURIComponent(t.connectUrl)}/templates`, + 'v1', + undefined, + true, + true + ) + .pipe( + catchError((_) => of([])) // Fail silently + ) ) + ).pipe( + map((allTemplates) => { + const allFiltered = allTemplates.filter((ts) => ts.length); + return allFiltered.length + ? allFiltered.reduce((acc, curr) => _.intersectionWith(acc, curr, _.isEqual)) + : []; + }) + ), + of([]) ) - ), - first() + ) ) - .subscribe({ - next: handleTemplateList, - error: handleError, - }) + .subscribe(setTemplates) ); - }, [addSubscription, context.api, targetSubject, handleError, handleTemplateList]); + }, [addSubscription, context.api, matchedTargets]); React.useEffect(() => { - addSubscription(targetSubject.subscribe(refreshTemplateList)); - }, [addSubscription, targetSubject, refreshTemplateList]); + addSubscription(context.targets.targets().subscribe(setTargets)); + }, [addSubscription, context.targets, setTargets]); - // Note: authFailure can be reused - // since no operation on global target selection is done here. React.useEffect(() => { - addSubscription( - context.target.authFailure().subscribe(() => { - setErrorMessage(authFailMessage); - }) - ); - }, [addSubscription, context.target, setErrorMessage]); - - const breadcrumbs: BreadcrumbTrail[] = [ - { - title: 'Automated Rules', - path: '/rules', - }, - ]; + // Set validations + let validation: ValidatedOptions = ValidatedOptions.default; + let matches: Target[] = []; + if (matchExpression !== '' && targets.length > 0) { + try { + matches = targets.filter((t) => { + const res = evaluateTargetWithExpr(t, matchExpression); + if (typeof res === 'boolean') { + return res; + } + throw new Error('Invalid match expression'); + }); + validation = matches.length ? ValidatedOptions.success : ValidatedOptions.warning; + } catch (err) { + validation = ValidatedOptions.error; + } + } + setMatchExpressionValid(validation); + matchedTargets.next(matches); + }, [matchExpression, targets, matchedTargets, setMatchExpressionValid, setTemplateName]); const createButtonLoadingProps = React.useMemo( () => @@ -304,305 +315,338 @@ const Comp: React.FC = () => { return ''; }, [templateName, templateType]); - const authRetry = React.useCallback(() => { - context.target.setAuthRetry(); - }, [context.target]); + return ( +
+ + Automated Rules are configurations that instruct Cryostat to create JDK Flight Recordings on matching target JVM + applications. Each Automated Rule specifies parameters for which Event Template to use, how much data should be + kept in the application recording buffer, and how frequently Cryostat should copy the application recording + buffer into Cryostat's own archived storage. + + + + + +