diff --git a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_configuration.ts b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_configuration.ts new file mode 100644 index 0000000000000..bb79b5ebc5290 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_configuration.ts @@ -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 +>; diff --git a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts new file mode 100644 index 0000000000000..ae872cee9aa56 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts @@ -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; + +/** + * response + */ + +const logIndexFieldRT = rt.strict({ + name: rt.string, + type: rt.string, + searchable: rt.boolean, + aggregatable: rt.boolean, +}); + +export type LogIndexField = rt.TypeOf; + +const logSourceStatusRT = rt.strict({ + logIndexFields: rt.array(logIndexFieldRT), + logIndexNames: rt.array(rt.string), +}); + +export type LogSourceStatus = rt.TypeOf; + +export const getLogSourceStatusSuccessResponsePayloadRT = rt.intersection([ + rt.type({ + data: logSourceStatusRT, + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogSourceStatusSuccessResponsePayload = rt.TypeOf< + typeof getLogSourceStatusSuccessResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_sources/index.ts b/x-pack/plugins/infra/common/http_api/log_sources/index.ts new file mode 100644 index 0000000000000..5fd1b5efec177 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_sources/index.ts @@ -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'; diff --git a/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts b/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts new file mode 100644 index 0000000000000..e8bf63843c623 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_sources/log_source_configuration.ts @@ -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; + +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; + +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; diff --git a/x-pack/plugins/infra/common/http_api/log_sources/patch_log_source_configuration.ts b/x-pack/plugins/infra/common/http_api/log_sources/patch_log_source_configuration.ts new file mode 100644 index 0000000000000..fd312d7429b60 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_sources/patch_log_source_configuration.ts @@ -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 +>; diff --git a/x-pack/plugins/infra/common/runtime_types.ts b/x-pack/plugins/infra/common/runtime_types.ts index d5b858df38def..a8d5cd8693a3d 100644 --- a/x-pack/plugins/infra/common/runtime_types.ts +++ b/x-pack/plugins/infra/common/runtime_types.ts @@ -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 = ( - runtimeType: Type, +export const decodeOrThrow = ( + runtimeType: Type, createError: ErrorFactory = createPlainError -) => (inputValue: I) => +) => (inputValue: InputValue) => pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); + +type ValdidationResult = ReturnType>; + +export const createValidationFunction = ( + runtimeType: Type +): RouteValidationFunction => (inputValue, { badRequest, ok }) => + pipe( + runtimeType.decode(inputValue), + fold>( + (errors: Errors) => badRequest(failure(errors).join('\n')), + (result: DecodedValue) => ok(result) + ) + ); diff --git a/x-pack/plugins/infra/public/components/source_configuration/index.ts b/x-pack/plugins/infra/public/components/source_configuration/index.ts index 98825567cc204..66f64aee24f6e 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/index.ts +++ b/x-pack/plugins/infra/public/components/source_configuration/index.ts @@ -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'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 267abe631c142..b0981f9b3c41f 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -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(false); const [flyoutId, setFlyoutId] = useState(null); const [flyoutItem, setFlyoutItem] = useState(null); diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts new file mode 100644 index 0000000000000..786cb485b38dd --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts @@ -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); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts new file mode 100644 index 0000000000000..2f1d15ffaf4d3 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts @@ -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); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts new file mode 100644 index 0000000000000..848801ab3c7ce --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts @@ -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); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/index.ts b/x-pack/plugins/infra/public/containers/logs/log_source/index.ts new file mode 100644 index 0000000000000..5d9c6373050a1 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_source/index.ts @@ -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'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts new file mode 100644 index 0000000000000..8332018fddf90 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts @@ -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(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); diff --git a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts index 14da2b47bcfa2..625a1ead4d930 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_summary/with_summary.ts @@ -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); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index ed1aa9e72ebae..04b472ceb59c8 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -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 ; } else if (hasFailedLoadingSource) { return ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx index 619cea6eda8b7..cecea733b49e4 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx @@ -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 ( {children} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 2f34e62d8e611..fc07289f02fe7 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -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 ; } else if (hasFailedLoadingSource) { return ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index 67c8ea7660a26..e91ef87bdf34a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -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 ( {children} diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index ed6f06deeef64..0433a2771d3ac 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -7,6 +7,7 @@ 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'; @@ -16,6 +17,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'; @@ -26,6 +28,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, diff --git a/x-pack/plugins/infra/public/pages/logs/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/page_providers.tsx index 24c1598787a20..d2db5002f4aa2 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_providers.tsx @@ -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 ( - + {children} - + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings.tsx deleted file mode 100644 index faee7a643085a..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/settings.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/add_log_column_popover.tsx b/x-pack/plugins/infra/public/pages/logs/settings/add_log_column_popover.tsx new file mode 100644 index 0000000000000..6e68debceac70 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/add_log_column_popover.tsx @@ -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( + () => [ + { + optionProps: { + append: , + '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: , + // 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(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( + () => 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 ( + + + + } + closePopover={closePopover} + id="addLogColumn" + isOpen={isOpen} + ownFocus + panelPaddingSize="none" + > + + {(list, search) => ( + + {search} + {list} + + )} + + + ); +}; + +const searchProps = { + 'data-test-subj': 'fieldSearchInput', +}; + +const selectableListProps = { + showIcons: false, +}; + +const SystemColumnBadge: React.FunctionComponent = () => ( + + + +); + +const SelectableContent = euiStyled.div` + width: 400px; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx new file mode 100644 index 0000000000000..ac3b75ad97bb2 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/fields_configuration_panel.tsx @@ -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 ( + + +

+ +

+
+ + +

+ + + + ), + ecsLink: ( + + ECS + + ), + }} + /> +

