From 203f25645feb06600742d653bc75ee610e0cf13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Mon, 7 Sep 2020 16:08:27 +0200 Subject: [PATCH] [Logs UI] Shared `` component (#76262) --- .../public/components/log_stream/README.md | 73 ++++++++++ .../public/components/log_stream/index.tsx | 133 ++++++++++++++++++ .../log_stream/lazy_log_stream_wrapper.tsx | 16 +++ .../scrollable_log_text_stream_view.tsx | 4 +- .../containers/logs/log_stream/index.ts | 89 ++++++++++++ .../view_log_in_context.ts | 50 +------ x-pack/plugins/infra/public/index.ts | 4 +- .../logs/stream/page_view_log_in_context.tsx | 76 ++++------ 8 files changed, 351 insertions(+), 94 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/log_stream/README.md create mode 100644 x-pack/plugins/infra/public/components/log_stream/index.tsx create mode 100644 x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx create mode 100644 x-pack/plugins/infra/public/containers/logs/log_stream/index.ts diff --git a/x-pack/plugins/infra/public/components/log_stream/README.md b/x-pack/plugins/infra/public/components/log_stream/README.md new file mode 100644 index 000000000000..59b3edfab736 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/README.md @@ -0,0 +1,73 @@ +# Embeddable `` component + +The purpose of this component is to allow you, the developer, to have your very own Log Stream in your plugin. + +The plugin is exposed through `infra/public`. Since Kibana uses relative paths is up to you to find how to import it (sorry). + +```tsx +import { LogStream } from '../../../../../../infra/public'; +``` + +## Prerequisites + +To use the component, there are several things you need to ensure in your plugin: + +- In your plugin's `kibana.json` plugin, add `"infra"` to `requiredPlugins`. +- The component needs to be mounted inside the hiearchy of a [`kibana-react` provider](https://github.com/elastic/kibana/blob/b2d0aa7b7fae1c89c8f9e8854ae73e71be64e765/src/plugins/kibana_react/README.md#L45). + +## Usage + +The simplest way to use the component is with a date range, passed with the `startTimestamp` and `endTimestamp` props. + +```tsx +const endTimestamp = Date.now(); +const startTimestamp = endTimestamp - 15 * 60 * 1000; // 15 minutes + +; +``` + +This will show a list of log entries between the time range, in ascending order (oldest first), but with the scroll position all the way to the bottom (showing the newest entries) + +### Filtering data + +You might want to show specific data for the purpose of your plugin. Maybe you want to show log lines from a specific host, or for an APM trace. You can pass a KQL expression via the `query` prop. + +```tsx + +``` + +### Modifying rendering + +By default the component will initially load at the bottom of the list, showing the newest entries. You can change what log line is shown in the center via the `center` prop. The prop takes a [`LogEntriesCursor`](https://github.com/elastic/kibana/blob/0a6c748cc837c016901f69ff05d81395aa2d41c8/x-pack/plugins/infra/common/http_api/log_entries/common.ts#L9-L13). + +```tsx + +``` + +If you want to highlight a specific log line, you can do so by passing its ID in the `highlight` prop. + +```tsx + +``` + +### Source configuration + +The infra plugin has the concept of "source configuration" to store settings for the logs UI. The component will use the source configuration to determine which indices to query or what columns to show. + +By default the `` uses the `"default"` source confiuration, but if your plugin uses a different one you can specify it via the `sourceId` prop. + +```tsx + +``` + +### Considerations + +As mentioned in the prerequisites, the component relies on `kibana-react` to access kibana's core services. If this is not the case the component will throw an exception when rendering. We advise to use an `` in your component hierarchy to catch this error if necessary. diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/index.tsx new file mode 100644 index 000000000000..f9bfbf956479 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/index.tsx @@ -0,0 +1,133 @@ +/* + * 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, { useMemo } from 'react'; +import { noop } from 'lodash'; +import { useMount } from 'react-use'; +import { euiStyled } from '../../../../observability/public'; + +import { LogEntriesCursor } from '../../../common/http_api'; + +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useLogSource } from '../../containers/logs/log_source'; +import { useLogStream } from '../../containers/logs/log_stream'; + +import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; + +export interface LogStreamProps { + sourceId?: string; + startTimestamp: number; + endTimestamp: number; + query?: string; + center?: LogEntriesCursor; + highlight?: string; + height?: string | number; +} + +export const LogStream: React.FC = ({ + sourceId = 'default', + startTimestamp, + endTimestamp, + query, + center, + highlight, + height = '400px', +}) => { + // source boilerplate + const { services } = useKibana(); + if (!services?.http?.fetch) { + throw new Error( + ` cannot access kibana core services. + +Ensure the component is mounted within kibana-react's hierarchy. +Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_react/README.md" +` + ); + } + + const { + sourceConfiguration, + loadSourceConfiguration, + isLoadingSourceConfiguration, + } = useLogSource({ + sourceId, + fetch: services.http.fetch, + }); + + // Internal state + const { loadingState, entries, fetchEntries } = useLogStream({ + sourceId, + startTimestamp, + endTimestamp, + query, + center, + }); + + // Derived state + const isReloading = + isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading'; + + const columnConfigurations = useMemo(() => { + return sourceConfiguration ? sourceConfiguration.configuration.logColumns : []; + }, [sourceConfiguration]); + + const streamItems = useMemo( + () => + entries.map((entry) => ({ + kind: 'logEntry' as const, + logEntry: entry, + highlights: [], + })), + [entries] + ); + + // Component lifetime + useMount(() => { + loadSourceConfiguration(); + fetchEntries(); + }); + + const parsedHeight = typeof height === 'number' ? `${height}px` : height; + + return ( + + + + ); +}; + +const LogStreamContent = euiStyled.div<{ height: string }>` + display: flex; + background-color: ${(props) => props.theme.eui.euiColorEmptyShade}; + height: ${(props) => props.height}; +`; + +// Allow for lazy loading +// eslint-disable-next-line import/no-default-export +export default LogStream; diff --git a/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx new file mode 100644 index 000000000000..65433aab1571 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx @@ -0,0 +1,16 @@ +/* + * 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 type { LogStreamProps } from './'; + +const LazyLogStream = React.lazy(() => import('./')); + +export const LazyLogStreamWrapper: React.FC = (props) => ( + }> + + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index fc0c50b9044d..ae375392b6b9 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -60,6 +60,7 @@ interface ScrollableLogTextStreamViewProps { endDateExpression: string; updateDateRange: (range: { startDateExpression?: string; endDateExpression?: string }) => void; startLiveStreaming: () => void; + hideScrollbar?: boolean; } interface ScrollableLogTextStreamViewState { @@ -146,6 +147,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< setFlyoutVisibility, setContextEntry, } = this.props; + const hideScrollbar = this.props.hideScrollbar ?? true; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; @@ -196,7 +198,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< width={width} onVisibleChildrenChange={this.handleVisibleChildrenChange} target={targetId} - hideScrollbar={true} + hideScrollbar={hideScrollbar} data-test-subj={'logStream'} isLocked={isScrollLocked} entriesCount={items.length} diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts new file mode 100644 index 000000000000..b414408512db --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -0,0 +1,89 @@ +/* + * 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 { useState, useMemo } from 'react'; +import { esKuery } from '../../../../../../../src/plugins/data/public'; +import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { LogEntry, LogEntriesCursor } from '../../../../common/http_api'; + +interface LogStreamProps { + sourceId: string; + startTimestamp: number; + endTimestamp: number; + query?: string; + center?: LogEntriesCursor; +} + +interface LogStreamState { + entries: LogEntry[]; + fetchEntries: () => void; + loadingState: 'uninitialized' | 'loading' | 'success' | 'error'; +} + +export function useLogStream({ + sourceId, + startTimestamp, + endTimestamp, + query, + center, +}: LogStreamProps): LogStreamState { + const [entries, setEntries] = useState([]); + + const parsedQuery = useMemo(() => { + return query + ? JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query))) + : null; + }, [query]); + + // Callbacks + const [entriesPromise, fetchEntries] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + setEntries([]); + const fetchPosition = center ? { center } : { before: 'last' }; + + return fetchLogEntries({ + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + ...fetchPosition, + }); + }, + onResolve: ({ data }) => { + setEntries(data.entries); + }, + }, + [sourceId, startTimestamp, endTimestamp, query] + ); + + const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [ + entriesPromise.state, + ]); + + return { + entries, + fetchEntries, + loadingState, + }; +} + +function convertPromiseStateToLoadingState( + state: 'uninitialized' | 'pending' | 'resolved' | 'rejected' +): LogStreamState['loadingState'] { + switch (state) { + case 'uninitialized': + return 'uninitialized'; + case 'pending': + return 'loading'; + case 'resolved': + return 'success'; + case 'rejected': + return 'error'; + } +} diff --git a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts index bc719cbd694e..61e1ea353880 100644 --- a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts +++ b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts @@ -3,24 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; import createContainer from 'constate'; import { LogEntry } from '../../../../common/http_api'; -import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; -import { esKuery } from '../../../../../../../src/plugins/data/public'; - -function getQueryFromLogEntry(entry: LogEntry) { - const expression = Object.entries(entry.context).reduce((kuery, [key, value]) => { - const currentExpression = `${key} : "${value}"`; - if (kuery.length > 0) { - return `${kuery} AND ${currentExpression}`; - } else { - return currentExpression; - } - }, ''); - - return JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(expression))); -} interface ViewLogInContextProps { sourceId: string; @@ -28,9 +13,7 @@ interface ViewLogInContextProps { endTimestamp: number; } -export interface ViewLogInContextState { - entries: LogEntry[]; - isLoading: boolean; +export interface ViewLogInContextState extends ViewLogInContextProps { contextEntry?: LogEntry; } @@ -42,37 +25,14 @@ export const useViewLogInContext = ( props: ViewLogInContextProps ): [ViewLogInContextState, ViewLogInContextCallbacks] => { const [contextEntry, setContextEntry] = useState(); - const [entries, setEntries] = useState([]); - const [isLoading, setIsLoading] = useState(false); const { startTimestamp, endTimestamp, sourceId } = props; - const maybeFetchLogs = useCallback(async () => { - if (contextEntry) { - setIsLoading(true); - const { data } = await fetchLogEntries({ - sourceId, - startTimestamp, - endTimestamp, - center: contextEntry.cursor, - query: getQueryFromLogEntry(contextEntry), - }); - setEntries(data.entries); - setIsLoading(false); - } else { - setEntries([]); - setIsLoading(false); - } - }, [contextEntry, startTimestamp, endTimestamp, sourceId]); - - useEffect(() => { - maybeFetchLogs(); - }, [maybeFetchLogs]); - return [ { + startTimestamp, + endTimestamp, + sourceId, contextEntry, - entries, - isLoading, }, { setContextEntry, diff --git a/x-pack/plugins/infra/public/index.ts b/x-pack/plugins/infra/public/index.ts index cadf9a483786..873e3b1ce058 100644 --- a/x-pack/plugins/infra/public/index.ts +++ b/x-pack/plugins/infra/public/index.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import { Plugin } from './plugin'; import { @@ -26,3 +25,6 @@ export { FORMATTERS } from '../common/formatters'; export { InfraFormatterType } from './lib/lib'; export type InfraAppId = 'logs' | 'metrics'; + +// Shared components +export { LazyLogStreamWrapper as LogStream } from './components/log_stream/lazy_log_stream_wrapper'; 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 3ef32c920e29..4ac3d15a8222 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 @@ -12,43 +12,38 @@ import { EuiText, EuiTextColor, EuiToolTip, - EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { noop } from 'lodash'; +import { isEmpty } from 'lodash'; import React, { useCallback, useContext, useMemo } from 'react'; import { LogEntry } from '../../../../common/http_api'; -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'; import { euiStyled } from '../../../../../observability/public'; +import { LogStream } from '../../../components/log_stream'; const MODAL_MARGIN = 25; export const PageViewLogInContext: React.FC = () => { - const { sourceConfiguration } = useLogSourceContext(); - const { textScale, textWrap } = useContext(LogViewConfiguration.Context); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const columnConfigurations = useMemo(() => sourceConfiguration?.configuration.logColumns ?? [], [ - sourceConfiguration, - ]); - const [{ contextEntry, entries, isLoading }, { setContextEntry }] = useContext( - ViewLogInContext.Context - ); + const [ + { contextEntry, startTimestamp, endTimestamp, sourceId }, + { setContextEntry }, + ] = useContext(ViewLogInContext.Context); const closeModal = useCallback(() => setContextEntry(undefined), [setContextEntry]); const { width: vw, height: vh } = useViewportDimensions(); - const streamItems = useMemo( - () => - entries.map((entry) => ({ - kind: 'logEntry' as const, - logEntry: entry, - highlights: [], - })), - [entries] - ); + const contextQuery = useMemo(() => { + if (contextEntry && !isEmpty(contextEntry.context)) { + return Object.entries(contextEntry.context).reduce((kuery, [key, value]) => { + const currentExpression = `${key} : "${value}"`; + if (kuery.length > 0) { + return `${kuery} AND ${currentExpression}`; + } else { + return currentExpression; + } + }, ''); + } + }, [contextEntry]); if (!contextEntry) { return null; @@ -64,31 +59,18 @@ export const PageViewLogInContext: React.FC = () => { wrap={false} style={{ height: '100%' }} > - + - - + +