diff --git a/locales/en/common.json b/locales/en/common.json index 1e82835fe..5b601005f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -20,6 +20,7 @@ "DESCRIPTION": "Description", "DEVELOPMENT": "DEVELOPMENT", "DOWNLOAD": "Download", + "EDIT": "Edit", "FILTER_NAME": "Name", "HELP": "Help", "HOUR": "Hour", @@ -54,7 +55,6 @@ "SUBMITTING": "Submitting", "SUGGESTED": "Suggested", "TEMPLATE": "Template", - "TEMPLATE_HELPER_TEXT_INVALID": "Template must be selected", "TIME": "Time", "UPLOAD": "Upload", "USER_SUBMITTED": "User-submitted", diff --git a/locales/en/public.json b/locales/en/public.json index 60ca3974e..cb3cc5db8 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -72,8 +72,12 @@ } }, "AutomatedAnalysisConfigForm": { - "ERROR_TITLE": "Error displaying recording configuration settings", + "CURRENT_CONFIG": "Current Configuration", "FORM_TITLE": "Profiling Recording Configuration", + "FORMATTED_TEMPLATE": "Name: {{template.name}}, Type: {{template.type}}", + "MAXIMUM_AGE": "Maximum age ({{unit}})", + "MAXIMUM_SIZE": "Maximum size ({{unit}})", + "SAVE_CHANGES": "Save changes", "TEMPLATE_HELPER_TEXT": "The Event Template to be applied to Automated Analysis recordings.", "TEMPLATE_INVALID_WARNING": "WARNING: Setting a Target Template as a default template type configuration may not apply to all Target JVMs if the JVMs do not support them." }, @@ -230,6 +234,9 @@ "ERROR_MESSAGE": "Reason: {{message}}", "RESOLVE_MESSAGE": "Reload the page and try again. If the error still persists, see the list of {{knownIssue}} or {{fileReport}}." }, + "ErrorView": { + "EVENT_TEMPLATES": "Error retrieving event templates" + }, "JvmDetailsCard": { "CARD_DESCRIPTION": "Display details about the selected target JVM.", "CARD_DESCRIPTION_FULL": "View information such as the connection URL, labels, and annotations belonging to the selected target JVM.", diff --git a/src/app/AppLayout/AppLayout.tsx b/src/app/AppLayout/AppLayout.tsx index c1b4f4ab3..09aa582e0 100644 --- a/src/app/AppLayout/AppLayout.tsx +++ b/src/app/AppLayout/AppLayout.tsx @@ -619,7 +619,12 @@ const AppLayout: React.FC = ({ children }) => { > {children} - + diff --git a/src/app/AppLayout/AuthModal.tsx b/src/app/AppLayout/AuthModal.tsx index 1230ea954..856c9379d 100644 --- a/src/app/AppLayout/AuthModal.tsx +++ b/src/app/AppLayout/AuthModal.tsx @@ -36,21 +36,22 @@ * SOFTWARE. */ 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 { useSubscriptions } from '@app/utils/useSubscriptions'; 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 { Observable, filter, first, map, mergeMap } from 'rxjs'; import { CredentialAuthForm } from './CredentialAuthForm'; export interface AuthModalProps { visible: boolean; onDismiss: () => void; onSave: () => void; + targetObs: Observable; } -export const AuthModal: React.FC = ({ onDismiss, onSave: onPropsSave, ...props }) => { +export const AuthModal: React.FC = ({ onDismiss, onSave: onPropsSave, targetObs, ...props }) => { const context = React.useContext(ServiceContext); const [loading, setLoading] = React.useState(false); const addSubscription = useSubscriptions(); @@ -59,8 +60,7 @@ export const AuthModal: React.FC = ({ onDismiss, onSave: onProps (username: string, password: string) => { setLoading(true); addSubscription( - context.target - .target() + targetObs .pipe( filter((target) => target !== NO_TARGET), first(), @@ -75,7 +75,7 @@ export const AuthModal: React.FC = ({ onDismiss, onSave: onProps }) ); }, - [addSubscription, context.authCredentials, context.target, setLoading, onPropsSave] + [addSubscription, context.authCredentials, targetObs, setLoading, onPropsSave] ); return ( diff --git a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx index 916d2cfca..f1c9e6d41 100644 --- a/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx +++ b/src/app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm.tsx @@ -35,152 +35,156 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import { AuthModal } from '@app/AppLayout/AuthModal'; import { EventTemplate } from '@app/CreateRecording/CreateRecording'; import { authFailMessage, ErrorView, isAuthFail } from '@app/ErrorView/ErrorView'; -import { LoadingView } from '@app/LoadingView/LoadingView'; import { SelectTemplateSelectorForm } from '@app/Shared/SelectTemplateSelectorForm'; import { AutomatedAnalysisRecordingConfig, automatedAnalysisRecordingName, + isHttpError, 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 { TargetSelect } from '@app/Shared/TargetSelect'; import { useSubscriptions } from '@app/utils/useSubscriptions'; import { + Button, + Card, + CardActions, + CardBody, + CardHeader, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, Form, FormGroup, FormSection, FormSelect, FormSelectOption, + Grid, + GridItem, HelperText, HelperTextItem, + Spinner, Split, SplitItem, + Stack, + StackItem, Text, TextInput, TextVariants, - ValidatedOptions, + Title, } from '@patternfly/react-core'; +import { CloseIcon, PencilAltIcon } from '@patternfly/react-icons'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { concatMap, filter, first, Observable, take } from 'rxjs'; +import { first, iif, of, ReplaySubject, take } from 'rxjs'; interface AutomatedAnalysisConfigFormProps { useTitle?: boolean; - targetObs?: Observable; + inlineForm?: boolean; +} + +interface FormConfig { + maxAge: number; + maxAgeUnits: number; + maxSize: number; + maxSizeUnits: number; + template: Pick, 'name' | 'type'>; } export const AutomatedAnalysisConfigForm: React.FC = ({ useTitle = false, - targetObs, + inlineForm = false, }) => { const context = React.useContext(ServiceContext); const addSubscription = useSubscriptions(); const { t } = useTranslation(); - const parseEventString = React.useMemo((): Pick, 'name' | 'type'> => { - const eventString = context.settings.automatedAnalysisRecordingConfig().template; - if (!eventString) { - return {}; - } - const templateName = eventString.split(',')[0].split('=')[1]; - const templateType = eventString.split(',')[1].split('=')[1]; - if (!(templateType === 'TARGET' || templateType === 'CUSTOM')) { - console.error(`Invalid template type ${templateType}`); - return {}; - } - return { - name: templateName, - type: templateType, - }; - }, [context.settings]); + const targetSubjectRef = React.useRef(new ReplaySubject(1)); + const targetSubject = targetSubjectRef.current; const [recordingConfig, setRecordingConfig] = React.useState( context.settings.automatedAnalysisRecordingConfig() ); + const [formConfig, setFormConfig] = React.useState({ + maxAge: context.settings.automatedAnalysisRecordingConfig().maxAge, + maxAgeUnits: 1, + maxSize: context.settings.automatedAnalysisRecordingConfig().maxSize, + maxSizeUnits: 1, + template: context.settings.automatedAnalysisRecordingConfig().template, + }); const [templates, setTemplates] = React.useState([]); - const [template, setTemplate] = React.useState, 'name' | 'type'>>(parseEventString); - const [maxAge, setMaxAge] = React.useState(recordingConfig.maxAge); - const [maxAgeUnits, setMaxAgeUnits] = React.useState(1); - const [maxSize, setMaxSize] = React.useState(recordingConfig.maxSize); - const [maxSizeUnits, setMaxSizeUnits] = React.useState(1); const [errorMessage, setErrorMessage] = React.useState(''); - const [isLoading, setIsLoading] = React.useState(true); - - const refreshTemplates = React.useCallback(() => { - setIsLoading(true); - addSubscription( - (targetObs ? targetObs : context.target.target()) - .pipe( - filter((target) => target !== NO_TARGET), - take(1), // first() will throw EmptyError - concatMap((target) => - context.api - .doGet(`targets/${encodeURIComponent(target.connectUrl)}/templates`) - .pipe(first()) - ) - ) - .subscribe({ - next: (templates) => { + const [isLoading, setIsLoading] = React.useState(false); + const [editing, setEditing] = React.useState(false); + const [isAuthModalOpen, setIsAuthModalOpen] = React.useState(false); + const refreshTemplates = React.useCallback( + (target: Target) => { + setIsLoading(true); + addSubscription( + iif( + () => { + return target === NO_TARGET; + }, + of([]), + context.api + .doGet( + `targets/${encodeURIComponent(target.connectUrl)}/templates`, + 'v1', + undefined, + undefined, + true + ) + .pipe(first()) + ).subscribe({ + next: (templates: EventTemplate[]) => { setErrorMessage(''); setTemplates(templates); - setTemplate((old) => { - const matched = templates.find((t) => t.name === old.name && t.type === t.type); - return matched ? { name: matched.name, type: matched.type } : {}; + setFormConfig((old) => { + const oldTemplate = old.template; + const matched = templates.find((t) => t.name === oldTemplate.name && t.type === t.type); + return { + ...old, + template: matched ? { name: matched.name, type: matched.type } : {}, + }; }); + setIsLoading(false); }, error: (err) => { - setErrorMessage(err.message); setTemplates([]); - setTemplate({}); + setIsLoading(false); + if (isHttpError(err) && err.httpResponse.status === 427) { + setErrorMessage(authFailMessage); + setIsAuthModalOpen(true); + } else { + setErrorMessage(err.message); + } }, }) - ); - }, [ - addSubscription, - context.target, - context.api, - setErrorMessage, - setTemplates, - setTemplate, - setIsLoading, - targetObs, - ]); - - React.useEffect(() => { - addSubscription( - context.target.authFailure().subscribe(() => { - setErrorMessage(authFailMessage); - setTemplates([]); - setIsLoading(false); - }) - ); - }, [addSubscription, context.target, setErrorMessage, setTemplates, setIsLoading]); + ); + }, + [addSubscription, context.api, setErrorMessage, setTemplates, setFormConfig, setIsLoading, setIsAuthModalOpen] + ); React.useEffect(() => { addSubscription( - (targetObs ? targetObs : context.target.target()).subscribe(() => { - refreshTemplates(); - setIsLoading(false); + targetSubject.subscribe((target) => { + refreshTemplates(target); }) ); - }, [addSubscription, targetObs, refreshTemplates, setIsLoading, context.target]); - - const getEventString = React.useCallback((templateName: string, templateType: string) => { - let str = ''; - if (templateName) { - str += `template=${templateName}`; - } - if (templateType) { - str += `,type=${templateType}`; - } - return str; - }, []); + }, [targetSubject, addSubscription, refreshTemplates, setIsLoading, editing]); const setAAConfig = React.useCallback( (config: AutomatedAnalysisRecordingConfig) => { + if (!config.template) { + return; + } setRecordingConfig(config); context.settings.setAutomatedAnalysisRecordingConfig(config); }, @@ -189,183 +193,316 @@ export const AutomatedAnalysisConfigForm: React.FC { - setMaxAge(Number(evt)); - setAAConfig({ ...recordingConfig, maxAge: Number(evt) * maxAgeUnits }); + setFormConfig((old) => { + return { + ...old, + maxAge: Number(evt), + }; + }); }, - [setMaxAge, setAAConfig, recordingConfig, maxAgeUnits] + [setFormConfig] ); const handleMaxAgeUnitChange = React.useCallback( (evt) => { - setMaxAgeUnits(Number(evt)); - setAAConfig({ ...recordingConfig, maxAge: Number(evt) * maxAge }); + setFormConfig((old) => { + return { + ...old, + maxAgeUnits: Number(evt), + }; + }); }, - [setMaxAgeUnits, setAAConfig, recordingConfig, maxAge] + [setFormConfig] ); const handleMaxSizeChange = React.useCallback( (evt) => { - setMaxSize(Number(evt)); - setAAConfig({ ...recordingConfig, maxSize: Number(evt) * maxSizeUnits }); + setFormConfig((old) => { + return { + ...old, + maxSize: Number(evt), + }; + }); }, - [setMaxSize, setAAConfig, recordingConfig, maxSizeUnits] + [setFormConfig] ); const handleMaxSizeUnitChange = React.useCallback( (evt) => { - setMaxSizeUnits(Number(evt)); - setAAConfig({ ...recordingConfig, maxSize: Number(evt) * maxSize }); + setFormConfig((old) => { + return { + ...old, + maxSizeUnits: Number(evt), + }; + }); }, - [setMaxSizeUnits, setAAConfig, recordingConfig, maxSize] + [setFormConfig] ); const handleTemplateChange = React.useCallback( (templateName?: string, templateType?: TemplateType) => { - setTemplate({ - name: templateName, - type: templateType, - }); - setAAConfig({ - ...recordingConfig, - template: getEventString(templateName || '', templateType || ''), + setFormConfig((old) => { + return { + ...old, + template: { + name: templateName, + type: templateType, + }, + }; }); }, - [setTemplate, recordingConfig, getEventString, setAAConfig] + [setFormConfig] ); + const handleSubmit = React.useCallback(() => { + const { template, maxSize, maxSizeUnits, maxAge, maxAgeUnits } = formConfig; + setAAConfig({ + template: template as Pick, + maxSize: maxSize * maxSizeUnits, + maxAge: maxAge * maxAgeUnits, + }); + setEditing(false); + }, [setAAConfig, setEditing, formConfig]); + const authRetry = React.useCallback(() => { - context.target.setAuthRetry(); - }, [context.target]); + setIsAuthModalOpen(true); + }, [setIsAuthModalOpen]); const selectedSpecifier = React.useMemo(() => { - const { name, type } = template; + const { name, type } = formConfig.template; if (name && type) { return `${name},${type}`; } return ''; - }, [template]); + }, [formConfig.template]); + + const targetSelect = React.useMemo(() => { + return editing && targetSubject.next(target)} />; + }, [editing, targetSubject]); + + const configData = React.useMemo(() => { + if (editing) { + return ( + + + + + {t('AutomatedAnalysisConfigForm.TEMPLATE_HELPER_TEXT')} + {formConfig.template.type == 'TARGET' && errorMessage === '' && ( + + + {t('AutomatedAnalysisConfigForm.TEMPLATE_INVALID_WARNING')} + + + )} + + + } + > + {isLoading ? ( + + ) : errorMessage != '' ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } + return ( + + + {t('TEMPLATE', { ns: 'common' })} + + {t('AutomatedAnalysisConfigForm.FORMATTED_TEMPLATE', { template: recordingConfig.template })} + + + + {t('AutomatedAnalysisConfigForm.MAXIMUM_SIZE', { unit: 'B' })} + {recordingConfig.maxSize} + + + {t('AutomatedAnalysisConfigForm.MAXIMUM_AGE', { unit: 's' })} + {recordingConfig.maxAge} + + + ); + }, [ + t, + isLoading, + editing, + recordingConfig, + formConfig, + templates, + selectedSpecifier, + handleTemplateChange, + handleMaxSizeChange, + handleMaxSizeUnitChange, + handleMaxAgeChange, + handleMaxAgeUnitChange, + errorMessage, + authRetry, + ]); + + const toggleEdit = React.useCallback(() => { + setEditing((edit) => !edit); + setFormConfig({ + template: recordingConfig.template, + maxSize: recordingConfig.maxSize, + maxAge: recordingConfig.maxAge, + maxSizeUnits: 1, + maxAgeUnits: 1, + }); + }, [setEditing, setFormConfig, recordingConfig]); + + const authModal = React.useMemo(() => { + return ( + setIsAuthModalOpen(false)} + onSave={() => { + setIsAuthModalOpen(false); + addSubscription( + targetSubject.pipe(take(1)).subscribe((target) => { + refreshTemplates(target); + }) + ); + }} + targetObs={targetSubject} + /> + ); + }, [addSubscription, isAuthModalOpen, setIsAuthModalOpen, refreshTemplates, targetSubject]); const formContent = React.useMemo( () => ( <> - - - - - - - - - - + + + + {t('AutomatedAnalysisConfigForm.CURRENT_CONFIG')} + + + + {editing && ( + + )} + + + + + + {targetSelect} + {configData} + + + + {authModal} ), - [ - t, - template, - templates, - selectedSpecifier, - maxSize, - maxSizeUnits, - maxAge, - maxAgeUnits, - handleMaxSizeChange, - handleMaxAgeChange, - handleMaxSizeUnitChange, - handleMaxAgeUnitChange, - handleTemplateChange, - ] + [t, handleSubmit, toggleEdit, formConfig.template, targetSelect, configData, editing, authModal] ); - if (errorMessage != '') { - return ( - - ); - } - if (isLoading) { - return ; - } - return useTitle ? ( -
- {formContent} -
- ) : ( - formContent + const formSection = React.useMemo( + () => + useTitle ? ( + {formContent} + ) : ( + formContent + ), + [useTitle, t, formContent] ); + + return inlineForm ? formSection :
{formSection}
; }; diff --git a/src/app/Settings/AutomatedAnalysisConfig.tsx b/src/app/Settings/AutomatedAnalysisConfig.tsx index e3013cb20..827d96f35 100644 --- a/src/app/Settings/AutomatedAnalysisConfig.tsx +++ b/src/app/Settings/AutomatedAnalysisConfig.tsx @@ -37,25 +37,11 @@ */ import { AutomatedAnalysisConfigForm } from '@app/Dashboard/AutomatedAnalysis/AutomatedAnalysisConfigForm'; -import { NO_TARGET } from '@app/Shared/Services/Target.service'; -import { TargetSelect } from '@app/Shared/TargetSelect'; -import { Stack, StackItem } from '@patternfly/react-core'; import * as React from 'react'; -import { of } from 'rxjs'; import { SettingTab, UserSetting } from './SettingsUtils'; const Component = () => { - const [target, setTarget] = React.useState(NO_TARGET); - const _targetAsObs = React.useMemo(() => of(target), [target]); - - return ( - - - - - - - ); + return ; }; export const AutomatedAnalysisConfig: UserSetting = { diff --git a/src/app/Shared/Services/Api.service.tsx b/src/app/Shared/Services/Api.service.tsx index 369c1f2ca..eb2606515 100644 --- a/src/app/Shared/Services/Api.service.tsx +++ b/src/app/Shared/Services/Api.service.tsx @@ -1823,13 +1823,16 @@ export interface ActiveRecordingFilterInput { export const automatedAnalysisRecordingName = 'automated-analysis'; export interface AutomatedAnalysisRecordingConfig { - template: string; + template: Pick; maxSize: number; maxAge: number; } export const defaultAutomatedAnalysisRecordingConfig: AutomatedAnalysisRecordingConfig = { - template: 'template=Continuous,type=TARGET', + template: { + name: 'Continuous', + type: 'TARGET', + }, maxSize: 1048576, maxAge: 0, }; diff --git a/src/app/Shared/Services/Settings.service.tsx b/src/app/Shared/Services/Settings.service.tsx index 89c52f93c..f3d7e0792 100644 --- a/src/app/Shared/Services/Settings.service.tsx +++ b/src/app/Shared/Services/Settings.service.tsx @@ -62,7 +62,7 @@ export const automatedAnalysisConfigToRecordingAttributes = ( ): RecordingAttributes => { return { name: automatedAnalysisRecordingName, - events: config.template, + events: `template=${config.template.name},type=${config.template.type}`, duration: undefined, archiveOnStop: false, options: { diff --git a/src/app/Shared/TargetSelect.tsx b/src/app/Shared/TargetSelect.tsx index b33742910..5f074b1b0 100644 --- a/src/app/Shared/TargetSelect.tsx +++ b/src/app/Shared/TargetSelect.tsx @@ -48,7 +48,6 @@ import { CardExpandableContent, CardHeader, CardTitle, - Divider, Select, SelectGroup, SelectOption, @@ -129,10 +128,7 @@ export const TargetSelect: React.FunctionComponent = ({ onSel }, [handleSelect, targets, selected, firstLoadRef]); const selectOptions = React.useMemo(() => { - let options = [ - , - , - ]; + let options = [] as JSX.Element[]; const groupNames = new Set(); targets.forEach((t) => groupNames.add(t.annotations?.cryostat['REALM'] || 'Others')); @@ -192,7 +188,7 @@ export const TargetSelect: React.FunctionComponent = ({ onSel ); return ( - + Target JVM @@ -202,6 +198,7 @@ export const TargetSelect: React.FunctionComponent = ({ onSel <>