[Logs UI] Reimplement log source configuration routes in plain HTTP+JSON (#64021)

This commit is contained in:
Felix Stürmer 2020-04-28 11:12:50 +02:00 committed by GitHub
parent f9c81a30cb
commit 2fba7ed9f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2342 additions and 97 deletions

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
import { badRequestErrorRT, forbiddenErrorRT, routeTimingMetadataRT } from '../shared';
import { logSourceConfigurationRT } from './log_source_configuration';
/**
* request
*/
export const getLogSourceConfigurationRequestParamsRT = rt.type({
// the id of the source configuration
sourceId: rt.string,
});
export type GetLogSourceConfigurationRequestParams = rt.TypeOf<
typeof getLogSourceConfigurationRequestParamsRT
>;
/**
* response
*/
export const getLogSourceConfigurationSuccessResponsePayloadRT = rt.intersection([
rt.type({
data: logSourceConfigurationRT,
}),
rt.partial({
timing: routeTimingMetadataRT,
}),
]);
export type GetLogSourceConfigurationSuccessResponsePayload = rt.TypeOf<
typeof getLogSourceConfigurationSuccessResponsePayloadRT
>;
export const getLogSourceConfigurationErrorResponsePayloadRT = rt.union([
badRequestErrorRT,
forbiddenErrorRT,
]);
export type GetLogSourceConfigurationErrorReponsePayload = rt.TypeOf<
typeof getLogSourceConfigurationErrorResponsePayloadRT
>;
export const getLogSourceConfigurationResponsePayloadRT = rt.union([
getLogSourceConfigurationSuccessResponsePayloadRT,
getLogSourceConfigurationErrorResponsePayloadRT,
]);
export type GetLogSourceConfigurationReponsePayload = rt.TypeOf<
typeof getLogSourceConfigurationResponsePayloadRT
>;

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
import { routeTimingMetadataRT } from '../shared';
import {
getLogSourceConfigurationPath,
LOG_SOURCE_CONFIGURATION_PATH,
} from './log_source_configuration';
export const LOG_SOURCE_STATUS_PATH_SUFFIX = 'status';
export const LOG_SOURCE_STATUS_PATH = `${LOG_SOURCE_CONFIGURATION_PATH}/${LOG_SOURCE_STATUS_PATH_SUFFIX}`;
export const getLogSourceStatusPath = (sourceId: string) =>
`${getLogSourceConfigurationPath(sourceId)}/${LOG_SOURCE_STATUS_PATH_SUFFIX}`;
/**
* request
*/
export const getLogSourceStatusRequestParamsRT = rt.type({
// the id of the source configuration
sourceId: rt.string,
});
export type GetLogSourceStatusRequestParams = rt.TypeOf<typeof getLogSourceStatusRequestParamsRT>;
/**
* response
*/
const logIndexFieldRT = rt.strict({
name: rt.string,
type: rt.string,
searchable: rt.boolean,
aggregatable: rt.boolean,
});
export type LogIndexField = rt.TypeOf<typeof logIndexFieldRT>;
const logSourceStatusRT = rt.strict({
logIndexFields: rt.array(logIndexFieldRT),
logIndexNames: rt.array(rt.string),
});
export type LogSourceStatus = rt.TypeOf<typeof logSourceStatusRT>;
export const getLogSourceStatusSuccessResponsePayloadRT = rt.intersection([
rt.type({
data: logSourceStatusRT,
}),
rt.partial({
timing: routeTimingMetadataRT,
}),
]);
export type GetLogSourceStatusSuccessResponsePayload = rt.TypeOf<
typeof getLogSourceStatusSuccessResponsePayloadRT
>;

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './get_log_source_configuration';
export * from './get_log_source_status';
export * from './log_source_configuration';
export * from './patch_log_source_configuration';

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
export const LOG_SOURCE_CONFIGURATION_PATH_PREFIX = '/api/infra/log_source_configurations';
export const LOG_SOURCE_CONFIGURATION_PATH = `${LOG_SOURCE_CONFIGURATION_PATH_PREFIX}/{sourceId}`;
export const getLogSourceConfigurationPath = (sourceId: string) =>
`${LOG_SOURCE_CONFIGURATION_PATH_PREFIX}/${sourceId}`;
export const logSourceConfigurationOriginRT = rt.keyof({
fallback: null,
internal: null,
stored: null,
});
export type LogSourceConfigurationOrigin = rt.TypeOf<typeof logSourceConfigurationOriginRT>;
const logSourceFieldsConfigurationRT = rt.strict({
timestamp: rt.string,
tiebreaker: rt.string,
});
const logSourceCommonColumnConfigurationRT = rt.strict({
id: rt.string,
});
const logSourceTimestampColumnConfigurationRT = rt.strict({
timestampColumn: logSourceCommonColumnConfigurationRT,
});
const logSourceMessageColumnConfigurationRT = rt.strict({
messageColumn: logSourceCommonColumnConfigurationRT,
});
const logSourceFieldColumnConfigurationRT = rt.strict({
fieldColumn: rt.intersection([
logSourceCommonColumnConfigurationRT,
rt.strict({
field: rt.string,
}),
]),
});
const logSourceColumnConfigurationRT = rt.union([
logSourceTimestampColumnConfigurationRT,
logSourceMessageColumnConfigurationRT,
logSourceFieldColumnConfigurationRT,
]);
export const logSourceConfigurationPropertiesRT = rt.strict({
name: rt.string,
description: rt.string,
logAlias: rt.string,
fields: logSourceFieldsConfigurationRT,
logColumns: rt.array(logSourceColumnConfigurationRT),
});
export type LogSourceConfigurationProperties = rt.TypeOf<typeof logSourceConfigurationPropertiesRT>;
export const logSourceConfigurationRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
origin: logSourceConfigurationOriginRT,
configuration: logSourceConfigurationPropertiesRT,
}),
rt.partial({
updatedAt: rt.number,
version: rt.string,
}),
])
);
export type LogSourceConfiguration = rt.TypeOf<typeof logSourceConfigurationRT>;

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as rt from 'io-ts';
import { badRequestErrorRT, forbiddenErrorRT } from '../shared';
import { getLogSourceConfigurationSuccessResponsePayloadRT } from './get_log_source_configuration';
import { logSourceConfigurationPropertiesRT } from './log_source_configuration';
/**
* request
*/
export const patchLogSourceConfigurationRequestParamsRT = rt.type({
// the id of the source configuration
sourceId: rt.string,
});
export type PatchLogSourceConfigurationRequestParams = rt.TypeOf<
typeof patchLogSourceConfigurationRequestParamsRT
>;
const logSourceConfigurationProperiesPatchRT = rt.partial({
...logSourceConfigurationPropertiesRT.type.props,
fields: rt.partial(logSourceConfigurationPropertiesRT.type.props.fields.type.props),
});
export type LogSourceConfigurationPropertiesPatch = rt.TypeOf<
typeof logSourceConfigurationProperiesPatchRT
>;
export const patchLogSourceConfigurationRequestBodyRT = rt.type({
data: logSourceConfigurationProperiesPatchRT,
});
export type PatchLogSourceConfigurationRequestBody = rt.TypeOf<
typeof patchLogSourceConfigurationRequestBodyRT
>;
/**
* response
*/
export const patchLogSourceConfigurationSuccessResponsePayloadRT = getLogSourceConfigurationSuccessResponsePayloadRT;
export type PatchLogSourceConfigurationSuccessResponsePayload = rt.TypeOf<
typeof patchLogSourceConfigurationSuccessResponsePayloadRT
>;
export const patchLogSourceConfigurationResponsePayloadRT = rt.union([
patchLogSourceConfigurationSuccessResponsePayloadRT,
badRequestErrorRT,
forbiddenErrorRT,
]);
export type PatchLogSourceConfigurationReponsePayload = rt.TypeOf<
typeof patchLogSourceConfigurationResponsePayloadRT
>;

View file

