From cc9c30be9655a916819514947fbae82f0a9c6815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 19 Sep 2018 23:58:59 +0200 Subject: [PATCH] [Infra UI] Provide routes for accessing pre-filtered log views (#23246) This PR introduces a set of routes that can be used as stable entry points into the infra ui with partly pre-populated stated (e.g. filters and time): * `app/infra/#/link-to/container-logs/:containerId[?time=${TIMESTAMP}]` * `app/infra/#/link-to/host-logs/:hostname[?time=${TIMESTAMP}]` * `app/infra/#/link-to/pod-logs/:podId[?time=${TIMESTAMP}]` It also fixes the links from the waffle map to the logging ui to result in an appropriately filtered view. --- x-pack/plugins/infra/common/graphql/types.ts | 8 +++ .../infra/public/components/loading_page.tsx | 35 +++++++++++++ .../plugins/infra/public/components/page.tsx | 5 ++ .../components/waffle/node_context_menu.tsx | 37 +++++++++++-- .../containers/logs/with_log_filter.tsx | 8 ++- .../containers/logs/with_log_position.tsx | 12 ++++- .../infra/public/containers/with_source.ts | 16 ++++++ .../infra/public/pages/link_to/index.ts | 10 ++++ .../infra/public/pages/link_to/link_to.tsx | 34 ++++++++++++ .../public/pages/link_to/query_params.ts | 14 +++++ .../link_to/redirect_to_container_logs.tsx | 43 +++++++++++++++ .../pages/link_to/redirect_to_host_logs.tsx | 38 ++++++++++++++ .../pages/link_to/redirect_to_pod_logs.tsx | 35 +++++++++++++ x-pack/plugins/infra/public/routes.tsx | 2 + .../operations/query_source.gql_query.ts | 5 ++ .../public/store/remote/source/selectors.ts | 5 ++ .../plugins/infra/public/utils/url_state.tsx | 52 ++++++++++++------- 17 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/loading_page.tsx create mode 100644 x-pack/plugins/infra/public/containers/with_source.ts create mode 100644 x-pack/plugins/infra/public/pages/link_to/index.ts create mode 100644 x-pack/plugins/infra/public/pages/link_to/link_to.tsx create mode 100644 x-pack/plugins/infra/public/pages/link_to/query_params.ts create mode 100644 x-pack/plugins/infra/public/pages/link_to/redirect_to_container_logs.tsx create mode 100644 x-pack/plugins/infra/public/pages/link_to/redirect_to_host_logs.tsx create mode 100644 x-pack/plugins/infra/public/pages/link_to/redirect_to_pod_logs.tsx diff --git a/x-pack/plugins/infra/common/graphql/types.ts b/x-pack/plugins/infra/common/graphql/types.ts index fb412c1a82b31..5c9b96f23c262 100644 --- a/x-pack/plugins/infra/common/graphql/types.ts +++ b/x-pack/plugins/infra/common/graphql/types.ts @@ -659,6 +659,14 @@ export namespace SourceQuery { __typename?: 'InfraSourceConfiguration'; metricAlias: string; logAlias: string; + fields: Fields; + }; + + export type Fields = { + __typename?: 'InfraSourceFields'; + container: string; + hostname: string; + pod: string; }; export type Status = { diff --git a/x-pack/plugins/infra/public/components/loading_page.tsx b/x-pack/plugins/infra/public/components/loading_page.tsx new file mode 100644 index 0000000000000..606f1fb69aa55 --- /dev/null +++ b/x-pack/plugins/infra/public/components/loading_page.tsx @@ -0,0 +1,35 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPageBody, + EuiPageContent, +} from '@elastic/eui'; +import React from 'react'; + +import { FlexPage } from './page'; + +interface LoadingPageProps { + message?: string; +} + +export const LoadingPage = ({ message }: LoadingPageProps) => ( + + + + + + + + {message} + + + + +); diff --git a/x-pack/plugins/infra/public/components/page.tsx b/x-pack/plugins/infra/public/components/page.tsx index cbb90c256ae41..04d69fdb1d9e8 100644 --- a/x-pack/plugins/infra/public/components/page.tsx +++ b/x-pack/plugins/infra/public/components/page.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiPage } from '@elastic/eui'; import styled from 'styled-components'; export const ColumnarPage = styled.div` @@ -19,3 +20,7 @@ export const PageContent = styled.div` flex-direction: row; background-color: ${props => props.theme.eui.euiColorEmptyShade}; `; + +export const FlexPage = styled(EuiPage)` + flex: 1 0 0; +`; diff --git a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx index db54097b1310d..b6f263f883cf5 100644 --- a/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -7,6 +7,7 @@ import { EuiContextMenu, EuiContextMenuPanelDescriptor, EuiPopover } from '@elastic/eui'; import React from 'react'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; +import { getContainerLogsUrl, getHostLogsUrl, getPodLogsUrl } from '../../pages/link_to'; interface Props { options: InfraWaffleMapOptions; @@ -24,15 +25,21 @@ export const NodeContextMenu: React.SFC = ({ // TODO: This needs to be change to be dynamic based on the options passed in. const nodeType = 'host'; + const nodeLogsUrl = getNodeLogsUrl(nodeType, node); + const panels: EuiContextMenuPanelDescriptor[] = [ { id: 0, title: '', items: [ - { - name: `View logs for this ${nodeType}`, - href: `#/logs?filter=${node.name}`, - }, + ...(nodeLogsUrl + ? [ + { + name: `View logs for this ${nodeType}`, + href: nodeLogsUrl, + }, + ] + : []), { name: `View APM Traces for this ${nodeType}`, href: `/app/apm`, @@ -52,3 +59,25 @@ export const NodeContextMenu: React.SFC = ({ ); }; + +const getNodeLogsUrl = ( + nodeType: 'host' | 'container' | 'pod', + { path }: InfraWaffleMapNode +): string | undefined => { + if (path.length <= 0) { + return undefined; + } + + const lastPathSegment = path[path.length - 1]; + + switch (nodeType) { + case 'host': + return getHostLogsUrl({ hostname: lastPathSegment.value }); + case 'container': + return getContainerLogsUrl({ containerId: lastPathSegment.value }); + case 'host': + return getPodLogsUrl({ podId: lastPathSegment.value }); + default: + return undefined; + } +}; diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_filter.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_filter.tsx index aff1eca5c0614..fba0075c3c3b3 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_log_filter.tsx +++ b/x-pack/plugins/infra/public/containers/logs/with_log_filter.tsx @@ -10,7 +10,7 @@ import { connect } from 'react-redux'; import { logFilterActions, logFilterSelectors, State } from '../../store'; import { asChildFunctionRenderer } from '../../utils/typed_react'; import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { UrlStateContainer } from '../../utils/url_state'; +import { replaceStateKeyInQueryString, UrlStateContainer } from '../../utils/url_state'; const withLogFilter = connect( (state: State) => ({ @@ -71,3 +71,9 @@ const mapToFilterQuery = (value: any): LogFilterUrlState | undefined => expression: value.expression, } : undefined; + +export const replaceLogFilterInQueryString = (expression: string) => + replaceStateKeyInQueryString('logFilter', { + kind: 'kuery', + expression, + }); diff --git a/x-pack/plugins/infra/public/containers/logs/with_log_position.tsx b/x-pack/plugins/infra/public/containers/logs/with_log_position.tsx index a6647fe6c88a0..a548ae62facb0 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_log_position.tsx +++ b/x-pack/plugins/infra/public/containers/logs/with_log_position.tsx @@ -12,7 +12,7 @@ import { pickTimeKey } from '../../../common/time'; import { logPositionActions, logPositionSelectors, State } from '../../store'; import { asChildFunctionRenderer } from '../../utils/typed_react'; import { bindPlainActionCreators } from '../../utils/typed_redux'; -import { UrlStateContainer } from '../../utils/url_state'; +import { replaceStateKeyInQueryString, UrlStateContainer } from '../../utils/url_state'; export const withLogPosition = connect( (state: State) => ({ @@ -113,3 +113,13 @@ const mapToPositionUrlState = (value: any) => : undefined; const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : undefined); + +export const replaceLogPositionInQueryString = (time: number) => + Number.isNaN(time) + ? (value: string) => value + : replaceStateKeyInQueryString('logPosition', { + position: { + time, + tiebreaker: 0, + }, + }); diff --git a/x-pack/plugins/infra/public/containers/with_source.ts b/x-pack/plugins/infra/public/containers/with_source.ts new file mode 100644 index 0000000000000..db4f01608f178 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/with_source.ts @@ -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 { connect } from 'react-redux'; + +import { sourceSelectors, State } from '../store'; +import { asChildFunctionRenderer } from '../utils/typed_react'; + +export const withSource = connect((state: State) => ({ + configuredFields: sourceSelectors.selectSourceFields(state), +})); + +export const WithSource = asChildFunctionRenderer(withSource); diff --git a/x-pack/plugins/infra/public/pages/link_to/index.ts b/x-pack/plugins/infra/public/pages/link_to/index.ts new file mode 100644 index 0000000000000..6862c2cc2195d --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/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 { LinkToPage } from './link_to'; +export { getContainerLogsUrl, RedirectToContainerLogs } from './redirect_to_container_logs'; +export { getHostLogsUrl, RedirectToHostLogs } from './redirect_to_host_logs'; +export { getPodLogsUrl, RedirectToPodLogs } from './redirect_to_pod_logs'; diff --git a/x-pack/plugins/infra/public/pages/link_to/link_to.tsx b/x-pack/plugins/infra/public/pages/link_to/link_to.tsx new file mode 100644 index 0000000000000..006ff63f825f6 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/link_to.tsx @@ -0,0 +1,34 @@ +/* + * 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 { match as RouteMatch, Redirect, Route, Switch } from 'react-router-dom'; + +import { RedirectToContainerLogs } from './redirect_to_container_logs'; +import { RedirectToHostLogs } from './redirect_to_host_logs'; +import { RedirectToPodLogs } from './redirect_to_pod_logs'; + +interface LinkToPageProps { + match: RouteMatch<{}>; +} + +export class LinkToPage extends React.Component { + public render() { + const { match } = this.props; + + return ( + + + + + + + ); + } +} diff --git a/x-pack/plugins/infra/public/pages/link_to/query_params.ts b/x-pack/plugins/infra/public/pages/link_to/query_params.ts new file mode 100644 index 0000000000000..8ee492ebc010d --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/query_params.ts @@ -0,0 +1,14 @@ +/* + * 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 { Location } from 'history'; + +import { getParamFromQueryString, getQueryStringFromLocation } from '../../utils/url_state'; + +export const getTimeFromLocation = (location: Location) => { + const timeParam = getParamFromQueryString(getQueryStringFromLocation(location), 'time'); + return timeParam ? parseFloat(timeParam) : NaN; +}; diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_container_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_container_logs.tsx new file mode 100644 index 0000000000000..d6d4d89eb168e --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_container_logs.tsx @@ -0,0 +1,43 @@ +/* + * 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 compose from 'lodash/fp/compose'; +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +import { LoadingPage } from '../../components/loading_page'; +import { replaceLogFilterInQueryString } from '../../containers/logs/with_log_filter'; +import { replaceLogPositionInQueryString } from '../../containers/logs/with_log_position'; +import { WithSource } from '../../containers/with_source'; +import { getTimeFromLocation } from './query_params'; + +export const RedirectToContainerLogs = ({ + match, + location, +}: RouteComponentProps<{ containerId: string }>) => ( + + {({ configuredFields }) => { + if (!configuredFields) { + return ; + } + + const searchString = compose( + replaceLogFilterInQueryString(`${configuredFields.container}: ${match.params.containerId}`), + replaceLogPositionInQueryString(getTimeFromLocation(location)) + )(''); + + return ; + }} + +); + +export const getContainerLogsUrl = ({ + containerId, + time, +}: { + containerId: string; + time?: number; +}) => ['#/link-to/container-logs/', containerId, ...(time ? [`?time=${time}`] : [])].join(''); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_logs.tsx new file mode 100644 index 0000000000000..480a3d9167acc --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_host_logs.tsx @@ -0,0 +1,38 @@ +/* + * 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 compose from 'lodash/fp/compose'; +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +import { LoadingPage } from '../../components/loading_page'; +import { replaceLogFilterInQueryString } from '../../containers/logs/with_log_filter'; +import { replaceLogPositionInQueryString } from '../../containers/logs/with_log_position'; +import { WithSource } from '../../containers/with_source'; +import { getTimeFromLocation } from './query_params'; + +export const RedirectToHostLogs = ({ + match, + location, +}: RouteComponentProps<{ hostname: string }>) => ( + + {({ configuredFields }) => { + if (!configuredFields) { + return ; + } + + const searchString = compose( + replaceLogFilterInQueryString(`${configuredFields.hostname}: ${match.params.hostname}`), + replaceLogPositionInQueryString(getTimeFromLocation(location)) + )(''); + + return ; + }} + +); + +export const getHostLogsUrl = ({ hostname, time }: { hostname: string; time?: number }) => + ['#/link-to/host-logs/', hostname, ...(time ? [`?time=${time}`] : [])].join(''); diff --git a/x-pack/plugins/infra/public/pages/link_to/redirect_to_pod_logs.tsx b/x-pack/plugins/infra/public/pages/link_to/redirect_to_pod_logs.tsx new file mode 100644 index 0000000000000..0ba15ee346f4c --- /dev/null +++ b/x-pack/plugins/infra/public/pages/link_to/redirect_to_pod_logs.tsx @@ -0,0 +1,35 @@ +/* + * 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 compose from 'lodash/fp/compose'; +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +import { LoadingPage } from '../../components/loading_page'; +import { replaceLogFilterInQueryString } from '../../containers/logs/with_log_filter'; +import { replaceLogPositionInQueryString } from '../../containers/logs/with_log_position'; +import { WithSource } from '../../containers/with_source'; +import { getTimeFromLocation } from './query_params'; + +export const RedirectToPodLogs = ({ match, location }: RouteComponentProps<{ podId: string }>) => ( + + {({ configuredFields }) => { + if (!configuredFields) { + return ; + } + + const searchString = compose( + replaceLogFilterInQueryString(`${configuredFields.pod}: ${match.params.podId}`), + replaceLogPositionInQueryString(getTimeFromLocation(location)) + )(''); + + return ; + }} + +); + +export const getPodLogsUrl = ({ podId, time }: { podId: string; time?: number }) => + ['#/link-to/pod-logs/', podId, ...(time ? [`?time=${time}`] : [])].join(''); diff --git a/x-pack/plugins/infra/public/routes.tsx b/x-pack/plugins/infra/public/routes.tsx index 2c206b5403321..2201098a73a00 100644 --- a/x-pack/plugins/infra/public/routes.tsx +++ b/x-pack/plugins/infra/public/routes.tsx @@ -10,6 +10,7 @@ import { Redirect, Route, Router, Switch } from 'react-router-dom'; import { NotFoundPage } from './pages/404'; import { HomePage } from './pages/home'; +import { LinkToPage } from './pages/link_to'; import { LogsPage } from './pages/logs'; interface RouterProps { @@ -23,6 +24,7 @@ export const PageRouter: React.SFC = ({ history }) => { + diff --git a/x-pack/plugins/infra/public/store/remote/source/operations/query_source.gql_query.ts b/x-pack/plugins/infra/public/store/remote/source/operations/query_source.gql_query.ts index 0a93e13845ad4..d76d23070d9d5 100644 --- a/x-pack/plugins/infra/public/store/remote/source/operations/query_source.gql_query.ts +++ b/x-pack/plugins/infra/public/store/remote/source/operations/query_source.gql_query.ts @@ -12,6 +12,11 @@ export const sourceQuery = gql` configuration { metricAlias logAlias + fields { + container + hostname + pod + } } status { indexFields { diff --git a/x-pack/plugins/infra/public/store/remote/source/selectors.ts b/x-pack/plugins/infra/public/store/remote/source/selectors.ts index 7858e5bb996ac..6ac12d9b79b88 100644 --- a/x-pack/plugins/infra/public/store/remote/source/selectors.ts +++ b/x-pack/plugins/infra/public/store/remote/source/selectors.ts @@ -28,6 +28,11 @@ export const selectSourceMetricAlias = createSelector( configuration => (configuration ? configuration.metricAlias : null) ); +export const selectSourceFields = createSelector( + selectSourceConfiguration, + configuration => (configuration ? configuration.fields : null) +); + export const selectSourceStatus = createSelector( selectSource, source => (source ? source.status : null) diff --git a/x-pack/plugins/infra/public/utils/url_state.tsx b/x-pack/plugins/infra/public/utils/url_state.tsx index 418aecd332207..8ed80fbb2e383 100644 --- a/x-pack/plugins/infra/public/utils/url_state.tsx +++ b/x-pack/plugins/infra/public/utils/url_state.tsx @@ -56,7 +56,10 @@ class UrlStateContainerLifecycle extends React.Component< private replaceStateInLocation = throttle(1000, (urlState: UrlState | undefined) => { const { history, location, urlStateKey } = this.props; - const newLocation = updateLocationWithUrlState(urlStateKey, urlState, this.props.location); + const newLocation = replaceQueryStringInLocation( + location, + replaceStateKeyInQueryString(urlStateKey, urlState)(getQueryStringFromLocation(location)) + ); if (newLocation !== location) { history.replace(newLocation); @@ -70,8 +73,10 @@ class UrlStateContainerLifecycle extends React.Component< return; } - const newQueryValue = parseLocation(location)[urlStateKey]; - const newUrlStateString = Array.isArray(newQueryValue) ? newQueryValue[0] : newQueryValue; + const newUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(location), + urlStateKey + ); const newUrlState = mapToUrlState(decodeRisonUrlState(newUrlStateString)); onInitialize(newUrlState); @@ -84,13 +89,14 @@ class UrlStateContainerLifecycle extends React.Component< return; } - const previousQueryValue = parseLocation(prevLocation)[urlStateKey]; - const newQueryValue = parseLocation(newLocation)[urlStateKey]; - - const previousUrlStateString = Array.isArray(previousQueryValue) - ? previousQueryValue[0] - : previousQueryValue; - const newUrlStateString = Array.isArray(newQueryValue) ? newQueryValue[0] : newQueryValue; + const previousUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(prevLocation), + urlStateKey + ); + const newUrlStateString = getParamFromQueryString( + getQueryStringFromLocation(newLocation), + urlStateKey + ); if (previousUrlStateString !== newUrlStateString) { const previousUrlState = mapToUrlState(decodeRisonUrlState(previousUrlStateString)); @@ -113,7 +119,7 @@ export const UrlStateContainer = ( ); -const decodeRisonUrlState = (value: string): RisonValue | undefined => { +export const decodeRisonUrlState = (value: string | undefined): RisonValue | undefined => { try { return value ? decode(value) : undefined; } catch (error) { @@ -126,27 +132,33 @@ const decodeRisonUrlState = (value: string): RisonValue | undefined => { const encodeRisonUrlState = (state: any) => encode(state); -const parseLocation = (location: Location) => parseQueryString(location.search.substring(1)); +export const getQueryStringFromLocation = (location: Location) => location.search.substring(1); + +export const getParamFromQueryString = (queryString: string, key: string): string | undefined => { + const queryParam = parseQueryString(queryString)[key]; + return Array.isArray(queryParam) ? queryParam[0] : queryParam; +}; -const updateLocationWithUrlState = ( +export const replaceStateKeyInQueryString = ( stateKey: string, - urlState: UrlState | undefined, - location: Location -): Location => { - const previousQueryValues = parseLocation(location); + urlState: UrlState | undefined +) => (queryString: string) => { + const previousQueryValues = parseQueryString(queryString); const encodedUrlState = typeof urlState !== 'undefined' ? encodeRisonUrlState(urlState) : undefined; - const newQueryString = stringifyQueryString({ + return stringifyQueryString({ ...previousQueryValues, [stateKey]: encodedUrlState, }); +}; - if (newQueryString === location.search.substring(1)) { +const replaceQueryStringInLocation = (location: Location, queryString: string): Location => { + if (queryString === getQueryStringFromLocation(location)) { return location; } else { return { ...location, - search: `?${newQueryString}`, + search: `?${queryString}`, }; } };