[Logs UI] Support Kibana index patterns in the Logs UI settings UI (#94849)

This enhances the Logs UI settings screen to support both the legacy configuration style with index name patterns as well as the new style based on Kibana index patterns.
This commit is contained in:
Felix Stürmer 2021-04-20 00:04:25 +02:00 committed by GitHub
parent 83824ab13a
commit 89dd4b6eec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1745 additions and 667 deletions

View file

@ -53,18 +53,21 @@ export const logSourceColumnConfigurationRT = rt.union([
export type LogSourceColumnConfiguration = rt.TypeOf<typeof logSourceColumnConfigurationRT>;
// Kibana index pattern
const logIndexPatternReferenceRT = rt.type({
export const logIndexPatternReferenceRT = rt.type({
type: rt.literal('index_pattern'),
indexPatternId: rt.string,
});
export type LogIndexPatternReference = rt.TypeOf<typeof logIndexPatternReferenceRT>;
// Legacy support
const logIndexNameReferenceRT = rt.type({
export const logIndexNameReferenceRT = rt.type({
type: rt.literal('index_name'),
indexName: rt.string,
});
export type LogIndexNameReference = rt.TypeOf<typeof logIndexNameReferenceRT>;
export const logIndexReferenceRT = rt.union([logIndexPatternReferenceRT, logIndexNameReferenceRT]);
export type LogIndexReference = rt.TypeOf<typeof logIndexReferenceRT>;
export const logSourceConfigurationPropertiesRT = rt.strict({
name: rt.string,

View file

@ -45,5 +45,7 @@ interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}
type DeepPartialObject<T> = { [P in keyof T]+?: DeepPartial<T[P]> };
export type ObjectValues<T> = Array<T[keyof T]>;
export type ObjectEntry<T> = [keyof T, T[keyof T]];
export type ObjectEntries<T> = Array<ObjectEntry<T>>;

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { from, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { CoreStart } from '../../../../../src/core/public';
import { FieldSpec } from '../../../../../src/plugins/data/common';
import {
IIndexPattern,
IndexPattern,
IndexPatternField,
IndexPatternsContract,
} from '../../../../../src/plugins/data/public';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { Pick2 } from '../../common/utility_types';
type MockIndexPattern = Pick<
IndexPattern,
'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName'
>;
export type MockIndexPatternSpec = Pick<
IIndexPattern,
'id' | 'title' | 'type' | 'timeFieldName'
> & {
fields: FieldSpec[];
};
export const MockIndexPatternsKibanaContextProvider: React.FC<{
asyncDelay: number;
mockIndexPatterns: MockIndexPatternSpec[];
}> = ({ asyncDelay, children, mockIndexPatterns }) => {
const indexPatterns = useMemo(
() =>
createIndexPatternsMock(
asyncDelay,
mockIndexPatterns.map(({ id, title, type = undefined, fields, timeFieldName }) => {
const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec));
return {
id,
title,
type,
getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName),
isTimeBased: () => timeFieldName != null,
getFieldByName: (fieldName) =>
indexPatternFields.find(({ name }) => name === fieldName),
};
})
),
[asyncDelay, mockIndexPatterns]
);
const core = useMemo<Pick2<CoreStart, 'application', 'getUrlForApp'>>(
() => ({
application: {
getUrlForApp: () => '',
},
}),
[]
);
return (
<KibanaContextProvider services={{ ...core, data: { indexPatterns } }}>
{children}
</KibanaContextProvider>
);
};
const createIndexPatternsMock = (
asyncDelay: number,
indexPatterns: MockIndexPattern[]
): {
getIdsWithTitle: IndexPatternsContract['getIdsWithTitle'];
get: (...args: Parameters<IndexPatternsContract['get']>) => Promise<MockIndexPattern>;
} => {
return {
async getIdsWithTitle(_refresh?: boolean) {
const indexPatterns$ = of(
indexPatterns.map(({ id = 'unknown_id', title }) => ({ id, title }))
);
return await indexPatterns$.pipe(delay(asyncDelay)).toPromise();
},
async get(indexPatternId: string) {
const indexPatterns$ = from(
indexPatterns.filter((indexPattern) => indexPattern.id === indexPatternId)
);
return await indexPatterns$.pipe(delay(asyncDelay)).toPromise();
},
};
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState } from 'react';
import { useTrackedPromise } from '../utils/use_tracked_promise';
import { useKibanaContextForPlugin } from './use_kibana';
export const useKibanaIndexPatternService = () => {
const {
services: {
data: { indexPatterns },
},
} = useKibanaContextForPlugin();
return indexPatterns;
};
interface IndexPatternDescriptor {
id: string;
title: string;
}
export const useKibanaIndexPatternTitles = () => {
const indexPatterns = useKibanaIndexPatternService();
const [indexPatternTitles, setIndexPatternTitles] = useState<IndexPatternDescriptor[]>([]);
const [indexPatternTitlesRequest, fetchIndexPatternTitles] = useTrackedPromise(
{
createPromise: () => indexPatterns.getIdsWithTitle(true),
onResolve: setIndexPatternTitles,
},
[indexPatterns]
);
return {
fetchIndexPatternTitles,
indexPatternTitles,
latestIndexPatternTitlesRequest: indexPatternTitlesRequest,
};
};

View file

@ -10,7 +10,6 @@ import {
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiLink,
EuiSpacer,
@ -18,27 +17,29 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { InputFieldProps } from '../../../components/source_configuration/input_fields';
import React, { useMemo } from 'react';
import { FormElement } from './form_elements';
import { getFormRowProps, getStringInputFieldProps } from './form_field_props';
import { FormValidationError } from './validation_errors';
interface FieldsConfigurationPanelProps {
isLoading: boolean;
readOnly: boolean;
tiebreakerFieldProps: InputFieldProps;
timestampFieldProps: InputFieldProps;
isReadOnly: boolean;
tiebreakerFieldFormElement: FormElement<string, FormValidationError>;
timestampFieldFormElement: FormElement<string, FormValidationError>;
}
export const FieldsConfigurationPanel = ({
isLoading,
readOnly,
tiebreakerFieldProps,
timestampFieldProps,
isReadOnly,
tiebreakerFieldFormElement,
timestampFieldFormElement,
}: FieldsConfigurationPanelProps) => {
const isTimestampValueDefault = timestampFieldProps.value === '@timestamp';
const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc';
const isTimestampValueDefault = timestampFieldFormElement.value === '@timestamp';
const isTiebreakerValueDefault = tiebreakerFieldFormElement.value === '_doc';
return (
<EuiForm>
<>
<EuiTitle size="s">
<h3>
<FormattedMessage
@ -101,7 +102,6 @@ export const FieldsConfigurationPanel = ({
}
>
<EuiFormRow
error={timestampFieldProps.error}
fullWidth
helpText={
<FormattedMessage
@ -112,20 +112,24 @@ export const FieldsConfigurationPanel = ({
}}
/>
}
isInvalid={timestampFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.timestampFieldLabel"
defaultMessage="Timestamp"
/>
}
{...useMemo(() => getFormRowProps(timestampFieldFormElement), [
timestampFieldFormElement,
])}
>
<EuiFieldText
fullWidth
disabled={isLoading || isTimestampValueDefault}
readOnly={readOnly}
readOnly={isReadOnly}
isLoading={isLoading}
{...timestampFieldProps}
{...useMemo(() => getStringInputFieldProps(timestampFieldFormElement), [
timestampFieldFormElement,
])}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -146,7 +150,6 @@ export const FieldsConfigurationPanel = ({
}
>
<EuiFormRow
error={tiebreakerFieldProps.error}
fullWidth
helpText={
<FormattedMessage
@ -157,23 +160,27 @@ export const FieldsConfigurationPanel = ({
}}
/>
}
isInvalid={tiebreakerFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.tiebreakerFieldLabel"
defaultMessage="Tiebreaker"
/>
}
{...useMemo(() => getFormRowProps(tiebreakerFieldFormElement), [
tiebreakerFieldFormElement,
])}
>
<EuiFieldText
fullWidth
disabled={isLoading || isTiebreakerValueDefault}
readOnly={readOnly}
readOnly={isReadOnly}
isLoading={isLoading}
{...tiebreakerFieldProps}
{...useMemo(() => getStringInputFieldProps(tiebreakerFieldFormElement), [
tiebreakerFieldFormElement,
])}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiForm>
</>
);
};

View file

@ -0,0 +1,243 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import equal from 'fast-deep-equal';
import { useCallback, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { ObjectEntries } from '../../../../common/utility_types';
import { ChildFormValidationError, GenericValidationError } from './validation_errors';
const unsetValue = Symbol('unset form value');
type ValueUpdater<Value> = (updater: (previousValue: Value) => Value) => void;
export interface FormElement<Value, InvalidReason> {
initialValue: Value;
isDirty: boolean;
resetValue: () => void;
updateValue: ValueUpdater<Value>;
validity: FormElementValidity<InvalidReason | GenericValidationError>;
value: Value;
}
type FormElementMap<FormValues extends {}> = {
[formElementName in keyof FormValues]: FormElement<FormValues[formElementName], any>;
};
export interface CompositeFormElement<CompositeValue extends {}, InvalidReason>
extends FormElement<CompositeValue, InvalidReason | ChildFormValidationError> {
childFormElements: FormElementMap<CompositeValue>;
}
export type FormElementValidity<InvalidReason> =
| { validity: 'valid' }
| { validity: 'invalid'; reasons: InvalidReason[] }
| { validity: 'pending' };
export const useFormElement = <Value, InvalidReason>({
initialValue,
validate,
}: {
initialValue: Value;
validate?: (value: Value) => Promise<InvalidReason[]>;
}): FormElement<Value, InvalidReason> => {
const [changedValue, setChangedValue] = useState<Value | typeof unsetValue>(unsetValue);
const value = changedValue !== unsetValue ? changedValue : initialValue;
const updateValue = useCallback<ValueUpdater<Value>>(
(updater) =>
setChangedValue((previousValue) =>
previousValue === unsetValue ? updater(initialValue) : updater(previousValue)
),
[initialValue]
);
const resetValue = useCallback(() => setChangedValue(unsetValue), []);
const isDirty = useMemo(() => !equal(value, initialValue), [value, initialValue]);
const validity = useValidity(value, validate);
return useMemo(
() => ({
initialValue,
isDirty,
resetValue,
updateValue,
validity,
value,
}),
[initialValue, isDirty, resetValue, updateValue, validity, value]
);
};
export const useCompositeFormElement = <FormValues extends {}, InvalidReason>({
childFormElements,
validate,
}: {
childFormElements: FormElementMap<FormValues>;
validate?: (values: FormValues) => Promise<InvalidReason[]>;
}): CompositeFormElement<FormValues, InvalidReason> => {
const childFormElementEntries = useMemo(
() => Object.entries(childFormElements) as ObjectEntries<typeof childFormElements>,
// eslint-disable-next-line react-hooks/exhaustive-deps
Object.entries(childFormElements).flat()
);
const value = useMemo(
() =>
childFormElementEntries.reduce<FormValues>(
(accumulatedFormValues, [formElementName, formElement]) => ({
...accumulatedFormValues,
[formElementName]: formElement.value,
}),
{} as FormValues
),
[childFormElementEntries]
);
const updateValue = useCallback(
(updater: (previousValues: FormValues) => FormValues) => {
const newValues = updater(value);
childFormElementEntries.forEach(([formElementName, formElement]) =>
formElement.updateValue(() => newValues[formElementName])
);
},
[childFormElementEntries, value]
);
const isDirty = useMemo(
() => childFormElementEntries.some(([, formElement]) => formElement.isDirty),
[childFormElementEntries]
);
const formValidity = useValidity(value, validate);
const childFormElementsValidity = useMemo<
FormElementValidity<InvalidReason | ChildFormValidationError>
>(() => {
if (
childFormElementEntries.some(([, formElement]) => formElement.validity.validity === 'invalid')
) {
return {
validity: 'invalid',
reasons: [{ type: 'child' }],
};
} else if (
childFormElementEntries.some(([, formElement]) => formElement.validity.validity === 'pending')
) {
return {
validity: 'pending',
};
} else {
return {
validity: 'valid',
};
}
}, [childFormElementEntries]);
const validity = useMemo(() => getCombinedValidity(formValidity, childFormElementsValidity), [
formValidity,
childFormElementsValidity,
]);
const resetValue = useCallback(() => {
childFormElementEntries.forEach(([, formElement]) => formElement.resetValue());
}, [childFormElementEntries]);
const initialValue = useMemo(
() =>
childFormElementEntries.reduce<FormValues>(
(accumulatedFormValues, [formElementName, formElement]) => ({
...accumulatedFormValues,
[formElementName]: formElement.initialValue,
}),
{} as FormValues
),
[childFormElementEntries]
);
return useMemo(
() => ({
childFormElements,
initialValue,
isDirty,
resetValue,
updateValue,
validity,
value,
}),
[childFormElements, initialValue, isDirty, resetValue, updateValue, validity, value]
);
};
const useValidity = <Value, InvalidReason>(
value: Value,
validate?: (value: Value) => Promise<InvalidReason[]>
) => {
const validationState = useAsync(() => validate?.(value) ?? Promise.resolve([]), [
validate,
value,
]);
const validity = useMemo<FormElementValidity<InvalidReason | GenericValidationError>>(() => {
if (validationState.loading) {
return { validity: 'pending' as const };
} else if (validationState.error != null) {
return {
validity: 'invalid' as const,
reasons: [
{
type: 'generic' as const,
message: `${validationState.error}`,
},
],
};
} else if (validationState.value && validationState.value.length > 0) {
return {
validity: 'invalid' as const,
reasons: validationState.value,
};
} else {
return {
validity: 'valid' as const,
};
}
}, [validationState.error, validationState.loading, validationState.value]);
return validity;
};
export const getCombinedValidity = <FirstInvalidReason, SecondInvalidReason>(
first: FormElementValidity<FirstInvalidReason>,
second: FormElementValidity<SecondInvalidReason>
): FormElementValidity<FirstInvalidReason | SecondInvalidReason> => {
if (first.validity === 'invalid' || second.validity === 'invalid') {
return {
validity: 'invalid',
reasons: [
...(first.validity === 'invalid' ? first.reasons : []),
...(second.validity === 'invalid' ? second.reasons : []),
],
};
} else if (first.validity === 'pending' || second.validity === 'pending') {
return {
validity: 'pending',
};
} else {
return {
validity: 'valid',
};
}
};
export const isFormElementForType = <Value extends any>(
isValue: (value: any) => value is Value
) => <InvalidReason extends unknown>(
formElement: FormElement<any, InvalidReason>
): formElement is FormElement<Value, InvalidReason> => isValue(formElement.value);

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormElement } from './form_elements';
import { LogSourceConfigurationFormError } from './source_configuration_form_errors';
import { FormValidationError } from './validation_errors';
export const getFormRowProps = (formElement: FormElement<any, FormValidationError>) => ({
error:
formElement.validity.validity === 'invalid'
? formElement.validity.reasons.map((error) => (
<LogSourceConfigurationFormError error={error} />
))
: [],
isInvalid: formElement.validity.validity === 'invalid',
});
export const getInputFieldProps = <Value extends unknown>(
decodeInputValue: (value: string) => Value,
encodeInputValue: (value: Value) => string
) => (formElement: FormElement<Value, any>) => ({
isInvalid: formElement.validity.validity === 'invalid',
onChange: (evt: React.ChangeEvent<HTMLInputElement>) => {
const newValue = evt.currentTarget.value;
formElement.updateValue(() => decodeInputValue(newValue));
},
value: encodeInputValue(formElement.value),
});
export const getStringInputFieldProps = getInputFieldProps<string>(
(value) => `${value}`,
(value) => value
);

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiCallOut,
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiFormRow,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { useTrackPageview } from '../../../../../observability/public';
import { LogIndexNameReference } from '../../../../common/log_sources';
import { FormElement } from './form_elements';
import { getFormRowProps, getInputFieldProps } from './form_field_props';
import { FormValidationError } from './validation_errors';
export const IndexNamesConfigurationPanel: React.FC<{
isLoading: boolean;
isReadOnly: boolean;
indexNamesFormElement: FormElement<LogIndexNameReference, FormValidationError>;
onSwitchToIndexPatternReference: () => void;
}> = ({ isLoading, isReadOnly, indexNamesFormElement, onSwitchToIndexPatternReference }) => {
useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_name' });
useTrackPageview({
app: 'infra_logs',
path: 'log_source_configuration_index_name',
delay: 15000,
});
return (
<>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.infra.sourceConfiguration.indicesSectionTitle"
defaultMessage="Indices"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiCallOut title={deprecationCalloutTitle} color="warning" iconType="alert">
<FormattedMessage
tagName="p"
id="xpack.infra.logSourceConfiguration.indexNameReferenceDeprecationDescription"
defaultMessage="Referring to Elasticsearch indices directly is a deprecated way of configuring a log source. Instead, log source now integrate with Kibana index patterns to configure the used indices."
/>
<EuiButton color="warning" onClick={onSwitchToIndexPatternReference}>
<FormattedMessage
id="xpack.infra.logSourceConfiguration.switchToIndexPatternReferenceButtonLabel"
defaultMessage="Use Kibana index patterns"
/>
</EuiButton>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesTitle"
defaultMessage="Log indices"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesDescription"
defaultMessage="Index pattern for matching indices that contain log data"
/>
}
>
<EuiFormRow
fullWidth
helpText={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesRecommendedValue"
defaultMessage="The recommended value is {defaultValue}"
values={{
defaultValue: <EuiCode>logs-*,filebeat-*</EuiCode>,
}}
/>
}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesLabel"
defaultMessage="Log indices"
/>
}
{...getFormRowProps(indexNamesFormElement)}
>
<EuiFieldText
data-test-subj="logIndicesInput"
fullWidth
disabled={isLoading}
isLoading={isLoading}
readOnly={isReadOnly}
{...getIndexNamesInputFieldProps(indexNamesFormElement)}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</>
);
};
const getIndexNamesInputFieldProps = getInputFieldProps<LogIndexNameReference>(
(value) => ({
type: 'index_name',
indexName: value,
}),
({ indexName }) => indexName
);
const deprecationCalloutTitle = i18n.translate(
'xpack.infra.logSourceConfiguration.indexNameReferenceDeprecationTitle',
{
defaultMessage: 'Deprecated configuration option',
}
);

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo } from 'react';
import { useTrackPageview } from '../../../../../observability/public';
import { LogIndexPatternReference } from '../../../../common/log_sources';
import { useLinkProps } from '../../../hooks/use_link_props';
import { FormElement } from './form_elements';
import { getFormRowProps } from './form_field_props';
import { IndexPatternSelector } from './index_pattern_selector';
import { FormValidationError } from './validation_errors';
export const IndexPatternConfigurationPanel: React.FC<{
isLoading: boolean;
isReadOnly: boolean;
indexPatternFormElement: FormElement<LogIndexPatternReference | undefined, FormValidationError>;
}> = ({ isLoading, isReadOnly, indexPatternFormElement }) => {
useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_pattern' });
useTrackPageview({
app: 'infra_logs',
path: 'log_source_configuration_index_pattern',
delay: 15000,
});
const changeIndexPatternId = useCallback(
(indexPatternId: string | undefined) => {
if (indexPatternId != null) {
indexPatternFormElement.updateValue(() => ({
type: 'index_pattern',
indexPatternId,
}));
} else {
indexPatternFormElement.updateValue(() => undefined);
}
},
[indexPatternFormElement]
);
return (
<>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.infra.logSourceConfiguration.indexPatternSectionTitle"
defaultMessage="Index pattern"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<IndexPatternInlineHelpMessage />
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.infra.logSourceConfiguration.logIndexPatternTitle"
defaultMessage="Log index pattern"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.infra.logSourceConfiguration.logIndexPatternDescription"
defaultMessage="Index pattern that contains log data"
/>
}
>
<EuiFormRow
fullWidth
label={
<FormattedMessage
id="xpack.infra.logSourceConfiguration.logIndexPatternLabel"
defaultMessage="Log index pattern"
/>
}
{...useMemo(() => (isLoading ? {} : getFormRowProps(indexPatternFormElement)), [
isLoading,
indexPatternFormElement,
])}
>
<IndexPatternSelector
isLoading={isLoading || indexPatternFormElement.validity.validity === 'pending'}
isReadOnly={isReadOnly}
indexPatternId={indexPatternFormElement.value?.indexPatternId}
onChangeIndexPatternId={changeIndexPatternId}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</>
);
};
const IndexPatternInlineHelpMessage = React.memo(() => {
const indexPatternManagementLinkProps = useLinkProps({
app: 'management',
pathname: '/kibana/indexPatterns',
});
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.logIndexPatternHelpText"
defaultMessage="Kibana index patterns are shared among apps in the Kibana space and can be managed via the {indexPatternsManagementLink}."
values={{
indexPatternsManagementLink: (
<EuiLink {...indexPatternManagementLinkProps}>
<FormattedMessage
id="xpack.infra.logSourceConfiguration.indexPatternManagementLinkText"
defaultMessage="index patterns management screen"
/>
</EuiLink>
),
}}
/>
);
});

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useKibanaIndexPatternTitles } from '../../../hooks/use_kibana_index_patterns';
type IndexPatternOption = EuiComboBoxOptionOption<string>;
export const IndexPatternSelector: React.FC<{
indexPatternId: string | undefined;
isLoading: boolean;
isReadOnly: boolean;
onChangeIndexPatternId: (indexPatternId: string | undefined) => void;
}> = ({ indexPatternId, isLoading, isReadOnly, onChangeIndexPatternId }) => {
const {
indexPatternTitles: availableIndexPatterns,
latestIndexPatternTitlesRequest,
fetchIndexPatternTitles,
} = useKibanaIndexPatternTitles();
useEffect(() => {
fetchIndexPatternTitles();
}, [fetchIndexPatternTitles]);
const availableOptions = useMemo<IndexPatternOption[]>(
() =>
availableIndexPatterns.map(({ id, title }) => ({
key: id,
label: title,
value: id,
})),
[availableIndexPatterns]
);
const selectedOptions = useMemo<IndexPatternOption[]>(
() => availableOptions.filter(({ key }) => key === indexPatternId),
[availableOptions, indexPatternId]
);
const changeSelectedIndexPatterns = useCallback(
([newlySelectedOption]: IndexPatternOption[]) => {
if (typeof newlySelectedOption?.key === 'string') {
return onChangeIndexPatternId(newlySelectedOption.key);
}
return onChangeIndexPatternId(undefined);
},
[onChangeIndexPatternId]
);
return (
<EuiComboBox<string>
isLoading={isLoading || latestIndexPatternTitlesRequest.state === 'pending'}
isDisabled={isReadOnly}
options={availableOptions}
placeholder={indexPatternSelectorPlaceholder}
selectedOptions={selectedOptions}
singleSelection={true}
onChange={changeSelectedIndexPatterns}
/>
);
};
const indexPatternSelectorPlaceholder = i18n.translate(
'xpack.infra.logSourceConfiguration.indexPatternSelectorPlaceholder',
{ defaultMessage: 'Choose an index pattern' }
);

View file

@ -5,120 +5,107 @@
* 2.0.
*/
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useUiTracker } from '../../../../../observability/public';
import {
createInputFieldProps,
validateInputFieldNotEmpty,
} from '../../../components/source_configuration/input_fields';
LogIndexNameReference,
logIndexNameReferenceRT,
LogIndexPatternReference,
} from '../../../../common/log_sources';
import { useKibanaIndexPatternService } from '../../../hooks/use_kibana_index_patterns';
import { useCompositeFormElement, useFormElement } from './form_elements';
import {
FormValidationError,
validateIndexPattern,
validateStringNotEmpty,
} from './validation_errors';
interface FormState {
name: string;
description: string;
logAlias: string;
export type LogIndicesFormState = LogIndexNameReference | LogIndexPatternReference | undefined;
export const useLogIndicesFormElement = (initialValue: LogIndicesFormState) => {
const indexPatternService = useKibanaIndexPatternService();
const trackIndexPatternValidationError = useUiTracker({ app: 'infra_logs' });
const logIndicesFormElement = useFormElement<LogIndicesFormState, FormValidationError>({
initialValue,
validate: useMemo(
() => async (logIndices) => {
if (logIndices == null) {
return validateStringNotEmpty('log index pattern', '');
} else if (logIndexNameReferenceRT.is(logIndices)) {
return validateStringNotEmpty('log indices', logIndices.indexName);
} else {
const emptyStringErrors = validateStringNotEmpty(
'log index pattern',
logIndices.indexPatternId
);
if (emptyStringErrors.length > 0) {
return emptyStringErrors;
}
const indexPatternErrors = validateIndexPattern(
await indexPatternService.get(logIndices.indexPatternId)
);
if (indexPatternErrors.length > 0) {
trackIndexPatternValidationError({
metric: 'configuration_index_pattern_validation_failed',
});
} else {
trackIndexPatternValidationError({
metric: 'configuration_index_pattern_validation_succeeded',
});
}
return indexPatternErrors;
}
},
[indexPatternService, trackIndexPatternValidationError]
),
});
return logIndicesFormElement;
};
export interface FieldsFormState {
tiebreakerField: string;
timestampField: string;
}
type FormStateChanges = Partial<FormState>;
export const useFieldsFormElement = (initialValues: FieldsFormState) => {
const tiebreakerFieldFormElement = useFormElement<string, FormValidationError>({
initialValue: initialValues.tiebreakerField,
validate: useMemo(
() => async (tiebreakerField) => validateStringNotEmpty('tiebreaker', tiebreakerField),
[]
),
});
export const useLogIndicesConfigurationFormState = ({
initialFormState = defaultFormState,
}: {
initialFormState?: FormState;
}) => {
const [formStateChanges, setFormStateChanges] = useState<FormStateChanges>({});
const timestampFieldFormElement = useFormElement<string, FormValidationError>({
initialValue: initialValues.timestampField,
validate: useMemo(
() => async (timestampField) => validateStringNotEmpty('timestamp', timestampField),
[]
),
});
const resetForm = useCallback(() => setFormStateChanges({}), []);
const formState = useMemo(
() => ({
...initialFormState,
...formStateChanges,
}),
[initialFormState, formStateChanges]
);
const nameFieldProps = useMemo(
() =>
createInputFieldProps({
errors: validateInputFieldNotEmpty(formState.name),
name: 'name',
onChange: (name) => setFormStateChanges((changes) => ({ ...changes, name })),
value: formState.name,
const fieldsFormElement = useCompositeFormElement(
useMemo(
() => ({
childFormElements: {
tiebreaker: tiebreakerFieldFormElement,
timestamp: timestampFieldFormElement,
},
}),
[formState.name]
[tiebreakerFieldFormElement, timestampFieldFormElement]
)
);
const logAliasFieldProps = useMemo(
() =>
createInputFieldProps({
errors: validateInputFieldNotEmpty(formState.logAlias),
name: 'logAlias',
onChange: (logAlias) => setFormStateChanges((changes) => ({ ...changes, logAlias })),
value: formState.logAlias,
}),
[formState.logAlias]
);
const tiebreakerFieldFieldProps = useMemo(
() =>
createInputFieldProps({
errors: validateInputFieldNotEmpty(formState.tiebreakerField),
name: `tiebreakerField`,
onChange: (tiebreakerField) =>
setFormStateChanges((changes) => ({ ...changes, tiebreakerField })),
value: formState.tiebreakerField,
}),
[formState.tiebreakerField]
);
const timestampFieldFieldProps = useMemo(
() =>
createInputFieldProps({
errors: validateInputFieldNotEmpty(formState.timestampField),
name: `timestampField`,
onChange: (timestampField) =>
setFormStateChanges((changes) => ({ ...changes, timestampField })),
value: formState.timestampField,
}),
[formState.timestampField]
);
const fieldProps = useMemo(
() => ({
name: nameFieldProps,
logAlias: logAliasFieldProps,
tiebreakerField: tiebreakerFieldFieldProps,
timestampField: timestampFieldFieldProps,
}),
[nameFieldProps, logAliasFieldProps, tiebreakerFieldFieldProps, timestampFieldFieldProps]
);
const errors = useMemo(
() =>
Object.values(fieldProps).reduce<ReactNode[]>(
(accumulatedErrors, { error }) => [...accumulatedErrors, ...error],
[]
),
[fieldProps]
);
const isFormValid = useMemo(() => errors.length <= 0, [errors]);
const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]);
return {
errors,
fieldProps,
formState,
formStateChanges,
isFormDirty,
isFormValid,
resetForm,
fieldsFormElement,
tiebreakerFieldFormElement,
timestampFieldFormElement,
};
};
const defaultFormState: FormState = {
name: '',
description: '',
logAlias: '',
tiebreakerField: '',
timestampField: '',
};

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCodeBlock, EuiPage, EuiPageBody, EuiPageContent, PropsOf } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n/react';
import { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';
import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common';
import {
MockIndexPatternsKibanaContextProvider,
MockIndexPatternSpec,
} from '../../../hooks/use_kibana_index_patterns.mock';
import {
FieldsFormState,
LogIndicesFormState,
useFieldsFormElement,
useLogIndicesFormElement,
} from './indices_configuration_form_state';
import { IndicesConfigurationPanel } from './indices_configuration_panel';
export default {
title: 'infra/logsSettings/indicesConfiguration',
decorators: [
(WrappedStory, { args }) => {
return (
<I18nProvider>
<EuiThemeProvider>
<MockIndexPatternsKibanaContextProvider
asyncDelay={2000}
mockIndexPatterns={args.availableIndexPatterns}
>
<EuiPage restrictWidth>
<EuiPageBody>
<EuiPageContent>
<WrappedStory />
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</MockIndexPatternsKibanaContextProvider>
</EuiThemeProvider>
</I18nProvider>
);
},
],
argTypes: {
logIndices: {
control: {
type: 'object',
},
},
availableIndexPatterns: {
control: {
type: 'object',
},
},
},
} as Meta;
type IndicesConfigurationPanelProps = PropsOf<typeof IndicesConfigurationPanel>;
type IndicesConfigurationPanelStoryArgs = Pick<
IndicesConfigurationPanelProps,
'isLoading' | 'isReadOnly'
> & {
availableIndexPatterns: MockIndexPatternSpec[];
logIndices: LogIndicesFormState;
fields: FieldsFormState;
};
const IndicesConfigurationPanelTemplate: Story<IndicesConfigurationPanelStoryArgs> = ({
isLoading,
isReadOnly,
logIndices,
fields,
}) => {
const logIndicesFormElement = useLogIndicesFormElement(logIndices);
const { tiebreakerFieldFormElement, timestampFieldFormElement } = useFieldsFormElement(fields);
return (
<>
<IndicesConfigurationPanel
isLoading={isLoading}
isReadOnly={isReadOnly}
indicesFormElement={logIndicesFormElement}
tiebreakerFieldFormElement={tiebreakerFieldFormElement}
timestampFieldFormElement={timestampFieldFormElement}
/>
<EuiCodeBlock language="json">
// field states{'\n'}
{JSON.stringify(
{
logIndices: {
value: logIndicesFormElement.value,
validity: logIndicesFormElement.validity,
},
tiebreakerField: {
value: tiebreakerFieldFormElement.value,
validity: tiebreakerFieldFormElement.validity,
},
timestampField: {
value: timestampFieldFormElement.value,
validity: timestampFieldFormElement.validity,
},
},
null,
2
)}
</EuiCodeBlock>
</>
);
};
const defaultArgs: IndicesConfigurationPanelStoryArgs = {
isLoading: false,
isReadOnly: false,
logIndices: {
type: 'index_name' as const,
indexName: 'logs-*',
},
fields: {
tiebreakerField: '_doc',
timestampField: '@timestamp',
},
availableIndexPatterns: [
{
id: 'INDEX_PATTERN_A',
title: 'pattern-a-*',
timeFieldName: '@timestamp',
fields: [
{
name: '@timestamp',
type: KBN_FIELD_TYPES.DATE,
searchable: true,
aggregatable: true,
},
{
name: 'message',
type: KBN_FIELD_TYPES.STRING,
searchable: true,
aggregatable: true,
},
],
},
{
id: 'INDEX_PATTERN_B',
title: 'pattern-b-*',
fields: [],
},
],
};
export const IndexNameWithDefaultFields = IndicesConfigurationPanelTemplate.bind({});
IndexNameWithDefaultFields.args = {
...defaultArgs,
};
export const IndexPattern = IndicesConfigurationPanelTemplate.bind({});
IndexPattern.args = {
...defaultArgs,
logIndices: undefined,
};

View file

@ -5,85 +5,77 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import { useUiTracker } from '../../../../../observability/public';
import {
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { InputFieldProps } from '../../../components/source_configuration/input_fields';
logIndexNameReferenceRT,
LogIndexPatternReference,
logIndexPatternReferenceRT,
LogIndexReference,
} from '../../../../common/log_sources';
import { FieldsConfigurationPanel } from './fields_configuration_panel';
import { FormElement, isFormElementForType } from './form_elements';
import { IndexNamesConfigurationPanel } from './index_names_configuration_panel';
import { IndexPatternConfigurationPanel } from './index_pattern_configuration_panel';
import { FormValidationError } from './validation_errors';
interface IndicesConfigurationPanelProps {
export const IndicesConfigurationPanel = React.memo<{
isLoading: boolean;
readOnly: boolean;
logAliasFieldProps: InputFieldProps;
}
isReadOnly: boolean;
indicesFormElement: FormElement<LogIndexReference | undefined, FormValidationError>;
tiebreakerFieldFormElement: FormElement<string, FormValidationError>;
timestampFieldFormElement: FormElement<string, FormValidationError>;
}>(
({
isLoading,
isReadOnly,
indicesFormElement,
tiebreakerFieldFormElement,
timestampFieldFormElement,
}) => {
const trackSwitchToIndexPatternReference = useUiTracker({ app: 'infra_logs' });
export const IndicesConfigurationPanel = ({
isLoading,
readOnly,
logAliasFieldProps,
}: IndicesConfigurationPanelProps) => (
<EuiForm>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.infra.sourceConfiguration.indicesSectionTitle"
defaultMessage="Indices"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesTitle"
defaultMessage="Log indices"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesDescription"
defaultMessage="Index pattern for matching indices that contain log data"
/>
}
>
<EuiFormRow
error={logAliasFieldProps.error}
fullWidth
helpText={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesRecommendedValue"
defaultMessage="The recommended value is {defaultValue}"
values={{
defaultValue: <EuiCode>logs-*,filebeat-*</EuiCode>,
}}
/>
}
isInvalid={logAliasFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesLabel"
defaultMessage="Log indices"
/>
}
>
<EuiFieldText
data-test-subj="logIndicesInput"
fullWidth
disabled={isLoading}
const switchToIndexPatternReference = useCallback(() => {
indicesFormElement.updateValue(() => undefined);
trackSwitchToIndexPatternReference({
metric: 'configuration_switch_to_index_pattern_reference',
});
}, [indicesFormElement, trackSwitchToIndexPatternReference]);
if (isIndexPatternFormElement(indicesFormElement)) {
return (
<IndexPatternConfigurationPanel
isLoading={isLoading}
readOnly={readOnly}
{...logAliasFieldProps}
isReadOnly={isReadOnly}
indexPatternFormElement={indicesFormElement}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiForm>
);
} else if (isIndexNamesFormElement(indicesFormElement)) {
return (
<>
<IndexNamesConfigurationPanel
isLoading={isLoading}
isReadOnly={isReadOnly}
indexNamesFormElement={indicesFormElement}
onSwitchToIndexPatternReference={switchToIndexPatternReference}
/>
<FieldsConfigurationPanel
isLoading={isLoading}
isReadOnly={isReadOnly}
tiebreakerFieldFormElement={tiebreakerFieldFormElement}
timestampFieldFormElement={timestampFieldFormElement}
/>
</>
);
} else {
return null;
}
}
);
const isIndexPatternFormElement = isFormElementForType(
(value): value is LogIndexPatternReference | undefined =>
value == null || logIndexPatternReferenceRT.is(value)
);
const isIndexNamesFormElement = isFormElementForType(logIndexNameReferenceRT.is);

View file

@ -5,150 +5,16 @@
* 2.0.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo, useState } from 'react';
import {
FieldLogColumnConfiguration,
isMessageLogColumnConfiguration,
isTimestampLogColumnConfiguration,
LogColumnConfiguration,
MessageLogColumnConfiguration,
TimestampLogColumnConfiguration,
} from '../../../utils/source_configuration';
import { useMemo } from 'react';
import { LogColumnConfiguration } from '../../../utils/source_configuration';
import { useFormElement } from './form_elements';
import { FormValidationError, validateColumnListNotEmpty } from './validation_errors';
export interface TimestampLogColumnConfigurationProps {
logColumnConfiguration: TimestampLogColumnConfiguration['timestampColumn'];
remove: () => void;
type: 'timestamp';
}
export const useLogColumnsFormElement = (initialValue: LogColumnConfiguration[]) => {
const logColumnsFormElement = useFormElement<LogColumnConfiguration[], FormValidationError>({
initialValue,
validate: useMemo(() => async (logColumns) => validateColumnListNotEmpty(logColumns), []),
});
export interface MessageLogColumnConfigurationProps {
logColumnConfiguration: MessageLogColumnConfiguration['messageColumn'];
remove: () => void;
type: 'message';
}
export interface FieldLogColumnConfigurationProps {
logColumnConfiguration: FieldLogColumnConfiguration['fieldColumn'];
remove: () => void;
type: 'field';
}
export type LogColumnConfigurationProps =
| TimestampLogColumnConfigurationProps
| MessageLogColumnConfigurationProps
| FieldLogColumnConfigurationProps;
interface FormState {
logColumns: LogColumnConfiguration[];
}
type FormStateChanges = Partial<FormState>;
export const useLogColumnsConfigurationFormState = ({
initialFormState = defaultFormState,
}: {
initialFormState?: FormState;
}) => {
const [formStateChanges, setFormStateChanges] = useState<FormStateChanges>({});
const resetForm = useCallback(() => setFormStateChanges({}), []);
const formState = useMemo(
() => ({
...initialFormState,
...formStateChanges,
}),
[initialFormState, formStateChanges]
);
const logColumnConfigurationProps = useMemo<LogColumnConfigurationProps[]>(
() =>
formState.logColumns.map(
(logColumn): LogColumnConfigurationProps => {
const remove = () =>
setFormStateChanges((changes) => ({
...changes,
logColumns: formState.logColumns.filter((item) => item !== logColumn),
}));
if (isTimestampLogColumnConfiguration(logColumn)) {
return {
logColumnConfiguration: logColumn.timestampColumn,
remove,
type: 'timestamp',
};
} else if (isMessageLogColumnConfiguration(logColumn)) {
return {
logColumnConfiguration: logColumn.messageColumn,
remove,
type: 'message',
};
} else {
return {
logColumnConfiguration: logColumn.fieldColumn,
remove,
type: 'field',
};
}
}
),
[formState.logColumns]
);
const addLogColumn = useCallback(
(logColumnConfiguration: LogColumnConfiguration) =>
setFormStateChanges((changes) => ({
...changes,
logColumns: [...formState.logColumns, logColumnConfiguration],
})),
[formState.logColumns]
);
const moveLogColumn = useCallback(
(sourceIndex, destinationIndex) => {
if (destinationIndex >= 0 && sourceIndex <= formState.logColumns.length - 1) {
const newLogColumns = [...formState.logColumns];
newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]);
setFormStateChanges((changes) => ({
...changes,
logColumns: newLogColumns,
}));
}
},
[formState.logColumns]
);
const errors = useMemo(
() =>
logColumnConfigurationProps.length <= 0
? [
<FormattedMessage
id="xpack.infra.sourceConfiguration.logColumnListEmptyErrorMessage"
defaultMessage="The log column list must not be empty."
/>,
]
: [],
[logColumnConfigurationProps]
);
const isFormValid = useMemo(() => (errors.length <= 0 ? true : false), [errors]);
const isFormDirty = useMemo(() => Object.keys(formStateChanges).length > 0, [formStateChanges]);
return {
addLogColumn,
moveLogColumn,
errors,
logColumnConfigurationProps,
formState,
formStateChanges,
isFormDirty,
isFormValid,
resetForm,
};
};
const defaultFormState: FormState = {
logColumns: [],
return logColumnsFormElement;
};

View file

@ -13,7 +13,6 @@ import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiIcon,
EuiPanel,
EuiSpacer,
@ -24,28 +23,54 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback } from 'react';
import { DragHandleProps, DropResult } from '../../../../../observability/public';
import { LogColumnConfiguration } from '../../../utils/source_configuration';
import { AddLogColumnButtonAndPopover } from './add_log_column_popover';
import {
FieldLogColumnConfigurationProps,
LogColumnConfigurationProps,
} from './log_columns_configuration_form_state';
FieldLogColumnConfiguration,
getLogColumnConfigurationId,
isMessageLogColumnConfiguration,
isTimestampLogColumnConfiguration,
LogColumnConfiguration,
MessageLogColumnConfiguration,
TimestampLogColumnConfiguration,
} from '../../../utils/source_configuration';
import { AddLogColumnButtonAndPopover } from './add_log_column_popover';
import { FormElement } from './form_elements';
import { LogSourceConfigurationFormError } from './source_configuration_form_errors';
import { FormValidationError } from './validation_errors';
interface LogColumnsConfigurationPanelProps {
export const LogColumnsConfigurationPanel = React.memo<{
availableFields: string[];
isLoading: boolean;
logColumnConfiguration: LogColumnConfigurationProps[];
addLogColumn: (logColumn: LogColumnConfiguration) => void;
moveLogColumn: (sourceIndex: number, destinationIndex: number) => void;
}
logColumnsFormElement: FormElement<LogColumnConfiguration[], FormValidationError>;
}>(({ availableFields, isLoading, logColumnsFormElement }) => {
const addLogColumn = useCallback(
(logColumnConfiguration: LogColumnConfiguration) =>
logColumnsFormElement.updateValue((logColumns) => [...logColumns, logColumnConfiguration]),
[logColumnsFormElement]
);
const removeLogColumn = useCallback(
(logColumn: LogColumnConfiguration) =>
logColumnsFormElement.updateValue((logColumns) =>
logColumns.filter((item) => item !== logColumn)
),
[logColumnsFormElement]
);
const moveLogColumn = useCallback(
(sourceIndex, destinationIndex) => {
logColumnsFormElement.updateValue((logColumns) => {
if (destinationIndex >= 0 && sourceIndex <= logColumnsFormElement.value.length - 1) {
const newLogColumns = [...logColumnsFormElement.value];
newLogColumns.splice(destinationIndex, 0, newLogColumns.splice(sourceIndex, 1)[0]);
return newLogColumns;
} else {
return logColumns;
}
});
},
[logColumnsFormElement]
);
export const LogColumnsConfigurationPanel: React.FunctionComponent<LogColumnsConfigurationPanelProps> = ({
addLogColumn,
moveLogColumn,
availableFields,
isLoading,
logColumnConfiguration,
}) => {
const onDragEnd = useCallback(
({ source, destination }: DropResult) =>
destination && moveLogColumn(source.index, destination.index),
@ -53,7 +78,7 @@ export const LogColumnsConfigurationPanel: React.FunctionComponent<LogColumnsCon
);
return (
<EuiForm>
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="s" data-test-subj="sourceConfigurationLogColumnsSectionTitle">
@ -73,63 +98,89 @@ export const LogColumnsConfigurationPanel: React.FunctionComponent<LogColumnsCon
/>
</EuiFlexItem>
</EuiFlexGroup>
{logColumnConfiguration.length > 0 ? (
{logColumnsFormElement.value.length > 0 ? (
<EuiDragDropContext onDragEnd={onDragEnd}>
<EuiDroppable droppableId="COLUMN_CONFIG_DROPPABLE_AREA">
<>
{/* Fragment here necessary for typechecking */}
{logColumnConfiguration.map((column, index) => (
{logColumnsFormElement.value.map((logColumnConfiguration, index) => {
const columnId = getLogColumnConfigurationId(logColumnConfiguration);
return (
<EuiDraggable
key={`logColumnConfigurationPanel-${column.logColumnConfiguration.id}`}
key={`logColumnConfigurationPanel-${columnId}`}
index={index}
draggableId={column.logColumnConfiguration.id}
draggableId={columnId}
customDragHandle
>
{(provided) => (
<LogColumnConfigurationPanel
dragHandleProps={provided.dragHandleProps}
logColumnConfigurationProps={column}
logColumnConfiguration={logColumnConfiguration}
onRemove={removeLogColumn}
/>
)}
</EuiDraggable>
))}
</>
);
})}
</EuiDroppable>
</EuiDragDropContext>
) : (
<LogColumnConfigurationEmptyPrompt />
)}
</EuiForm>
{logColumnsFormElement.validity.validity === 'invalid'
? logColumnsFormElement.validity.reasons.map((error) => (
<EuiText key={error.type} textAlign="center" color="danger">
<LogSourceConfigurationFormError error={error} />
</EuiText>
))
: null}
</>
);
});
const LogColumnConfigurationPanel: React.FunctionComponent<{
logColumnConfiguration: LogColumnConfiguration;
dragHandleProps: DragHandleProps;
onRemove: (logColumnConfiguration: LogColumnConfiguration) => void;
}> = ({ logColumnConfiguration, dragHandleProps, onRemove }) => {
const removeColumn = useCallback(() => onRemove(logColumnConfiguration), [
logColumnConfiguration,
onRemove,
]);
return (
<>
<EuiSpacer size="m" />
{isTimestampLogColumnConfiguration(logColumnConfiguration) ? (
<TimestampLogColumnConfigurationPanel
dragHandleProps={dragHandleProps}
logColumnConfiguration={logColumnConfiguration}
onRemove={removeColumn}
/>
) : isMessageLogColumnConfiguration(logColumnConfiguration) ? (
<MessageLogColumnConfigurationPanel
dragHandleProps={dragHandleProps}
logColumnConfiguration={logColumnConfiguration}
onRemove={removeColumn}
/>
) : (
<FieldLogColumnConfigurationPanel
dragHandleProps={dragHandleProps}
logColumnConfiguration={logColumnConfiguration}
onRemove={removeColumn}
/>
)}
</>
);
};
interface LogColumnConfigurationPanelProps {
logColumnConfigurationProps: LogColumnConfigurationProps;
interface LogColumnConfigurationPanelProps<LogColumnConfigurationType> {
logColumnConfiguration: LogColumnConfigurationType;
dragHandleProps: DragHandleProps;
onRemove: () => void;
}
const LogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = (
props
) => (
<>
<EuiSpacer size="m" />
{props.logColumnConfigurationProps.type === 'timestamp' ? (
<TimestampLogColumnConfigurationPanel {...props} />
) : props.logColumnConfigurationProps.type === 'message' ? (
<MessageLogColumnConfigurationPanel {...props} />
) : (
<FieldLogColumnConfigurationPanel
logColumnConfigurationProps={props.logColumnConfigurationProps}
dragHandleProps={props.dragHandleProps}
/>
)}
</>
);
const TimestampLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = ({
logColumnConfigurationProps,
dragHandleProps,
}) => (
const TimestampLogColumnConfigurationPanel: React.FunctionComponent<
LogColumnConfigurationPanelProps<TimestampLogColumnConfiguration>
> = ({ dragHandleProps, onRemove }) => (
<ExplainedLogColumnConfigurationPanel
fieldName="Timestamp"
helpText={
@ -142,15 +193,14 @@ const TimestampLogColumnConfigurationPanel: React.FunctionComponent<LogColumnCon
}}
/>
}
removeColumn={logColumnConfigurationProps.remove}
onRemove={onRemove}
dragHandleProps={dragHandleProps}
/>
);
const MessageLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = ({
logColumnConfigurationProps,
dragHandleProps,
}) => (
const MessageLogColumnConfigurationPanel: React.FunctionComponent<
LogColumnConfigurationPanelProps<MessageLogColumnConfiguration>
> = ({ dragHandleProps, onRemove }) => (
<ExplainedLogColumnConfigurationPanel
fieldName="Message"
helpText={
@ -160,29 +210,26 @@ const MessageLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfi
defaultMessage="This system field shows the log entry message as derived from the document fields."
/>
}
removeColumn={logColumnConfigurationProps.remove}
onRemove={onRemove}
dragHandleProps={dragHandleProps}
/>
);
const FieldLogColumnConfigurationPanel: React.FunctionComponent<{
logColumnConfigurationProps: FieldLogColumnConfigurationProps;
dragHandleProps: DragHandleProps;
}> = ({
logColumnConfigurationProps: {
logColumnConfiguration: { field },
remove,
},
const FieldLogColumnConfigurationPanel: React.FunctionComponent<
LogColumnConfigurationPanelProps<FieldLogColumnConfiguration>
> = ({
dragHandleProps,
logColumnConfiguration: {
fieldColumn: { field },
},
onRemove,
}) => {
const fieldLogColumnTitle = i18n.translate(
'xpack.infra.sourceConfiguration.fieldLogColumnTitle',
{
defaultMessage: 'Field',
}
);
return (
<EuiPanel data-test-subj={`logColumnPanel fieldLogColumnPanel fieldLogColumnPanel:${field}`}>
<EuiPanel
color="subdued"
data-test-subj={`logColumnPanel fieldLogColumnPanel fieldLogColumnPanel:${field}`}
hasShadow={false}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<div data-test-subj="moveLogColumnHandle" {...dragHandleProps}>
@ -195,7 +242,7 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveLogColumnButton
onClick={remove}
onClick={onRemove}
columnDescription={`${fieldLogColumnTitle} - ${field}`}
/>
</EuiFlexItem>
@ -207,11 +254,13 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{
const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{
fieldName: React.ReactNode;
helpText: React.ReactNode;
removeColumn: () => void;
onRemove: () => void;
dragHandleProps: DragHandleProps;
}> = ({ fieldName, helpText, removeColumn, dragHandleProps }) => (
}> = ({ fieldName, helpText, onRemove, dragHandleProps }) => (
<EuiPanel
color="subdued"
data-test-subj={`logColumnPanel systemLogColumnPanel systemLogColumnPanel:${fieldName}`}
hasShadow={false}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
@ -226,7 +275,7 @@ const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveLogColumnButton onClick={removeColumn} columnDescription={String(fieldName)} />
<RemoveLogColumnButton onClick={onRemove} columnDescription={String(fieldName)} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
@ -277,3 +326,7 @@ const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => (
}
/>
);
const fieldLogColumnTitle = i18n.translate('xpack.infra.sourceConfiguration.fieldLogColumnTitle', {
defaultMessage: 'Field',
});

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { useFormElement } from './form_elements';
import { FormValidationError, validateStringNotEmpty } from './validation_errors';
export const useNameFormElement = (initialValue: string) => {
const nameFormElement = useFormElement<string, FormValidationError>({
initialValue,
validate: useMemo(() => async (name) => validateStringNotEmpty('name', name), []),
});
return nameFormElement;
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiDescribedFormGroup,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useMemo } from 'react';
import { FormElement } from './form_elements';
import { getFormRowProps, getStringInputFieldProps } from './form_field_props';
import { FormValidationError } from './validation_errors';
export const NameConfigurationPanel = React.memo<{
isLoading: boolean;
isReadOnly: boolean;
nameFormElement: FormElement<string, FormValidationError>;
}>(({ isLoading, isReadOnly, nameFormElement }) => (
<EuiForm>
<EuiTitle size="s" data-test-subj="sourceConfigurationNameSectionTitle">
<h3>
<FormattedMessage
id="xpack.infra.sourceConfiguration.nameSectionTitle"
defaultMessage="Name"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage id="xpack.infra.sourceConfiguration.nameLabel" defaultMessage="Name" />
</h4>
}
description={
<FormattedMessage
id="xpack.infra.sourceConfiguration.nameDescription"
defaultMessage="A descriptive name for the source configuration"
/>
}
>
<EuiFormRow
fullWidth
label={
<FormattedMessage id="xpack.infra.sourceConfiguration.nameLabel" defaultMessage="Name" />
}
{...useMemo(() => getFormRowProps(nameFormElement), [nameFormElement])}
>
<EuiFieldText
data-test-subj="nameInput"
fullWidth
disabled={isLoading}
readOnly={isReadOnly}
isLoading={isLoading}
name="name"
{...useMemo(() => getStringInputFieldProps(nameFormElement), [nameFormElement])}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiForm>
));

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCallOut, EuiCode } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { FormValidationError } from './validation_errors';
export const LogSourceConfigurationFormErrors: React.FC<{ errors: FormValidationError[] }> = ({
errors,
}) => (
<EuiCallOut color="danger" iconType="alert" title={logSourceConfigurationFormErrorsCalloutTitle}>
<ul>
{errors.map((error, errorIndex) => (
<li key={errorIndex}>
<LogSourceConfigurationFormError error={error} />
</li>
))}
</ul>
</EuiCallOut>
);
export const LogSourceConfigurationFormError: React.FC<{ error: FormValidationError }> = ({
error,
}) => {
if (error.type === 'generic') {
return <>{error.message}</>;
} else if (error.type === 'empty_field') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.emptyFieldErrorMessage"
defaultMessage="The field '{fieldName}' must not be empty."
values={{
fieldName: error.fieldName,
}}
/>
);
} else if (error.type === 'empty_column_list') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.emptyColumnListErrorMessage"
defaultMessage="The column list must not be empty."
/>
);
} else if (error.type === 'child') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.childFormElementErrorMessage"
defaultMessage="At least one form field is in an invalid state."
/>
);
} else if (error.type === 'missing_timestamp_field') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.missingTimestampFieldErrorMessage"
defaultMessage="The index pattern must be time-based."
/>
);
} else if (error.type === 'missing_message_field') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.missingMessageFieldErrorMessage"
defaultMessage="The index pattern must contain a {messageField} field."
values={{
messageField: <EuiCode>message</EuiCode>,
}}
/>
);
} else if (error.type === 'invalid_message_field_type') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.invalidMessageFieldTypeErrorMessage"
defaultMessage="The {messageField} field must be a text field."
values={{
messageField: <EuiCode>message</EuiCode>,
}}
/>
);
} else if (error.type === 'rollup_index_pattern') {
return (
<FormattedMessage
id="xpack.infra.logSourceConfiguration.rollupIndexPatternErrorMessage"
defaultMessage="The index pattern must not be a rollup index pattern."
/>
);
} else {
return null;
}
};
const logSourceConfigurationFormErrorsCalloutTitle = i18n.translate(
'xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle',
{
defaultMessage: 'Inconsistent source configuration',
}
);