+
+ + + + + } + description={ + + } + > + @timestamp, + }} + /> + } + isInvalid={timestampFieldProps.isInvalid} + label={ + + } + > + + + + + + + } + description={ + + } + > + _doc, + }} + /> + } + isInvalid={tiebreakerFieldProps.isInvalid} + label={ + + } + > + + + +
+ ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index.ts b/x-pack/plugins/infra/public/pages/logs/settings/index.ts new file mode 100644 index 0000000000000..ebdda7ebbd587 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/index.ts @@ -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'; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts new file mode 100644 index 0000000000000..a97e38884a5bd --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_form_state.ts @@ -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; + +export const useLogIndicesConfigurationFormState = ({ + initialFormState = defaultFormState, +}: { + initialFormState?: FormState; +}) => { + const [formStateChanges, setFormStateChanges] = useState({}); + + 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( + (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: '', +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx new file mode 100644 index 0000000000000..83effaa3d51a5 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -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) => ( + + +

+ +

+
+ + + + + } + description={ + + } + > + filebeat-*, + }} + /> + } + isInvalid={logAliasFieldProps.isInvalid} + label={ + + } + > + + + +
+); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx new file mode 100644 index 0000000000000..0beccffe5f4e8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_form_state.tsx @@ -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; + +export const useLogColumnsConfigurationFormState = ({ + initialFormState = defaultFormState, +}: { + initialFormState?: FormState; +}) => { + const [formStateChanges, setFormStateChanges] = useState({}); + + const resetForm = useCallback(() => setFormStateChanges({}), []); + + const formState = useMemo( + () => ({ + ...initialFormState, + ...formStateChanges, + }), + [initialFormState, formStateChanges] + ); + + const logColumnConfigurationProps = useMemo( + () => + 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 + ? [ + , + ] + : [], + [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: [], +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx new file mode 100644 index 0000000000000..777f611ef33f7 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/log_columns_configuration_panel.tsx @@ -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 = ({ + addLogColumn, + moveLogColumn, + availableFields, + isLoading, + logColumnConfiguration, +}) => { + const onDragEnd = useCallback( + ({ source, destination }: DropResult) => + destination && moveLogColumn(source.index, destination.index), + [moveLogColumn] + ); + + return ( + + + + +

+ +

+
+
+ + + +
+ {logColumnConfiguration.length > 0 ? ( + + + <> + {/* Fragment here necessary for typechecking */} + {logColumnConfiguration.map((column, index) => ( + + {provided => ( + + )} + + ))} + + + + ) : ( + + )} +
+ ); +}; + +interface LogColumnConfigurationPanelProps { + logColumnConfigurationProps: LogColumnConfigurationProps; + dragHandleProps: DragHandleProps; +} + +const LogColumnConfigurationPanel: React.FunctionComponent = props => ( + <> + + {props.logColumnConfigurationProps.type === 'timestamp' ? ( + + ) : props.logColumnConfigurationProps.type === 'message' ? ( + + ) : ( + + )} + +); + +const TimestampLogColumnConfigurationPanel: React.FunctionComponent = ({ + logColumnConfigurationProps, + dragHandleProps, +}) => ( + timestamp, + }} + /> + } + removeColumn={logColumnConfigurationProps.remove} + dragHandleProps={dragHandleProps} + /> +); + +const MessageLogColumnConfigurationPanel: React.FunctionComponent = ({ + logColumnConfigurationProps, + dragHandleProps, +}) => ( + + } + 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 ( + + + +
+ +
+
+ {fieldLogColumnTitle} + + {field} + + + + +
+
+ ); +}; + +const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{ + fieldName: React.ReactNode; + helpText: React.ReactNode; + removeColumn: () => void; + dragHandleProps: DragHandleProps; +}> = ({ fieldName, helpText, removeColumn, dragHandleProps }) => ( + + + +
+ +
+
+ {fieldName} + + + {helpText} + + + + + +
+
+); + +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 ( + + ); +}; + +const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => ( + + + + } + body={ +

+ +

+ } + /> +); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx new file mode 100644 index 0000000000000..92d70955c678f --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_form_state.tsx @@ -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, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx new file mode 100644 index 0000000000000..88b1441f0ba7c --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx @@ -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 ; + } + if (!source?.configuration) { + return null; + } + + return ( + <> + + + + + + + + + + + + + + + + + + + {errors.length > 0 ? ( + <> + +
    + {errors.map((error, errorIndex) => ( +
  • {error}
  • + ))} +
+
+ + + ) : null} + + + {isWriteable && ( + + {isLoading ? ( + + + + Loading + + + + ) : ( + <> + + + { + resetForm(); + }} + > + + + + + + + + + + + )} + + )} + +
+
+ + ); +}; + +const unsavedFormPromptMessage = i18n.translate( + 'xpack.infra.logSourceConfiguration.unsavedFormPromptMessage', + { + defaultMessage: 'Are you sure you want to leave? Changes will be lost', + } +); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx index aff0ac27c36f8..712d625052140 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx @@ -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 = () => { - ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx index 010a17dae4ebd..40ac5c74a6836 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx @@ -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 ; } else if (hasFailedLoadingSource) { return ; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx index 3208ea2402950..85781c48f9512 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_logs_content.tsx @@ -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 = () => { + {flyoutVisible ? ( { loading={isLoading} /> ) : null} - + {({ currentHighlightKey, @@ -91,7 +90,9 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { checkForNewEntries, }) => ( { - const { createDerivedIndexPattern } = useContext(Source.Context); - const derivedIndexPattern = createDerivedIndexPattern('logs'); + const { derivedIndexPattern } = useLogSourceContext(); return ( @@ -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 ( diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 2f9a76fd47490..9667272eb2417 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -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 ); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx index fdfc16d6a9bef..9e0f7d5035aaf 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx @@ -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, + EuiFlexGroup, + EuiFlexItem, EuiModal, EuiModalBody, + EuiOverlayMask, EuiText, EuiTextColor, - EuiFlexGroup, - EuiFlexItem, 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 diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 88b78dfd3e41c..4ed30380dc164 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -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); }; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index eda1fbfa5f4ce..eed7d39b8e74a 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -70,6 +70,9 @@ export class KibanaFramework { case 'put': this.router.put(routeConfig, handler); break; + case 'patch': + this.router.patch(routeConfig, handler); + break; } } diff --git a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts index d2e151ca2c3f5..8887a97987338 100644 --- a/x-pack/plugins/infra/server/lib/domains/fields_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/fields_domain.ts @@ -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; diff --git a/x-pack/plugins/infra/server/routes/log_sources/configuration.ts b/x-pack/plugins/infra/server/routes/log_sources/configuration.ts new file mode 100644 index 0000000000000..0ce594675773c --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_sources/configuration.ts @@ -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', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/infra/server/routes/log_sources/index.ts b/x-pack/plugins/infra/server/routes/log_sources/index.ts new file mode 100644 index 0000000000000..5b68152b329ef --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_sources/index.ts @@ -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'; diff --git a/x-pack/plugins/infra/server/routes/log_sources/status.ts b/x-pack/plugins/infra/server/routes/log_sources/status.ts new file mode 100644 index 0000000000000..cdd053d2bb10a --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_sources/status.ts @@ -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', + }, + }); + } + }) + ); +}; diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index 8bb3475da6cc9..28a317893f5b2 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -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')); diff --git a/x-pack/test/api_integration/apis/infra/log_sources.ts b/x-pack/test/api_integration/apis/infra/log_sources.ts new file mode 100644 index 0000000000000..73d59bcdcd9a4 --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/log_sources.ts @@ -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'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index 84b8476bd1dd1..6dcc9bb291b02 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -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, diff --git a/x-pack/test/api_integration/services/infra_log_source_configuration.ts b/x-pack/test/api_integration/services/infra_log_source_configuration.ts new file mode 100644 index 0000000000000..851720895c620 --- /dev/null +++ b/x-pack/test/api_integration/services/infra_log_source_configuration.ts @@ -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, + }; +} diff --git a/x-pack/test/functional/apps/infra/link_to.ts b/x-pack/test/functional/apps/infra/link_to.ts index 4e5ebab90880e..89ed51f65b930 100644 --- a/x-pack/test/functional/apps/infra/link_to.ts +++ b/x-pack/test/functional/apps/infra/link_to.ts @@ -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'); }); }); });