@ -9,6 +9,7 @@ import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { Errors, Type } from 'io-ts';
import { failure } from 'io-ts/lib/PathReporter';
import { RouteValidationFunction } from 'kibana/server';
type ErrorFactory = (message: string) => Error;
@ -18,8 +19,21 @@ export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => {
throw createError(failure(errors).join('\n'));
};
export const decodeOrThrow = <A, O, I>(
runtimeType: Type<A, O, I>,
export const decodeOrThrow = <DecodedValue, EncodedValue, InputValue>(
runtimeType: Type<DecodedValue, EncodedValue, InputValue>,
createError: ErrorFactory = createPlainError
) => (inputValue: I) =>
) => (inputValue: InputValue) =>
pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity));
type ValdidationResult<Value> = ReturnType<RouteValidationFunction<Value>>;
export const createValidationFunction = <DecodedValue, EncodedValue, InputValue>(
runtimeType: Type<DecodedValue, EncodedValue, InputValue>
): RouteValidationFunction<DecodedValue> => (inputValue, { badRequest, ok }) =>
pipe(
runtimeType.decode(inputValue),
fold<Errors, DecodedValue, ValdidationResult<DecodedValue>>(
(errors: Errors) => badRequest(failure(errors).join('\n')),
(result: DecodedValue) => ok(result)
)
);

View file

@ -4,5 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './input_fields';
export { SourceConfigurationSettings } from './source_configuration_settings';
export { ViewSourceConfigurationButton } from './view_source_configuration_button';

View file

@ -8,11 +8,11 @@ import createContainer from 'constate';
import { isString } from 'lodash';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { LogEntriesItem } from '../../../common/http_api';
import { UrlStateContainer } from '../../utils/url_state';
import { useTrackedPromise } from '../../utils/use_tracked_promise';
import { Source } from '../source';
import { fetchLogEntriesItem } from './log_entries/api/fetch_log_entries_item';
import { LogEntriesItem } from '../../../common/http_api';
import { useLogSourceContext } from './log_source';
export enum FlyoutVisibility {
hidden = 'hidden',
@ -26,7 +26,7 @@ export interface FlyoutOptionsUrlState {
}
export const useLogFlyout = () => {
const { sourceId } = useContext(Source.Context);
const { sourceId } = useLogSourceContext();
const [flyoutVisible, setFlyoutVisibility] = useState<boolean>(false);
const [flyoutId, setFlyoutId] = useState<string | null>(null);
const [flyoutItem, setFlyoutItem] = useState<LogEntriesItem | null>(null);

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getLogSourceConfigurationPath,
getLogSourceConfigurationSuccessResponsePayloadRT,
} from '../../../../../common/http_api/log_sources';
import { decodeOrThrow } from '../../../../../common/runtime_types';
import { npStart } from '../../../../legacy_singletons';
export const callFetchLogSourceConfigurationAPI = async (sourceId: string) => {
const response = await npStart.http.fetch(getLogSourceConfigurationPath(sourceId), {
method: 'GET',
});
return decodeOrThrow(getLogSourceConfigurationSuccessResponsePayloadRT)(response);
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getLogSourceStatusPath,
getLogSourceStatusSuccessResponsePayloadRT,
} from '../../../../../common/http_api/log_sources';
import { decodeOrThrow } from '../../../../../common/runtime_types';
import { npStart } from '../../../../legacy_singletons';
export const callFetchLogSourceStatusAPI = async (sourceId: string) => {
const response = await npStart.http.fetch(getLogSourceStatusPath(sourceId), {
method: 'GET',
});
return decodeOrThrow(getLogSourceStatusSuccessResponsePayloadRT)(response);
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getLogSourceConfigurationPath,
patchLogSourceConfigurationSuccessResponsePayloadRT,
patchLogSourceConfigurationRequestBodyRT,
LogSourceConfigurationPropertiesPatch,
} from '../../../../../common/http_api/log_sources';
import { decodeOrThrow } from '../../../../../common/runtime_types';
import { npStart } from '../../../../legacy_singletons';
export const callPatchLogSourceConfigurationAPI = async (
sourceId: string,
patchedProperties: LogSourceConfigurationPropertiesPatch
) => {
const response = await npStart.http.fetch(getLogSourceConfigurationPath(sourceId), {
method: 'PATCH',
body: JSON.stringify(
patchLogSourceConfigurationRequestBodyRT.encode({
data: patchedProperties,
})
),
});
return decodeOrThrow(patchLogSourceConfigurationSuccessResponsePayloadRT)(response);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './log_source';

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import createContainer from 'constate';
import { useState, useMemo, useCallback } from 'react';
import {
LogSourceConfiguration,
LogSourceStatus,
LogSourceConfigurationPropertiesPatch,
LogSourceConfigurationProperties,
} from '../../../../common/http_api/log_sources';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
import { callFetchLogSourceConfigurationAPI } from './api/fetch_log_source_configuration';
import { callFetchLogSourceStatusAPI } from './api/fetch_log_source_status';
import { callPatchLogSourceConfigurationAPI } from './api/patch_log_source_configuration';
export {
LogSourceConfiguration,
LogSourceConfigurationProperties,
LogSourceConfigurationPropertiesPatch,
LogSourceStatus,
};
export const useLogSource = ({ sourceId }: { sourceId: string }) => {
const [sourceConfiguration, setSourceConfiguration] = useState<
LogSourceConfiguration | undefined
>(undefined);
const [sourceStatus, setSourceStatus] = useState<LogSourceStatus | undefined>(undefined);
const [loadSourceConfigurationRequest, loadSourceConfiguration] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async () => {
return await callFetchLogSourceConfigurationAPI(sourceId);
},
onResolve: ({ data }) => {
setSourceConfiguration(data);
},
},
[sourceId]
);
const [updateSourceConfigurationRequest, updateSourceConfiguration] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async (patchedProperties: LogSourceConfigurationPropertiesPatch) => {
return await callPatchLogSourceConfigurationAPI(sourceId, patchedProperties);
},
onResolve: ({ data }) => {
setSourceConfiguration(data);
loadSourceStatus();
},
},
[sourceId]
);
const [loadSourceStatusRequest, loadSourceStatus] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async () => {
return await callFetchLogSourceStatusAPI(sourceId);
},
onResolve: ({ data }) => {
setSourceStatus(data);
},
},
[sourceId]
);
const logIndicesExist = useMemo(() => (sourceStatus?.logIndexNames?.length ?? 0) > 0, [
sourceStatus,
]);
const derivedIndexPattern = useMemo(
() => ({
fields: sourceStatus?.logIndexFields ?? [],
title: sourceConfiguration?.configuration.name ?? 'unknown',
}),
[sourceConfiguration, sourceStatus]
);
const isLoadingSourceConfiguration = useMemo(
() => loadSourceConfigurationRequest.state === 'pending',
[loadSourceConfigurationRequest.state]
);
const isUpdatingSourceConfiguration = useMemo(
() => updateSourceConfigurationRequest.state === 'pending',
[updateSourceConfigurationRequest.state]
);
const isLoadingSourceStatus = useMemo(() => loadSourceStatusRequest.state === 'pending', [
loadSourceStatusRequest.state,
]);
const isLoading = useMemo(
() => isLoadingSourceConfiguration || isLoadingSourceStatus || isUpdatingSourceConfiguration,
[isLoadingSourceConfiguration, isLoadingSourceStatus, isUpdatingSourceConfiguration]
);
const isUninitialized = useMemo(
() =>
loadSourceConfigurationRequest.state === 'uninitialized' ||
loadSourceStatusRequest.state === 'uninitialized',
[loadSourceConfigurationRequest.state, loadSourceStatusRequest.state]
);
const hasFailedLoadingSource = useMemo(
() => loadSourceConfigurationRequest.state === 'rejected',
[loadSourceConfigurationRequest.state]
);
const loadSourceFailureMessage = useMemo(
() =>
loadSourceConfigurationRequest.state === 'rejected'
? `${loadSourceConfigurationRequest.value}`
: undefined,
[loadSourceConfigurationRequest]
);
const loadSource = useCallback(() => {
return Promise.all([loadSourceConfiguration(), loadSourceStatus()]);
}, [loadSourceConfiguration, loadSourceStatus]);
const initialize = useCallback(async () => {
if (!isUninitialized) {
return;
}
return await loadSource();
}, [isUninitialized, loadSource]);
return {
derivedIndexPattern,
hasFailedLoadingSource,
initialize,
isLoading,
isLoadingSourceConfiguration,
isLoadingSourceStatus,
isUninitialized,
loadSource,
loadSourceFailureMessage,
loadSourceConfiguration,
loadSourceStatus,
logIndicesExist,
sourceConfiguration,
sourceId,
sourceStatus,
updateSourceConfiguration,
};
};
export const [LogSourceProvider, useLogSourceContext] = createContainer(useLogSource);