View file

@ -5,103 +5,69 @@
* 2.0.
*/
import { useCallback, useMemo } from 'react';
import { ResolvedLogSourceConfiguration } from '../../../../common/log_sources';
import { useLogIndicesConfigurationFormState } from './indices_configuration_form_state';
import { useLogColumnsConfigurationFormState } from './log_columns_configuration_form_state';
import { useMemo } from 'react';
import { LogSourceConfigurationProperties } from '../../../containers/logs/log_source';
import { useCompositeFormElement } from './form_elements';
import { useFieldsFormElement, useLogIndicesFormElement } from './indices_configuration_form_state';
import { useLogColumnsFormElement } from './log_columns_configuration_form_state';
import { useNameFormElement } from './name_configuration_form_state';
export const useLogSourceConfigurationFormState = (
configuration?: ResolvedLogSourceConfiguration
configuration?: LogSourceConfigurationProperties
) => {
const indicesConfigurationFormState = useLogIndicesConfigurationFormState({
initialFormState: useMemo(
const nameFormElement = useNameFormElement(configuration?.name ?? '');
const logIndicesFormElement = useLogIndicesFormElement(
useMemo(
() =>
configuration
? {
name: configuration.name,
description: configuration.description,
logAlias: configuration.indices,
tiebreakerField: configuration.tiebreakerField,
timestampField: configuration.timestampField,
}
: undefined,
configuration?.logIndices ?? {
type: 'index_name',
indexName: '',
},
[configuration]
),
});
)
);
const logColumnsConfigurationFormState = useLogColumnsConfigurationFormState({
initialFormState: useMemo(
() =>
configuration
? {
logColumns: configuration.columns,
}
: undefined,
const {
fieldsFormElement,
tiebreakerFieldFormElement,
timestampFieldFormElement,
} = useFieldsFormElement(
useMemo(
() => ({
tiebreakerField: configuration?.fields?.tiebreaker ?? '_doc',
timestampField: configuration?.fields?.timestamp ?? '@timestamp',
}),
[configuration]
),
});
const errors = useMemo(
() => [...indicesConfigurationFormState.errors, ...logColumnsConfigurationFormState.errors],
[indicesConfigurationFormState.errors, logColumnsConfigurationFormState.errors]
)
);
const resetForm = useCallback(() => {
indicesConfigurationFormState.resetForm();
logColumnsConfigurationFormState.resetForm();
}, [indicesConfigurationFormState, logColumnsConfigurationFormState]);
const isFormDirty = useMemo(
() => indicesConfigurationFormState.isFormDirty || logColumnsConfigurationFormState.isFormDirty,
[indicesConfigurationFormState.isFormDirty, logColumnsConfigurationFormState.isFormDirty]
const logColumnsFormElement = useLogColumnsFormElement(
useMemo(() => configuration?.logColumns ?? [], [configuration])
);
const isFormValid = useMemo(
() => indicesConfigurationFormState.isFormValid && logColumnsConfigurationFormState.isFormValid,
[indicesConfigurationFormState.isFormValid, logColumnsConfigurationFormState.isFormValid]
);
const formState = useMemo(
() => ({
name: indicesConfigurationFormState.formState.name,
description: indicesConfigurationFormState.formState.description,
logAlias: indicesConfigurationFormState.formState.logAlias,
fields: {
tiebreaker: indicesConfigurationFormState.formState.tiebreakerField,
timestamp: indicesConfigurationFormState.formState.timestampField,
},
logColumns: logColumnsConfigurationFormState.formState.logColumns,
}),
[indicesConfigurationFormState.formState, logColumnsConfigurationFormState.formState]
);
const formStateChanges = useMemo(
() => ({
name: indicesConfigurationFormState.formStateChanges.name,
description: indicesConfigurationFormState.formStateChanges.description,
logAlias: indicesConfigurationFormState.formStateChanges.logAlias,
fields: {
tiebreaker: indicesConfigurationFormState.formStateChanges.tiebreakerField,
timestamp: indicesConfigurationFormState.formStateChanges.timestampField,
},
logColumns: logColumnsConfigurationFormState.formStateChanges.logColumns,
}),
[
indicesConfigurationFormState.formStateChanges,
logColumnsConfigurationFormState.formStateChanges,
]
const sourceConfigurationFormElement = useCompositeFormElement(
useMemo(
() => ({
childFormElements: {
name: nameFormElement,
logIndices: logIndicesFormElement,
fields: fieldsFormElement,
logColumns: logColumnsFormElement,
},
validate: async () => [],
}),
[nameFormElement, logIndicesFormElement, fieldsFormElement, logColumnsFormElement]
)
);
return {
addLogColumn: logColumnsConfigurationFormState.addLogColumn,
moveLogColumn: logColumnsConfigurationFormState.moveLogColumn,
errors,
formState,
formStateChanges,
isFormDirty,
isFormValid,
indicesConfigurationProps: indicesConfigurationFormState.fieldProps,
logColumnConfigurationProps: logColumnsConfigurationFormState.logColumnConfigurationProps,
resetForm,
formState: sourceConfigurationFormElement.value,
logIndicesFormElement,
logColumnsFormElement,
nameFormElement,
sourceConfigurationFormElement,
tiebreakerFieldFormElement,
timestampFieldFormElement,
};
};

View file

@ -7,33 +7,40 @@
import {
EuiButton,
EuiCallOut,
EuiErrorBoundary,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiPage,
EuiPageBody,
EuiPageContentBody,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo } from 'react';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { FieldsConfigurationPanel } from './fields_configuration_panel';
import { IndicesConfigurationPanel } from './indices_configuration_panel';
import { NameConfigurationPanel } from '../../../components/source_configuration/name_configuration_panel';
import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel';
import { useLogSourceConfigurationFormState } from './source_configuration_form_state';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useTrackPageview } from '../../../../../observability/public';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { Prompt } from '../../../utils/navigation_warning_prompt';
import { LogSourceConfigurationPropertiesPatch } from '../../../../common/http_api/log_sources';
import { IndicesConfigurationPanel } from './indices_configuration_panel';
import { LogColumnsConfigurationPanel } from './log_columns_configuration_panel';
import { NameConfigurationPanel } from './name_configuration_panel';
import { LogSourceConfigurationFormErrors } from './source_configuration_form_errors';
import { useLogSourceConfigurationFormState } from './source_configuration_form_state';
export const LogsSettingsPage = () => {
const uiCapabilities = useKibana().services.application?.capabilities;
const shouldAllowEdit = uiCapabilities?.logs?.configureSource === true;
useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration' });
useTrackPageview({
app: 'infra_logs',
path: 'log_source_configuration',
delay: 15000,
});
const {
sourceConfiguration: source,
isLoading,
@ -48,35 +55,19 @@ export const LogsSettingsPage = () => {
);
const {
addLogColumn,
moveLogColumn,
indicesConfigurationProps,
logColumnConfigurationProps,
errors,
resetForm,
isFormDirty,
isFormValid,
formStateChanges,
} = useLogSourceConfigurationFormState(resolvedSourceConfiguration);
sourceConfigurationFormElement,
formState,
logIndicesFormElement,
logColumnsFormElement,
nameFormElement,
tiebreakerFieldFormElement,
timestampFieldFormElement,
} = useLogSourceConfigurationFormState(source?.configuration);
const persistUpdates = useCallback(async () => {
// NOTE / TODO: This is just a temporary workaround until this work is merged with the corresponding UI branch.
// Otherwise we would be duplicating work changing the logAlias etc references twice.
const patchedProperties: LogSourceConfigurationPropertiesPatch & { logAlias?: string } = {
...formStateChanges,
...(formStateChanges.logAlias
? {
logIndices: {
type: 'index_name',
indexName: formStateChanges.logAlias,
},
}
: {}),
};
delete patchedProperties.logAlias;
await updateSourceConfiguration(patchedProperties);
resetForm();
}, [updateSourceConfiguration, resetForm, formStateChanges]);
await updateSourceConfiguration(formState);
sourceConfigurationFormElement.resetValue();
}, [updateSourceConfiguration, sourceConfigurationFormElement, formState]);
const isWriteable = useMemo(() => shouldAllowEdit && source && source.origin !== 'internal', [
shouldAllowEdit,
@ -92,110 +83,100 @@ export const LogsSettingsPage = () => {
return (
<EuiErrorBoundary>
<EuiPage>
<EuiPageBody
className="eui-displayBlock"
restrictWidth
data-test-subj="sourceConfigurationContent"
>
<Prompt prompt={isFormDirty ? unsavedFormPromptMessage : undefined} />
<EuiPanel paddingSize="l">
<NameConfigurationPanel
isLoading={isLoading}
nameFieldProps={indicesConfigurationProps.name}
readOnly={!isWriteable}
<EuiPage style={{ flex: '1 0 0%' }}>
<EuiPageBody data-test-subj="sourceConfigurationContent" restrictWidth>
<EuiPageContentBody>
<Prompt
prompt={sourceConfigurationFormElement.isDirty ? unsavedFormPromptMessage : undefined}
/>
</EuiPanel>
<EuiSpacer />
<EuiPanel paddingSize="l">
<IndicesConfigurationPanel
isLoading={isLoading}
logAliasFieldProps={indicesConfigurationProps.logAlias}
readOnly={!isWriteable}
/>
</EuiPanel>
<EuiSpacer />
<EuiPanel paddingSize="l">
<FieldsConfigurationPanel
isLoading={isLoading}
readOnly={!isWriteable}
tiebreakerFieldProps={indicesConfigurationProps.tiebreakerField}
timestampFieldProps={indicesConfigurationProps.timestampField}
/>
</EuiPanel>
<EuiSpacer />
<EuiPanel paddingSize="l">
<LogColumnsConfigurationPanel
addLogColumn={addLogColumn}
moveLogColumn={moveLogColumn}
availableFields={availableFields}
isLoading={isLoading}
logColumnConfiguration={logColumnConfigurationProps}
/>
</EuiPanel>
{errors.length > 0 ? (
<>
<EuiCallOut color="danger">
<ul>
{errors.map((error, errorIndex) => (
<li key={errorIndex}>{error}</li>
))}
</ul>
</EuiCallOut>
<EuiSpacer size="m" />
</>
) : null}
<EuiSpacer size="m" />
<EuiFlexGroup>
{isWriteable && (
<EuiFlexItem>
{isLoading ? (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton color="primary" isLoading fill>
Loading
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<>
<EuiPanel paddingSize="l">
<NameConfigurationPanel
isLoading={isLoading}
isReadOnly={!isWriteable}
nameFormElement={nameFormElement}
/>
</EuiPanel>
<EuiSpacer />
<EuiPanel paddingSize="l">
<IndicesConfigurationPanel
isLoading={isLoading}
isReadOnly={!isWriteable}
indicesFormElement={logIndicesFormElement}
tiebreakerFieldFormElement={tiebreakerFieldFormElement}
timestampFieldFormElement={timestampFieldFormElement}
/>
</EuiPanel>
<EuiSpacer />
<EuiPanel paddingSize="l">
<LogColumnsConfigurationPanel
availableFields={availableFields}
isLoading={isLoading}
logColumnsFormElement={logColumnsFormElement}
/>
</EuiPanel>
<EuiSpacer />
{sourceConfigurationFormElement.validity.validity === 'invalid' ? (
<>
<LogSourceConfigurationFormErrors
errors={sourceConfigurationFormElement.validity.reasons}
/>
<EuiSpacer />
</>
) : null}
<EuiFlexGroup>
{isWriteable && (
<EuiFlexItem>
{isLoading ? (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="discardSettingsButton"
color="danger"
iconType="cross"
isDisabled={isLoading || !isFormDirty}
onClick={() => {
resetForm();
}}
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.discardSettingsButtonLabel"
defaultMessage="Discard"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="applySettingsButton"
color="primary"
isDisabled={!isFormDirty || !isFormValid}
fill
onClick={persistUpdates}
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.applySettingsButtonLabel"
defaultMessage="Apply"
/>
<EuiButton color="primary" isLoading fill>
Loading
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="discardSettingsButton"
color="danger"
iconType="cross"
isDisabled={isLoading || !sourceConfigurationFormElement.isDirty}
onClick={() => {
sourceConfigurationFormElement.resetValue();
}}
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.discardSettingsButtonLabel"
defaultMessage="Discard"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="applySettingsButton"
color="primary"
isDisabled={
!sourceConfigurationFormElement.isDirty ||
sourceConfigurationFormElement.validity.validity !== 'valid'
}
fill
onClick={persistUpdates}
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.applySettingsButtonLabel"
defaultMessage="Apply"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>
</EuiErrorBoundary>

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IndexPattern, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
export interface GenericValidationError {
type: 'generic';
message: string;
}
export interface ChildFormValidationError {
type: 'child';
}
export interface EmptyFieldValidationError {
type: 'empty_field';
fieldName: string;
}
export interface EmptyColumnListValidationError {
type: 'empty_column_list';
}
export interface MissingTimestampFieldValidationError {
type: 'missing_timestamp_field';
indexPatternTitle: string;
}
export interface MissingMessageFieldValidationError {
type: 'missing_message_field';
indexPatternTitle: string;
}
export interface InvalidMessageFieldTypeValidationError {
type: 'invalid_message_field_type';
indexPatternTitle: string;
}
export interface RollupIndexPatternValidationError {
type: 'rollup_index_pattern';
indexPatternTitle: string;
}
export type FormValidationError =
| GenericValidationError
| ChildFormValidationError
| EmptyFieldValidationError
| EmptyColumnListValidationError
| MissingTimestampFieldValidationError
| MissingMessageFieldValidationError
| InvalidMessageFieldTypeValidationError
| RollupIndexPatternValidationError;
export const validateStringNotEmpty = (fieldName: string, value: string): FormValidationError[] =>
value === '' ? [{ type: 'empty_field', fieldName }] : [];
export const validateColumnListNotEmpty = (columns: unknown[]): FormValidationError[] =>
columns.length <= 0 ? [{ type: 'empty_column_list' }] : [];
export const validateIndexPattern = (indexPattern: IndexPattern): FormValidationError[] => {
return [
...validateIndexPatternIsTimeBased(indexPattern),
...validateIndexPatternHasStringMessageField(indexPattern),
...validateIndexPatternIsntRollup(indexPattern),
];
};
export const validateIndexPatternIsTimeBased = (
indexPattern: IndexPattern
): FormValidationError[] =>
indexPattern.isTimeBased()
? []
: [
{
type: 'missing_timestamp_field' as const,
indexPatternTitle: indexPattern.title,
},
];
export const validateIndexPatternHasStringMessageField = (
indexPattern: IndexPattern
): FormValidationError[] => {
const messageField = indexPattern.getFieldByName('message');
if (messageField == null) {
return [
{
type: 'missing_message_field' as const,
indexPatternTitle: indexPattern.title,
},
];
} else if (messageField.type !== KBN_FIELD_TYPES.STRING) {
return [
{
type: 'invalid_message_field_type' as const,
indexPatternTitle: indexPattern.title,
},
];
} else {
return [];
}
};
export const validateIndexPatternIsntRollup = (indexPattern: IndexPattern): FormValidationError[] =>
indexPattern.type != null
? [
{
type: 'rollup_index_pattern' as const,
indexPatternTitle: indexPattern.title,
},
]
: [];

View file

@ -19,8 +19,7 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { InputFieldProps } from '../../../components/source_configuration/input_fields';
import { InputFieldProps } from './input_fields';
interface FieldsConfigurationPanelProps {
containerFieldProps: InputFieldProps;

View file

@ -6,12 +6,11 @@
*/
import { ReactNode, useCallback, useMemo, useState } from 'react';
import {
createInputFieldProps,
createInputRangeFieldProps,
validateInputFieldNotEmpty,
} from '../../../components/source_configuration/input_fields';
} from './input_fields';
interface FormState {
name: string;

View file

@ -16,9 +16,8 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { METRICS_INDEX_PATTERN } from '../../../../common/constants';
import { InputFieldProps } from '../../../components/source_configuration/input_fields';
import { InputFieldProps } from './input_fields';
interface IndicesConfigurationPanelProps {
isLoading: boolean;

View file

@ -5,15 +5,17 @@
* 2.0.
*/
import { EuiTitle } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import { EuiRange } from '@elastic/eui';
import { EuiDescribedFormGroup } from '@elastic/eui';
import { EuiForm } from '@elastic/eui';
import {
EuiDescribedFormGroup,
EuiForm,
EuiFormRow,
EuiRange,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { InputRangeFieldProps } from '../../../components/source_configuration/input_fields';
import { InputRangeFieldProps } from './input_fields';
interface MLConfigurationPanelProps {
isLoading: boolean;

View file

@ -15,7 +15,6 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { InputFieldProps } from './input_fields';
interface NameConfigurationPanelProps {

View file

@ -10,24 +10,23 @@ import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiPage,
EuiPageBody,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useContext, useMemo } from 'react';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { Source } from '../../../containers/metrics_source';
import { useInfraMLCapabilitiesContext } from '../../../containers/ml/infra_ml_capabilities';
import { Prompt } from '../../../utils/navigation_warning_prompt';
import { FieldsConfigurationPanel } from './fields_configuration_panel';
import { IndicesConfigurationPanel } from './indices_configuration_panel';
import { NameConfigurationPanel } from '../../../components/source_configuration/name_configuration_panel';
import { useSourceConfigurationFormState } from './source_configuration_form_state';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { Prompt } from '../../../utils/navigation_warning_prompt';
import { MLConfigurationPanel } from './ml_configuration_panel';
import { useInfraMLCapabilitiesContext } from '../../../containers/ml/infra_ml_capabilities';
import { NameConfigurationPanel } from './name_configuration_panel';
import { useSourceConfigurationFormState } from './source_configuration_form_state';
interface SourceConfigurationSettingsProps {
shouldAllowEdit: boolean;

View file

@ -31,3 +31,15 @@ export const isTimestampLogColumnConfiguration = (
logColumnConfiguration: LogColumnConfiguration
): logColumnConfiguration is TimestampLogColumnConfiguration =>
logColumnConfiguration != null && 'timestampColumn' in logColumnConfiguration;
export const getLogColumnConfigurationId = (
logColumnConfiguration: LogColumnConfiguration
): string => {
if (isTimestampLogColumnConfiguration(logColumnConfiguration)) {
return logColumnConfiguration.timestampColumn.id;
} else if (isMessageLogColumnConfiguration(logColumnConfiguration)) {
return logColumnConfiguration.messageColumn.id;
} else {
return logColumnConfiguration.fieldColumn.id;
}
};

View file

@ -11094,7 +11094,6 @@
"xpack.infra.sourceConfiguration.hostNameFieldDescription": "ホストの識別に使用されるフィールドです",
"xpack.infra.sourceConfiguration.hostNameFieldLabel": "ホスト名",
"xpack.infra.sourceConfiguration.indicesSectionTitle": "インデックス",
"xpack.infra.sourceConfiguration.logColumnListEmptyErrorMessage": "ログ列リストは未入力のままにできません。",
"xpack.infra.sourceConfiguration.logColumnsSectionTitle": "ログ列",
"xpack.infra.sourceConfiguration.logIndicesDescription": "ログデータを含む一致するインデックスのインデックスパターンです",
"xpack.infra.sourceConfiguration.logIndicesLabel": "ログインデックス",

View file

@ -11248,7 +11248,6 @@
"xpack.infra.sourceConfiguration.hostNameFieldDescription": "用于标识主机的字段",
"xpack.infra.sourceConfiguration.hostNameFieldLabel": "主机名",
"xpack.infra.sourceConfiguration.indicesSectionTitle": "索引",
"xpack.infra.sourceConfiguration.logColumnListEmptyErrorMessage": "日志列列表不得为空。",
"xpack.infra.sourceConfiguration.logColumnsSectionTitle": "日志列",
"xpack.infra.sourceConfiguration.logIndicesDescription": "用于匹配包含日志数据的索引的索引模式",
"xpack.infra.sourceConfiguration.logIndicesLabel": "日志索引",