View file

@ -8,10 +8,10 @@ import { useContext } from 'react';
import { useThrottle } from 'react-use';
import { RendererFunction } from '../../../utils/typed_react';
import { Source } from '../../source';
import { LogSummaryBuckets, useLogSummary } from './log_summary';
import { LogFilterState } from '../log_filter';
import { LogPositionState } from '../log_position';
import { useLogSourceContext } from '../log_source';
const FETCH_THROTTLE_INTERVAL = 3000;
@ -24,7 +24,7 @@ export const WithSummary = ({
end: number | null;
}>;
}) => {
const { sourceId } = useContext(Source.Context);
const { sourceId } = useLogSourceContext();
const { filterQuery } = useContext(LogFilterState.Context);
const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context);

View file

@ -17,7 +17,7 @@ import {
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis';
import { useSourceContext } from '../../../containers/source';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { LogEntryCategoriesResultsContent } from './page_results_content';
import { LogEntryCategoriesSetupContent } from './page_setup_content';
import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module';
@ -25,11 +25,11 @@ import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_m
export const LogEntryCategoriesPageContent = () => {
const {
hasFailedLoadingSource,
isLoadingSource,
isLoading,
isUninitialized,
loadSource,
loadSourceFailureMessage,
} = useSourceContext();
} = useLogSourceContext();
const {
hasLogAnalysisCapabilites,
@ -45,7 +45,7 @@ export const LogEntryCategoriesPageContent = () => {
}
}, [fetchJobStatus, hasLogAnalysisReadCapabilities]);
if (isLoadingSource || isUninitialized) {
if (isLoading || isUninitialized) {
return <SourceLoadingPage />;
} else if (hasFailedLoadingSource) {
return <SourceErrorPage errorMessage={loadSourceFailureMessage ?? ''} retry={loadSource} />;

View file

@ -6,20 +6,20 @@
import React from 'react';
import { useSourceContext } from '../../../containers/source';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id';
import { LogEntryCategoriesModuleProvider } from './use_log_entry_categories_module';
export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ children }) => {
const { sourceId, source } = useSourceContext();
const { sourceId, sourceConfiguration } = useLogSourceContext();
const spaceId = useKibanaSpaceId();
return (
<LogEntryCategoriesModuleProvider
indexPattern={source ? source.configuration.logAlias : ''}
indexPattern={sourceConfiguration?.configuration.logAlias ?? ''}
sourceId={sourceId}
spaceId={spaceId}
timestampField={source ? source.configuration.fields.timestamp : ''}
timestampField={sourceConfiguration?.configuration.fields.timestamp ?? ''}
>
{children}
</LogEntryCategoriesModuleProvider>

View file

@ -17,7 +17,7 @@ import {
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis';
import { useSourceContext } from '../../../containers/source';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { LogEntryRateResultsContent } from './page_results_content';
import { LogEntryRateSetupContent } from './page_setup_content';
import { useLogEntryRateModuleContext } from './use_log_entry_rate_module';
@ -25,11 +25,11 @@ import { useLogEntryRateModuleContext } from './use_log_entry_rate_module';
export const LogEntryRatePageContent = () => {
const {
hasFailedLoadingSource,
isLoadingSource,
isLoading,
isUninitialized,
loadSource,
loadSourceFailureMessage,
} = useSourceContext();
} = useLogSourceContext();
const {
hasLogAnalysisCapabilites,
@ -45,7 +45,7 @@ export const LogEntryRatePageContent = () => {
}
}, [fetchJobStatus, hasLogAnalysisReadCapabilities]);
if (isLoadingSource || isUninitialized) {
if (isLoading || isUninitialized) {
return <SourceLoadingPage />;
} else if (hasFailedLoadingSource) {
return <SourceErrorPage errorMessage={loadSourceFailureMessage ?? ''} retry={loadSource} />;

View file

@ -6,20 +6,20 @@
import React from 'react';
import { useSourceContext } from '../../../containers/source';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id';
import { LogEntryRateModuleProvider } from './use_log_entry_rate_module';
export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => {
const { sourceId, source } = useSourceContext();
const { sourceId, sourceConfiguration } = useLogSourceContext();
const spaceId = useKibanaSpaceId();
return (
<LogEntryRateModuleProvider
indexPattern={source ? source.configuration.logAlias : ''}
indexPattern={sourceConfiguration?.configuration.logAlias ?? ''}
sourceId={sourceId}
spaceId={spaceId}
timestampField={source ? source.configuration.fields.timestamp : ''}
timestampField={sourceConfiguration?.configuration.fields.timestamp ?? ''}
>
{children}
</LogEntryRateModuleProvider>

View file

@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { useMount } from 'react-use';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { DocumentTitle } from '../../components/document_title';
@ -17,6 +18,7 @@ import { AppNavigation } from '../../components/navigation/app_navigation';
import { RoutedTabs } from '../../components/navigation/routed_tabs';
import { ColumnarPage } from '../../components/page';
import { useLogAnalysisCapabilitiesContext } from '../../containers/logs/log_analysis';
import { useLogSourceContext } from '../../containers/logs/log_source';
import { RedirectWithQueryParams } from '../../utils/redirect_with_query_params';
import { LogEntryCategoriesPage } from './log_entry_categories';
import { LogEntryRatePage } from './log_entry_rate';
@ -28,6 +30,12 @@ export const LogsPageContent: React.FunctionComponent = () => {
const uiCapabilities = useKibana().services.application?.capabilities;
const logAnalysisCapabilities = useLogAnalysisCapabilitiesContext();
const { initialize } = useLogSourceContext();
useMount(() => {
initialize();
});
const streamTab = {
app: 'logs',
title: streamTabTitle,

View file

@ -6,15 +6,16 @@
import React from 'react';
import { LogAnalysisCapabilitiesProvider } from '../../containers/logs/log_analysis';
import { SourceProvider } from '../../containers/source';
import { LogSourceProvider } from '../../containers/logs/log_source';
// import { SourceProvider } from '../../containers/source';
import { useSourceId } from '../../containers/source_id';
export const LogsPageProviders: React.FunctionComponent = ({ children }) => {
const [sourceId] = useSourceId();
return (
<SourceProvider sourceId={sourceId}>
<LogSourceProvider sourceId={sourceId}>
<LogAnalysisCapabilitiesProvider>{children}</LogAnalysisCapabilitiesProvider>
</SourceProvider>
</LogSourceProvider>
);
};

View file

@ -1,19 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
export const LogsSettingsPage = () => {
const uiCapabilities = useKibana().services.application?.capabilities;
return (
<SourceConfigurationSettings
shouldAllowEdit={uiCapabilities?.logs?.configureSource as boolean}
displaySettings="logs"
/>
);
};

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBadge,
EuiButton,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableOption,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { euiStyled } from '../../../../../observability/public';
import { LogColumnConfiguration } from '../../../utils/source_configuration';
import { useVisibilityState } from '../../../utils/use_visibility_state';
interface SelectableColumnOption {
optionProps: EuiSelectableOption;
columnConfiguration: LogColumnConfiguration;
}
export const AddLogColumnButtonAndPopover: React.FunctionComponent<{
addLogColumn: (logColumnConfiguration: LogColumnConfiguration) => void;
availableFields: string[];
isDisabled?: boolean;
}> = ({ addLogColumn, availableFields, isDisabled }) => {
const { isVisible: isOpen, show: openPopover, hide: closePopover } = useVisibilityState(false);
const availableColumnOptions = useMemo<SelectableColumnOption[]>(
() => [
{
optionProps: {
append: <SystemColumnBadge />,
'data-test-subj': 'addTimestampLogColumn',
// this key works around EuiSelectable using a lowercased label as
// key, which leads to conflicts with field names
key: 'timestamp',
label: 'Timestamp',
},
columnConfiguration: {
timestampColumn: {
id: uuidv4(),
},
},
},
{
optionProps: {
'data-test-subj': 'addMessageLogColumn',
append: <SystemColumnBadge />,
// this key works around EuiSelectable using a lowercased label as
// key, which leads to conflicts with field names
key: 'message',
label: 'Message',
},
columnConfiguration: {
messageColumn: {
id: uuidv4(),
},
},
},
...availableFields.map<SelectableColumnOption>(field => ({
optionProps: {
'data-test-subj': `addFieldLogColumn addFieldLogColumn:${field}`,
// this key works around EuiSelectable using a lowercased label as
// key, which leads to conflicts with fields that only differ in the
// case (e.g. the metricbeat mongodb module)
key: `field-${field}`,
label: field,
},
columnConfiguration: {
fieldColumn: {
id: uuidv4(),
field,
},
},
})),
],
[availableFields]
);
const availableOptions = useMemo<EuiSelectableOption[]>(
() => availableColumnOptions.map(availableColumnOption => availableColumnOption.optionProps),
[availableColumnOptions]
);
const handleColumnSelection = useCallback(
(selectedOptions: EuiSelectableOption[]) => {
closePopover();
const selectedOptionIndex = selectedOptions.findIndex(
selectedOption => selectedOption.checked === 'on'
);
const selectedOption = availableColumnOptions[selectedOptionIndex];
addLogColumn(selectedOption.columnConfiguration);
},
[addLogColumn, availableColumnOptions, closePopover]
);
return (
<EuiPopover
anchorPosition="downRight"
button={
<EuiButton
data-test-subj="addLogColumnButton"
isDisabled={isDisabled}
iconType="plusInCircle"
onClick={openPopover}
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.addLogColumnButtonLabel"
defaultMessage="Add column"
/>
</EuiButton>
}
closePopover={closePopover}
id="addLogColumn"
isOpen={isOpen}
ownFocus
panelPaddingSize="none"
>
<EuiSelectable
height={600}
listProps={selectableListProps}
onChange={handleColumnSelection}
options={availableOptions}
searchable
searchProps={searchProps}
singleSelection
>
{(list, search) => (
<SelectableContent data-test-subj="addLogColumnPopover">
<EuiPopoverTitle>{search}</EuiPopoverTitle>
{list}
</SelectableContent>
)}
</EuiSelectable>
</EuiPopover>
);
};
const searchProps = {
'data-test-subj': 'fieldSearchInput',
};
const selectableListProps = {
showIcons: false,
};
const SystemColumnBadge: React.FunctionComponent = () => (
<EuiBadge>
<FormattedMessage
id="xpack.infra.sourceConfiguration.systemColumnBadgeLabel"
defaultMessage="System"
/>
</EuiBadge>
);
const SelectableContent = euiStyled.div`
width: 400px;
`;

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiCallOut,
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { InputFieldProps } from '../../../components/source_configuration';
interface FieldsConfigurationPanelProps {
isLoading: boolean;
readOnly: boolean;
tiebreakerFieldProps: InputFieldProps;
timestampFieldProps: InputFieldProps;
}
export const FieldsConfigurationPanel = ({
isLoading,
readOnly,
tiebreakerFieldProps,
timestampFieldProps,
}: FieldsConfigurationPanelProps) => {
const isTimestampValueDefault = timestampFieldProps.value === '@timestamp';
const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc';
return (
<EuiForm>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.infra.sourceConfiguration.fieldsSectionTitle"
defaultMessage="Fields"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiCallOut
title={i18n.translate('xpack.infra.sourceConfiguration.deprecationNotice', {
defaultMessage: 'Deprecation Notice',
})}
color="warning"
iconType="help"
>
<p>
<FormattedMessage
id="xpack.infra.sourceConfiguration.deprecationMessage"
defaultMessage="Configuring these fields have been deprecated and will be removed in 8.0.0. This application is designed to work with {ecsLink}, you should adjust your indexing to use the {documentationLink}."
values={{
documentationLink: (
<EuiLink
href="https://www.elastic.co/guide/en/infrastructure/guide/7.4/infrastructure-metrics.html"
target="BLANK"
>
<FormattedMessage
id="xpack.infra.sourceConfiguration.documentedFields"
defaultMessage="documented fields"
/>
</EuiLink>
),
ecsLink: (
<EuiLink
href="https://www.elastic.co/guide/en/ecs/current/index.html"
target="BLANK"
>
ECS
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.infra.sourceConfiguration.timestampFieldLabel"
defaultMessage="Timestamp"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.infra.sourceConfiguration.timestampFieldDescription"
defaultMessage="Timestamp used to sort log entries"
/>
}
>
<EuiFormRow
error={timestampFieldProps.error}
fullWidth
helpText={
<FormattedMessage
id="xpack.infra.sourceConfiguration.timestampFieldRecommendedValue"
defaultMessage="The recommended value is {defaultValue}"
values={{
defaultValue: <EuiCode>@timestamp</EuiCode>,
}}
/>
}
isInvalid={timestampFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.timestampFieldLabel"
defaultMessage="Timestamp"
/>
}
>
<EuiFieldText
fullWidth
disabled={isLoading || isTimestampValueDefault}
readOnly={readOnly}
isLoading={isLoading}
{...timestampFieldProps}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.infra.sourceConfiguration.tiebreakerFieldLabel"
defaultMessage="Tiebreaker"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.infra.sourceConfiguration.tiebreakerFieldDescription"
defaultMessage="Field used to break ties between two entries with the same timestamp"
/>
}
>
<EuiFormRow
error={tiebreakerFieldProps.error}
fullWidth
helpText={
<FormattedMessage
id="xpack.infra.sourceConfiguration.tiebreakerFieldRecommendedValue"
defaultMessage="The recommended value is {defaultValue}"
values={{
defaultValue: <EuiCode>_doc</EuiCode>,
}}
/>
}
isInvalid={tiebreakerFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.tiebreakerFieldLabel"
defaultMessage="Tiebreaker"
/>
}
>
<EuiFieldText
fullWidth
disabled={isLoading || isTiebreakerValueDefault}
readOnly={readOnly}
isLoading={isLoading}
{...tiebreakerFieldProps}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiForm>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './source_configuration_settings';

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactNode, useCallback, useMemo, useState } from 'react';
import {
createInputFieldProps,
validateInputFieldNotEmpty,
} from '../../../components/source_configuration/input_fields';
interface FormState {
name: string;
description: string;
logAlias: string;
tiebreakerField: string;
timestampField: string;
}
type FormStateChanges = Partial<FormState>;
export const useLogIndicesConfigurationFormState = ({
initialFormState = defaultFormState,
}: {
initialFormState?: FormState;
}) => {
const [formStateChanges, setFormStateChanges] = useState<FormStateChanges>({});
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,
}),
[formState.name]
);
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,
};
};
const defaultFormState: FormState = {
name: '',
description: '',
logAlias: '',
tiebreakerField: '',
timestampField: '',
};

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
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';
interface IndicesConfigurationPanelProps {
isLoading: boolean;
readOnly: boolean;
logAliasFieldProps: InputFieldProps;
}
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>filebeat-*</EuiCode>,
}}
/>
}
isInvalid={logAliasFieldProps.isInvalid}
label={
<FormattedMessage
id="xpack.infra.sourceConfiguration.logIndicesLabel"
defaultMessage="Log indices"
/>
}
>
<EuiFieldText
data-test-subj="logIndicesInput"
fullWidth
disabled={isLoading}
isLoading={isLoading}
readOnly={readOnly}
{...logAliasFieldProps}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiForm>
);

View file

@ -0,0 +1,153 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React, { useCallback, useMemo, useState } from 'react';
import {
FieldLogColumnConfiguration,
isMessageLogColumnConfiguration,
isTimestampLogColumnConfiguration,
LogColumnConfiguration,
MessageLogColumnConfiguration,
TimestampLogColumnConfiguration,
} from '../../../utils/source_configuration';
export interface TimestampLogColumnConfigurationProps {
logColumnConfiguration: TimestampLogColumnConfiguration['timestampColumn'];
remove: () => void;
type: 'timestamp';
}
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: [],
};

View file

@ -0,0 +1,276 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButtonIcon,
EuiDragDropContext,
EuiDraggable,
EuiDroppable,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiIcon,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
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';
interface LogColumnsConfigurationPanelProps {
availableFields: string[];
isLoading: boolean;
logColumnConfiguration: LogColumnConfigurationProps[];
addLogColumn: (logColumn: LogColumnConfiguration) => void;
moveLogColumn: (sourceIndex: number, destinationIndex: number) => void;
}
export const LogColumnsConfigurationPanel: React.FunctionComponent<LogColumnsConfigurationPanelProps> = ({
addLogColumn,
moveLogColumn,
availableFields,
isLoading,
logColumnConfiguration,
}) => {
const onDragEnd = useCallback(
({ source, destination }: DropResult) =>
destination && moveLogColumn(source.index, destination.index),
[moveLogColumn]
);
return (
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="s" data-test-subj="sourceConfigurationLogColumnsSectionTitle">
<h3>
<FormattedMessage
id="xpack.infra.sourceConfiguration.logColumnsSectionTitle"
defaultMessage="Log Columns"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddLogColumnButtonAndPopover
addLogColumn={addLogColumn}
availableFields={availableFields}
isDisabled={isLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
{logColumnConfiguration.length > 0 ? (
<EuiDragDropContext onDragEnd={onDragEnd}>
<EuiDroppable droppableId="COLUMN_CONFIG_DROPPABLE_AREA">
<>
{/* Fragment here necessary for typechecking */}
{logColumnConfiguration.map((column, index) => (
<EuiDraggable
key={`logColumnConfigurationPanel-${column.logColumnConfiguration.id}`}
index={index}
draggableId={column.logColumnConfiguration.id}
customDragHandle
>
{provided => (
<LogColumnConfigurationPanel
dragHandleProps={provided.dragHandleProps}
logColumnConfigurationProps={column}
/>
)}
</EuiDraggable>
))}
</>
</EuiDroppable>
</EuiDragDropContext>
) : (
<LogColumnConfigurationEmptyPrompt />
)}
</EuiForm>
);
};
interface LogColumnConfigurationPanelProps {
logColumnConfigurationProps: LogColumnConfigurationProps;
dragHandleProps: DragHandleProps;
}
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,
}) => (
<ExplainedLogColumnConfigurationPanel
fieldName="Timestamp"
helpText={
<FormattedMessage
tagName="span"
id="xpack.infra.sourceConfiguration.timestampLogColumnDescription"
defaultMessage="This system field shows the log entry's time as determined by the {timestampSetting} field setting."
values={{
timestampSetting: <code>timestamp</code>,
}}
/>
}
removeColumn={logColumnConfigurationProps.remove}
dragHandleProps={dragHandleProps}
/>
);
const MessageLogColumnConfigurationPanel: React.FunctionComponent<LogColumnConfigurationPanelProps> = ({
logColumnConfigurationProps,
dragHandleProps,
}) => (
<ExplainedLogColumnConfigurationPanel
fieldName="Message"
helpText={
<FormattedMessage
tagName="span"
id="xpack.infra.sourceConfiguration.messageLogColumnDescription"
defaultMessage="This system field shows the log entry message as derived from the document fields."
/>
}
removeColumn={logColumnConfigurationProps.remove}
dragHandleProps={dragHandleProps}
/>
);
const FieldLogColumnConfigurationPanel: React.FunctionComponent<{
logColumnConfigurationProps: FieldLogColumnConfigurationProps;
dragHandleProps: DragHandleProps;
}> = ({
logColumnConfigurationProps: {
logColumnConfiguration: { field },
remove,
},
dragHandleProps,
}) => {
const fieldLogColumnTitle = i18n.translate(
'xpack.infra.sourceConfiguration.fieldLogColumnTitle',
{
defaultMessage: 'Field',
}
);
return (
<EuiPanel data-test-subj={`logColumnPanel fieldLogColumnPanel fieldLogColumnPanel:${field}`}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<div data-test-subj="moveLogColumnHandle" {...dragHandleProps}>
<EuiIcon type="grab" />
</div>
</EuiFlexItem>
<EuiFlexItem grow={1}>{fieldLogColumnTitle}</EuiFlexItem>
<EuiFlexItem grow={3}>
<code>{field}</code>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveLogColumnButton
onClick={remove}
columnDescription={`${fieldLogColumnTitle} - ${field}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{
fieldName: React.ReactNode;
helpText: React.ReactNode;
removeColumn: () => void;
dragHandleProps: DragHandleProps;
}> = ({ fieldName, helpText, removeColumn, dragHandleProps }) => (
<EuiPanel
data-test-subj={`logColumnPanel systemLogColumnPanel systemLogColumnPanel:${fieldName}`}
>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<div data-test-subj="moveLogColumnHandle" {...dragHandleProps}>
<EuiIcon type="grab" />
</div>
</EuiFlexItem>
<EuiFlexItem grow={1}>{fieldName}</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiText size="s" color="subdued">
{helpText}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveLogColumnButton onClick={removeColumn} columnDescription={String(fieldName)} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
const RemoveLogColumnButton: React.FunctionComponent<{
onClick?: () => void;
columnDescription: string;
}> = ({ onClick, columnDescription }) => {
const removeColumnLabel = i18n.translate(
'xpack.infra.sourceConfiguration.removeLogColumnButtonLabel',
{
defaultMessage: 'Remove {columnDescription} column',
values: { columnDescription },
}
);
return (
<EuiButtonIcon
color="danger"
data-test-subj="removeLogColumnButton"
iconType="trash"
onClick={onClick}
title={removeColumnLabel}
aria-label={removeColumnLabel}
/>
);
};
const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => (
<EuiEmptyPrompt
iconType="list"
title={
<h2>
<FormattedMessage
id="xpack.infra.sourceConfiguration.noLogColumnsTitle"
defaultMessage="No columns"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.infra.sourceConfiguration.noLogColumnsDescription"
defaultMessage="Add a column to this list using the button above."
/>
</p>
}
/>
);

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useMemo } from 'react';
import { LogSourceConfigurationProperties } from '../../../containers/logs/log_source';
import { useLogIndicesConfigurationFormState } from './indices_configuration_form_state';
import { useLogColumnsConfigurationFormState } from './log_columns_configuration_form_state';
export const useLogSourceConfigurationFormState = (
configuration?: LogSourceConfigurationProperties
) => {
const indicesConfigurationFormState = useLogIndicesConfigurationFormState({
initialFormState: useMemo(
() =>
configuration
? {
name: configuration.name,
description: configuration.description,
logAlias: configuration.logAlias,
tiebreakerField: configuration.fields.tiebreaker,
timestampField: configuration.fields.timestamp,
}
: undefined,
[configuration]
),
});
const logColumnsConfigurationFormState = useLogColumnsConfigurationFormState({
initialFormState: useMemo(
() =>
configuration
? {
logColumns: configuration.logColumns,
}
: undefined,
[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 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,
]
);
return {
addLogColumn: logColumnsConfigurationFormState.addLogColumn,
moveLogColumn: logColumnsConfigurationFormState.moveLogColumn,
errors,
formState,
formStateChanges,
isFormDirty,
isFormValid,
indicesConfigurationProps: indicesConfigurationFormState.fieldProps,
logColumnConfigurationProps: logColumnsConfigurationFormState.logColumnConfigurationProps,
resetForm,
};
};

View file

@ -0,0 +1,193 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiPage,
EuiPageBody,
} 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 { SourceLoadingPage } from '../../../components/source_loading_page';
import { Prompt } from '../../../utils/navigation_warning_prompt';
export const LogsSettingsPage = () => {
const uiCapabilities = useKibana().services.application?.capabilities;
const shouldAllowEdit = uiCapabilities?.logs?.configureSource === true;
const {
sourceConfiguration: source,
sourceStatus,
isLoading,
isUninitialized,
updateSourceConfiguration,
} = useLogSourceContext();
const availableFields = useMemo(
() => sourceStatus?.logIndexFields.map(field => field.name) ?? [],
[sourceStatus]
);
const {
addLogColumn,
moveLogColumn,
indicesConfigurationProps,
logColumnConfigurationProps,
errors,
resetForm,
isFormDirty,
isFormValid,
formStateChanges,
} = useLogSourceConfigurationFormState(source?.configuration);
const persistUpdates = useCallback(async () => {
await updateSourceConfiguration(formStateChanges);
resetForm();
}, [updateSourceConfiguration, resetForm, formStateChanges]);
const isWriteable = useMemo(() => shouldAllowEdit && source && source.origin !== 'internal', [
shouldAllowEdit,
source,
]);
if ((isLoading || isUninitialized) && !source) {
return <SourceLoadingPage />;
}
if (!source?.configuration) {
return null;
}
return (
<>
<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}
/>
</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>
) : (
<>
<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>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPageBody>
</EuiPage>
</>
);
};
const unsavedFormPromptMessage = i18n.translate(
'xpack.infra.logSourceConfiguration.unsavedFormPromptMessage',
{
defaultMessage: 'Are you sure you want to leave? Changes will be lost',
}
);

View file

@ -5,13 +5,11 @@
*/
import React from 'react';
import { useTrackPageview } from '../../../../../observability/public';
import { ColumnarPage } from '../../../components/page';
import { StreamPageContent } from './page_content';
import { StreamPageHeader } from './page_header';
import { LogsPageProviders } from './page_providers';
import { PageViewLogInContext } from './page_view_log_in_context';
import { useTrackPageview } from '../../../../../observability/public';
export const StreamPage = () => {
useTrackPageview({ app: 'infra_logs', path: 'stream' });
@ -22,7 +20,6 @@ export const StreamPage = () => {
<StreamPageHeader />
<StreamPageContent />
</ColumnarPage>
<PageViewLogInContext />
</LogsPageProviders>
);
};

View file

@ -7,21 +7,21 @@
import React from 'react';
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
import { useSourceContext } from '../../../containers/source';
import { LogsPageLogsContent } from './page_logs_content';
import { LogsPageNoIndicesContent } from './page_no_indices_content';
import { useLogSourceContext } from '../../../containers/logs/log_source';
export const StreamPageContent: React.FunctionComponent = () => {
const {
hasFailedLoadingSource,
isLoadingSource,
isLoading,
isUninitialized,
loadSource,
loadSourceFailureMessage,
logIndicesExist,
} = useSourceContext();
} = useLogSourceContext();
if (isLoadingSource || isUninitialized) {
if (isLoading || isUninitialized) {
return <SourceLoadingPage />;
} else if (hasFailedLoadingSource) {
return <SourceErrorPage errorMessage={loadSourceFailureMessage ?? ''} retry={loadSource} />;

View file

@ -5,32 +5,30 @@
*/
import React, { useContext } from 'react';
import { euiStyled } from '../../../../../observability/public';
import { AutoSizer } from '../../../components/auto_sizer';
import { LogEntryFlyout } from '../../../components/logging/log_entry_flyout';
import { LogMinimap } from '../../../components/logging/log_minimap';
import { ScrollableLogTextStreamView } from '../../../components/logging/log_text_stream';
import { PageContent } from '../../../components/page';
import { WithSummary } from '../../../containers/logs/log_summary';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { LogFilterState } from '../../../containers/logs/log_filter';
import {
LogFlyout as LogFlyoutState,
WithFlyoutOptionsUrlState,
} from '../../../containers/logs/log_flyout';
import { LogHighlightsState } from '../../../containers/logs/log_highlights';
import { LogPositionState } from '../../../containers/logs/log_position';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { WithSummary } from '../../../containers/logs/log_summary';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { WithLogTextviewUrlState } from '../../../containers/logs/with_log_textview';
import { WithStreamItems } from '../../../containers/logs/with_stream_items';
import { Source } from '../../../containers/source';
import { LogsToolbar } from './page_toolbar';
import { LogHighlightsState } from '../../../containers/logs/log_highlights';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { PageViewLogInContext } from './page_view_log_in_context';
export const LogsPageLogsContent: React.FunctionComponent = () => {
const { source, sourceId, version } = useContext(Source.Context);
const { sourceConfiguration, sourceId } = useLogSourceContext();
const { textScale, textWrap } = useContext(LogViewConfiguration.Context);
const {
setFlyoutVisibility,
@ -64,6 +62,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
<WithLogTextviewUrlState />
<WithFlyoutOptionsUrlState />
<LogsToolbar />
<PageViewLogInContext />
{flyoutVisible ? (
<LogEntryFlyout
setFilter={applyLogFilterQuery}
@ -77,7 +76,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
loading={isLoading}
/>
) : null}
<PageContent key={`${sourceId}-${version}`}>
<PageContent key={`${sourceId}-${sourceConfiguration?.version}`}>
<WithStreamItems>
{({
currentHighlightKey,
@ -91,7 +90,9 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
checkForNewEntries,
}) => (
<ScrollableLogTextStreamView
columnConfigurations={(source && source.configuration.logColumns) || []}
columnConfigurations={
(sourceConfiguration && sourceConfiguration.configuration.logColumns) || []
}
hasMoreAfterEnd={hasMoreAfterEnd}
hasMoreBeforeStart={hasMoreBeforeStart}
isLoadingMore={isLoadingMore}

View file

@ -12,13 +12,11 @@ import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_
import { LogPositionState, WithLogPositionUrlState } from '../../../containers/logs/log_position';
import { LogFilterState, WithLogFilterUrlState } from '../../../containers/logs/log_filter';
import { LogEntriesState } from '../../../containers/logs/log_entries';
import { Source } from '../../../containers/source';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
const LogFilterStateProvider: React.FC = ({ children }) => {
const { createDerivedIndexPattern } = useContext(Source.Context);
const derivedIndexPattern = createDerivedIndexPattern('logs');
const { derivedIndexPattern } = useLogSourceContext();
return (
<LogFilterState.Provider indexPattern={derivedIndexPattern}>
<WithLogFilterUrlState />
@ -29,7 +27,7 @@ const LogFilterStateProvider: React.FC = ({ children }) => {
const ViewLogInContextProvider: React.FC = ({ children }) => {
const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context);
const { sourceId } = useContext(Source.Context);
const { sourceId } = useLogSourceContext();
if (!startTimestamp || !endTimestamp) {
return null;
@ -47,7 +45,7 @@ const ViewLogInContextProvider: React.FC = ({ children }) => {
};
const LogEntriesStateProvider: React.FC = ({ children }) => {
const { sourceId } = useContext(Source.Context);
const { sourceId } = useLogSourceContext();
const {
startTimestamp,
endTimestamp,
@ -89,13 +87,13 @@ const LogEntriesStateProvider: React.FC = ({ children }) => {
};
const LogHighlightsStateProvider: React.FC = ({ children }) => {
const { sourceId, version } = useContext(Source.Context);
const { sourceId, sourceConfiguration } = useLogSourceContext();
const [{ topCursor, bottomCursor, centerCursor, entries }] = useContext(LogEntriesState.Context);
const { filterQuery } = useContext(LogFilterState.Context);
const highlightsProps = {
sourceId,
sourceVersion: version,
sourceVersion: sourceConfiguration?.version,
entriesStart: topCursor,
entriesEnd: bottomCursor,
centerCursor,
@ -106,6 +104,13 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => {
};
export const LogsPageProviders: React.FunctionComponent = ({ children }) => {
const { logIndicesExist } = useLogSourceContext();
// The providers assume the source is loaded, so short-circuit them otherwise
if (!logIndicesExist) {
return <>{children}</>;
}
return (
<LogViewConfiguration.Provider>
<LogFlyout.Provider>

View file

@ -19,13 +19,12 @@ import { LogFlyout } from '../../../containers/logs/log_flyout';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { LogFilterState } from '../../../containers/logs/log_filter';
import { LogPositionState } from '../../../containers/logs/log_position';
import { Source } from '../../../containers/source';
import { WithKueryAutocompletion } from '../../../containers/with_kuery_autocompletion';
import { LogDatepicker } from '../../../components/logging/log_datepicker';
import { useLogSourceContext } from '../../../containers/logs/log_source';
export const LogsToolbar = () => {
const { createDerivedIndexPattern } = useContext(Source.Context);
const derivedIndexPattern = createDerivedIndexPattern('logs');
const { derivedIndexPattern } = useLogSourceContext();
const { availableTextScales, setTextScale, setTextWrap, textScale, textWrap } = useContext(
LogViewConfiguration.Context
);

View file

@ -4,32 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext, useCallback, useMemo } from 'react';
import { noop } from 'lodash';
import {
EuiOverlayMask,
EuiModal,
EuiModalBody,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalBody,
EuiOverlayMask,
EuiText,
EuiTextColor,
EuiToolTip,
} from '@elastic/eui';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { noop } from 'lodash';
import React, { useCallback, useContext, useMemo } from 'react';
import { LogEntry } from '../../../../common/http_api';
import { Source } from '../../../containers/source';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { ScrollableLogTextStreamView } from '../../../components/logging/log_text_stream';
import { useLogSourceContext } from '../../../containers/logs/log_source';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { useViewportDimensions } from '../../../utils/use_viewport_dimensions';
const MODAL_MARGIN = 25;
export const PageViewLogInContext: React.FC = () => {
const { source } = useContext(Source.Context);
const { sourceConfiguration } = useLogSourceContext();
const { textScale, textWrap } = useContext(LogViewConfiguration.Context);
const columnConfigurations = useMemo(() => (source && source.configuration.logColumns) || [], [
source,
const columnConfigurations = useMemo(() => sourceConfiguration?.configuration.logColumns ?? [], [
sourceConfiguration,
]);
const [{ contextEntry, entries, isLoading }, { setContextEntry }] = useContext(
ViewLogInContext.Context

View file

@ -29,6 +29,7 @@ import {
initLogEntriesItemRoute,
} from './routes/log_entries';
import { initInventoryMetaRoute } from './routes/inventory_metadata';
import { initLogSourceConfigurationRoutes, initLogSourceStatusRoutes } from './routes/log_sources';
import { initSourceRoute } from './routes/source';
export const initInfraServer = (libs: InfraBackendLibs) => {
@ -59,4 +60,6 @@ export const initInfraServer = (libs: InfraBackendLibs) => {
initMetricExplorerRoute(libs);
initMetadataRoute(libs);
initInventoryMetaRoute(libs);
initLogSourceConfigurationRoutes(libs);
initLogSourceStatusRoutes(libs);
};

View file

@ -70,6 +70,9 @@ export class KibanaFramework {
case 'put':
this.router.put(routeConfig, handler);
break;
case 'patch':
this.router.patch(routeConfig, handler);
break;
}
}

View file

@ -29,9 +29,10 @@ export class InfraFieldsDomain {
const fields = await this.adapter.getIndexFields(
requestContext,
`${includeMetricIndices ? configuration.metricAlias : ''},${
includeLogIndices ? configuration.logAlias : ''
}`
[
...(includeMetricIndices ? [configuration.metricAlias] : []),
...(includeLogIndices ? [configuration.logAlias] : []),
].join(',')
);
return fields;

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
getLogSourceConfigurationRequestParamsRT,
getLogSourceConfigurationSuccessResponsePayloadRT,
LOG_SOURCE_CONFIGURATION_PATH,
patchLogSourceConfigurationRequestBodyRT,
patchLogSourceConfigurationRequestParamsRT,
patchLogSourceConfigurationSuccessResponsePayloadRT,
} from '../../../common/http_api/log_sources';
import { createValidationFunction } from '../../../common/runtime_types';
import { InfraBackendLibs } from '../../lib/infra_types';
export const initLogSourceConfigurationRoutes = ({ framework, sources }: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'get',
path: LOG_SOURCE_CONFIGURATION_PATH,
validate: {
params: createValidationFunction(getLogSourceConfigurationRequestParamsRT),
},
},
framework.router.handleLegacyErrors(async (requestContext, request, response) => {
const { sourceId } = request.params;
try {
const sourceConfiguration = await sources.getSourceConfiguration(
requestContext.core.savedObjects.client,
sourceId
);
return response.ok({
body: getLogSourceConfigurationSuccessResponsePayloadRT.encode({
data: sourceConfiguration,
}),
});
} catch (error) {
if (Boom.isBoom(error)) {
throw error;
}
return response.customError({
statusCode: error.statusCode ?? 500,
body: {
message: error.message ?? 'An unexpected error occurred',
},
});
}
})
);
framework.registerRoute(
{
method: 'patch',
path: LOG_SOURCE_CONFIGURATION_PATH,
validate: {
params: createValidationFunction(patchLogSourceConfigurationRequestParamsRT),
body: createValidationFunction(patchLogSourceConfigurationRequestBodyRT),
},
},
framework.router.handleLegacyErrors(async (requestContext, request, response) => {
const { sourceId } = request.params;
const { data: patchedSourceConfigurationProperties } = request.body;
try {
const sourceConfiguration = await sources.getSourceConfiguration(
requestContext.core.savedObjects.client,
sourceId
);
if (sourceConfiguration.origin === 'internal') {
response.conflict({
body: 'A conflicting read-only source configuration already exists.',
});
}
const sourceConfigurationExists = sourceConfiguration.origin === 'stored';
const patchedSourceConfiguration = await (sourceConfigurationExists
? sources.updateSourceConfiguration(
requestContext,
sourceId,
patchedSourceConfigurationProperties
)
: sources.createSourceConfiguration(
requestContext,
sourceId,
patchedSourceConfigurationProperties
));
return response.ok({
body: patchLogSourceConfigurationSuccessResponsePayloadRT.encode({
data: patchedSourceConfiguration,
}),
});
} catch (error) {
if (Boom.isBoom(error)) {
throw error;
}
return response.customError({
statusCode: error.statusCode ?? 500,
body: {
message: error.message ?? 'An unexpected error occurred',
},
});
}
})
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './configuration';
export * from './status';

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
getLogSourceStatusRequestParamsRT,
getLogSourceStatusSuccessResponsePayloadRT,
LOG_SOURCE_STATUS_PATH,
} from '../../../common/http_api/log_sources';
import { createValidationFunction } from '../../../common/runtime_types';
import { InfraIndexType } from '../../graphql/types';
import { InfraBackendLibs } from '../../lib/infra_types';
export const initLogSourceStatusRoutes = ({
framework,
sourceStatus,
fields,
}: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'get',
path: LOG_SOURCE_STATUS_PATH,
validate: {
params: createValidationFunction(getLogSourceStatusRequestParamsRT),
},
},
framework.router.handleLegacyErrors(async (requestContext, request, response) => {
const { sourceId } = request.params;
try {
const logIndexNames = await sourceStatus.getLogIndexNames(requestContext, sourceId);
const logIndexFields =
logIndexNames.length > 0
? await fields.getFields(requestContext, sourceId, InfraIndexType.LOGS)
: [];
return response.ok({
body: getLogSourceStatusSuccessResponsePayloadRT.encode({
data: {
logIndexFields,
logIndexNames,
},
}),
});
} catch (error) {
if (Boom.isBoom(error)) {
throw error;
}
return response.customError({
statusCode: error.statusCode ?? 500,
body: {
message: error.message ?? 'An unexpected error occurred',
},
});
}
})
);
};

View file

@ -11,6 +11,7 @@ export default function({ loadTestFile }) {
loadTestFile(require.resolve('./log_entries'));
loadTestFile(require.resolve('./log_entry_highlights'));
loadTestFile(require.resolve('./logs_without_millis'));
loadTestFile(require.resolve('./log_sources'));
loadTestFile(require.resolve('./log_summary'));
loadTestFile(require.resolve('./metrics'));
loadTestFile(require.resolve('./sources'));

View file

@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { beforeEach } from 'mocha';
import {
getLogSourceConfigurationSuccessResponsePayloadRT,
patchLogSourceConfigurationSuccessResponsePayloadRT,
} from '../../../../plugins/infra/common/http_api/log_sources';
import { decodeOrThrow } from '../../../../plugins/infra/common/runtime_types';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const logSourceConfiguration = getService('infraLogSourceConfiguration');
describe('log sources api', () => {
before(() => esArchiver.load('infra/metrics_and_logs'));
after(() => esArchiver.unload('infra/metrics_and_logs'));
beforeEach(() => esArchiver.load('empty_kibana'));
afterEach(() => esArchiver.unload('empty_kibana'));
describe('source configuration get method for non-existant source', () => {
it('returns the default source configuration', async () => {
const response = await logSourceConfiguration
.createGetLogSourceConfigurationAgent('default')
.expect(200);
const {
data: { configuration, origin },
} = decodeOrThrow(getLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
expect(origin).to.be('fallback');
expect(configuration.name).to.be('Default');
expect(configuration.logAlias).to.be('filebeat-*,kibana_sample_data_logs*');
expect(configuration.fields.timestamp).to.be('@timestamp');
expect(configuration.fields.tiebreaker).to.be('_doc');
expect(configuration.logColumns[0]).to.have.key('timestampColumn');
expect(configuration.logColumns[1]).to.have.key('fieldColumn');
expect(configuration.logColumns[2]).to.have.key('messageColumn');
});
});
describe('source configuration patch method for non-existant source', () => {
it('creates a source configuration', async () => {
const response = await logSourceConfiguration
.createUpdateLogSourceConfigurationAgent('default', {
name: 'NAME',
description: 'DESCRIPTION',
logAlias: 'filebeat-**',
fields: {
tiebreaker: 'TIEBREAKER',
timestamp: 'TIMESTAMP',
},
logColumns: [
{
messageColumn: {
id: 'MESSAGE_COLUMN',
},
},
],
})
.expect(200);
// check direct response
const {
data: { configuration, origin },
} = decodeOrThrow(patchLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
expect(configuration.name).to.be('NAME');
expect(origin).to.be('stored');
expect(configuration.logAlias).to.be('filebeat-**');
expect(configuration.fields.timestamp).to.be('TIMESTAMP');
expect(configuration.fields.tiebreaker).to.be('TIEBREAKER');
expect(configuration.logColumns).to.have.length(1);
expect(configuration.logColumns[0]).to.have.key('messageColumn');
// check for persistence
const {
data: { configuration: persistedConfiguration },
} = await logSourceConfiguration.getLogSourceConfiguration('default');
expect(configuration).to.eql(persistedConfiguration);
});
it('creates a source configuration with default values for unspecified properties', async () => {
const response = await logSourceConfiguration
.createUpdateLogSourceConfigurationAgent('default', {})
.expect(200);
const {
data: { configuration, origin },
} = decodeOrThrow(patchLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
expect(configuration.name).to.be('Default');
expect(origin).to.be('stored');
expect(configuration.logAlias).to.be('filebeat-*,kibana_sample_data_logs*');
expect(configuration.fields.timestamp).to.be('@timestamp');
expect(configuration.fields.tiebreaker).to.be('_doc');
expect(configuration.logColumns).to.have.length(3);
expect(configuration.logColumns[0]).to.have.key('timestampColumn');
expect(configuration.logColumns[1]).to.have.key('fieldColumn');
expect(configuration.logColumns[2]).to.have.key('messageColumn');
// check for persistence
const {
data: { configuration: persistedConfiguration, origin: persistedOrigin },
} = await logSourceConfiguration.getLogSourceConfiguration('default');
expect(persistedOrigin).to.be('stored');
expect(configuration).to.eql(persistedConfiguration);
});
});
describe('source configuration patch method for existing source', () => {
beforeEach(async () => {
await logSourceConfiguration.updateLogSourceConfiguration('default', {});
});
it('updates a source configuration', async () => {
const response = await logSourceConfiguration
.createUpdateLogSourceConfigurationAgent('default', {
name: 'NAME',
description: 'DESCRIPTION',
logAlias: 'filebeat-**',
fields: {
tiebreaker: 'TIEBREAKER',
timestamp: 'TIMESTAMP',
},
logColumns: [
{
messageColumn: {
id: 'MESSAGE_COLUMN',
},
},
],
})
.expect(200);
const {
data: { configuration, origin },
} = decodeOrThrow(patchLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
expect(configuration.name).to.be('NAME');
expect(origin).to.be('stored');
expect(configuration.logAlias).to.be('filebeat-**');
expect(configuration.fields.timestamp).to.be('TIMESTAMP');
expect(configuration.fields.tiebreaker).to.be('TIEBREAKER');
expect(configuration.logColumns).to.have.length(1);
expect(configuration.logColumns[0]).to.have.key('messageColumn');
});
it('partially updates a source configuration', async () => {
const response = await logSourceConfiguration
.createUpdateLogSourceConfigurationAgent('default', {
name: 'NAME',
})
.expect(200);
const {
data: { configuration, origin },
} = decodeOrThrow(patchLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
expect(configuration.name).to.be('NAME');
expect(origin).to.be('stored');
expect(configuration.logAlias).to.be('filebeat-*,kibana_sample_data_logs*');
expect(configuration.fields.timestamp).to.be('@timestamp');
expect(configuration.fields.tiebreaker).to.be('_doc');
expect(configuration.logColumns).to.have.length(3);
expect(configuration.logColumns[0]).to.have.key('timestampColumn');
expect(configuration.logColumns[1]).to.have.key('fieldColumn');
expect(configuration.logColumns[2]).to.have.key('messageColumn');
});
});
});
}

View file

@ -21,6 +21,7 @@ import {
} from './infraops_graphql_client';
import { SiemGraphQLClientProvider, SiemGraphQLClientFactoryProvider } from './siem_graphql_client';
import { InfraOpsSourceConfigurationProvider } from './infraops_source_configuration';
import { InfraLogSourceConfigurationProvider } from './infra_log_source_configuration';
import { MachineLearningProvider } from './ml';
import { IngestManagerProvider } from './ingest_manager';
@ -35,6 +36,7 @@ export const services = {
infraOpsGraphQLClient: InfraOpsGraphQLClientProvider,
infraOpsGraphQLClientFactory: InfraOpsGraphQLClientFactoryProvider,
infraOpsSourceConfiguration: InfraOpsSourceConfigurationProvider,
infraLogSourceConfiguration: InfraLogSourceConfigurationProvider,
siemGraphQLClient: SiemGraphQLClientProvider,
siemGraphQLClientFactory: SiemGraphQLClientFactoryProvider,
supertestWithoutAuth: SupertestWithoutAuthProvider,

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;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getLogSourceConfigurationPath,
getLogSourceConfigurationSuccessResponsePayloadRT,
PatchLogSourceConfigurationRequestBody,
patchLogSourceConfigurationRequestBodyRT,
patchLogSourceConfigurationResponsePayloadRT,
} from '../../../plugins/infra/common/http_api/log_sources';
import { decodeOrThrow } from '../../../plugins/infra/common/runtime_types';
import { FtrProviderContext } from '../ftr_provider_context';
export function InfraLogSourceConfigurationProvider({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const log = getService('log');
const createGetLogSourceConfigurationAgent = (sourceId: string) =>
supertest
.get(getLogSourceConfigurationPath(sourceId))
.set({
'kbn-xsrf': 'some-xsrf-token',
})
.send();
const getLogSourceConfiguration = async (sourceId: string) => {
log.debug(`Fetching Logs UI source configuration "${sourceId}"`);
const response = await createGetLogSourceConfigurationAgent(sourceId);
return decodeOrThrow(getLogSourceConfigurationSuccessResponsePayloadRT)(response.body);
};
const createUpdateLogSourceConfigurationAgent = (
sourceId: string,
sourceProperties: PatchLogSourceConfigurationRequestBody['data']
) =>
supertest
.patch(getLogSourceConfigurationPath(sourceId))
.set({
'kbn-xsrf': 'some-xsrf-token',
})
.send(patchLogSourceConfigurationRequestBodyRT.encode({ data: sourceProperties }));
const updateLogSourceConfiguration = async (
sourceId: string,
sourceProperties: PatchLogSourceConfigurationRequestBody['data']
) => {
log.debug(
`Updating Logs UI source configuration "${sourceId}" with properties ${JSON.stringify(
sourceProperties
)}`
);
const response = await createUpdateLogSourceConfigurationAgent(sourceId, sourceProperties);
return decodeOrThrow(patchLogSourceConfigurationResponsePayloadRT)(response.body);
};
return {
createGetLogSourceConfigurationAgent,
createUpdateLogSourceConfigurationAgent,
getLogSourceConfiguration,
updateLogSourceConfiguration,
};
}

View file

@ -5,6 +5,7 @@
*/
import expect from '@kbn/expect';
import { URL } from 'url';
import { FtrProviderContext } from '../../ftr_provider_context';
const ONE_HOUR = 60 * 60 * 1000;
@ -28,8 +29,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
search: `time=${timestamp}&filter=trace.id:${traceId}`,
state: undefined,
};
const expectedSearchString = `logFilter=(expression:'trace.id:${traceId}',kind:kuery)&logPosition=(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)&sourceId=default`;
const expectedRedirectPath = '/logs/stream?';
await pageObjects.common.navigateToUrlWithBrowserHistory(
'infraLogs',
@ -41,9 +40,16 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
);
await retry.tryForTime(5000, async () => {
const currentUrl = await browser.getCurrentUrl();
const decodedUrl = decodeURIComponent(currentUrl);
expect(decodedUrl).to.contain(expectedRedirectPath);
expect(decodedUrl).to.contain(expectedSearchString);
const parsedUrl = new URL(currentUrl);
expect(parsedUrl.pathname).to.be('/app/logs/stream');
expect(parsedUrl.searchParams.get('logFilter')).to.be(
`(expression:'trace.id:${traceId}',kind:kuery)`
);
expect(parsedUrl.searchParams.get('logPosition')).to.be(
`(end:'${endDate}',position:(tiebreaker:0,time:${timestamp}),start:'${startDate}',streamLive:!f)`
);
expect(parsedUrl.searchParams.get('sourceId')).to.be('default');
});
});
});