diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx new file mode 100644 index 0000000000000..00deeff89503f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx @@ -0,0 +1,278 @@ +/* + * 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, { memo, useMemo, useState, useCallback, useEffect } from 'react'; +import styled from 'styled-components'; +import url from 'url'; +import { encode } from 'rison-node'; +import { stringify } from 'query-string'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + EuiFilterGroup, + EuiPanel, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import semverGte from 'semver/functions/gte'; +import semverCoerce from 'semver/functions/coerce'; +import { createStateContainerReactHelpers } from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; +import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; +import { LogStream } from '../../../../../../../../../infra/public'; +import { Agent } from '../../../../../types'; +import { useStartServices } from '../../../../../hooks'; +import { DEFAULT_DATE_RANGE } from './constants'; +import { DatasetFilter } from './filter_dataset'; +import { LogLevelFilter } from './filter_log_level'; +import { LogQueryBar } from './query_bar'; +import { buildQuery } from './build_query'; +import { SelectLogLevel } from './select_log_level'; + +const WrapperFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +const DatePickerFlexItem = styled(EuiFlexItem)` + max-width: 312px; +`; + +export interface AgentLogsProps { + agent: Agent; + state: AgentLogsState; +} + +export interface AgentLogsState { + start: string; + end: string; + logLevels: string[]; + datasets: string[]; + query: string; +} + +export const AgentLogsUrlStateHelper = createStateContainerReactHelpers(); + +export const AgentLogsUI: React.FunctionComponent = memo(({ agent, state }) => { + const { data, application, http } = useStartServices(); + const { update: updateState } = AgentLogsUrlStateHelper.useTransitions(); + + // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) + const getDateRangeTimestamps = useCallback( + (timeRange: TimeRange) => { + const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); + return min && max + ? { + start: min.valueOf(), + end: max.valueOf(), + } + : undefined; + }, + [data.query.timefilter.timefilter] + ); + + const tryUpdateDateRange = useCallback( + (timeRange: TimeRange) => { + const timestamps = getDateRangeTimestamps(timeRange); + if (timestamps) { + updateState({ + start: timeRange.from, + end: timeRange.to, + }); + } + }, + [getDateRangeTimestamps, updateState] + ); + + const [dateRangeTimestamps, setDateRangeTimestamps] = useState<{ start: number; end: number }>( + getDateRangeTimestamps({ + from: state.start, + to: state.end, + }) || + getDateRangeTimestamps({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + })! + ); + + // Attempts to parse for timestamps when start/end date expressions change + // If invalid date expressions, set expressions back to default + // Otherwise set the new timestamps + useEffect(() => { + const timestampsFromDateRange = getDateRangeTimestamps({ + from: state.start, + to: state.end, + }); + if (!timestampsFromDateRange) { + tryUpdateDateRange({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + }); + } else { + setDateRangeTimestamps(timestampsFromDateRange); + } + }, [state.start, state.end, getDateRangeTimestamps, tryUpdateDateRange]); + + // Query validation helper + const isQueryValid = useCallback((testQuery: string) => { + try { + esKuery.fromKueryExpression(testQuery); + return true; + } catch (err) { + return false; + } + }, []); + + // User query state + const [draftQuery, setDraftQuery] = useState(state.query); + const [isDraftQueryValid, setIsDraftQueryValid] = useState(isQueryValid(state.query)); + const onUpdateDraftQuery = useCallback( + (newDraftQuery: string, runQuery?: boolean) => { + setDraftQuery(newDraftQuery); + if (isQueryValid(newDraftQuery)) { + setIsDraftQueryValid(true); + if (runQuery) { + updateState({ query: newDraftQuery }); + } + } else { + setIsDraftQueryValid(false); + } + }, + [isQueryValid, updateState] + ); + + // Build final log stream query from agent id, datasets, log levels, and user input + const logStreamQuery = useMemo( + () => + buildQuery({ + agentId: agent.id, + datasets: state.datasets, + logLevels: state.logLevels, + userQuery: state.query, + }), + [agent.id, state.datasets, state.logLevels, state.query] + ); + + // Generate URL to pass page state to Logs UI + const viewInLogsUrl = useMemo( + () => + http.basePath.prepend( + url.format({ + pathname: '/app/logs/stream', + search: stringify( + { + logPosition: encode({ + start: state.start, + end: state.end, + streamLive: false, + }), + logFilter: encode({ + expression: logStreamQuery, + kind: 'kuery', + }), + }, + { sort: false, encode: false } + ), + }) + ), + [http.basePath, state.start, state.end, logStreamQuery] + ); + + const agentVersion = agent.local_metadata?.elastic?.agent?.version; + const isLogLevelSelectionAvailable = useMemo(() => { + if (!agentVersion) { + return false; + } + const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; + if (!agentVersionWithPrerelease) { + return false; + } + return semverGte(agentVersionWithPrerelease, '7.11.0'); + }, [agentVersion]); + + return ( + + + + + + + + + { + const currentDatasets = [...state.datasets]; + const datasetPosition = currentDatasets.indexOf(dataset); + if (datasetPosition >= 0) { + currentDatasets.splice(datasetPosition, 1); + updateState({ datasets: currentDatasets }); + } else { + updateState({ datasets: [...state.datasets, dataset] }); + } + }} + /> + { + const currentLevels = [...state.logLevels]; + const levelPosition = currentLevels.indexOf(level); + if (levelPosition >= 0) { + currentLevels.splice(levelPosition, 1); + updateState({ logLevels: currentLevels }); + } else { + updateState({ logLevels: [...state.logLevels, level] }); + } + }} + /> + + + + { + tryUpdateDateRange({ + from: start, + to: end, + }); + }} + /> + + + + + + + + + + + + + + + + {isLogLevelSelectionAvailable && ( + + + + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index ea98de3560246..89fe1a916605d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { AgentLogsState } from './agent_logs'; + export const AGENT_LOG_INDEX_PATTERN = 'logs-elastic_agent-*,logs-elastic_agent.*-*'; export const AGENT_DATASET = 'elastic_agent'; export const AGENT_DATASET_PATTERN = 'elastic_agent.*'; @@ -24,6 +26,13 @@ export const DEFAULT_DATE_RANGE = { start: 'now-1d', end: 'now', }; +export const DEFAULT_LOGS_STATE: AgentLogsState = { + start: DEFAULT_DATE_RANGE.start, + end: DEFAULT_DATE_RANGE.end, + logLevels: [], + datasets: [AGENT_DATASET], + query: '', +}; export const AGENT_LOG_LEVELS = { ERROR: 'error', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx index bed857c073099..0d888a88ec2cb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx @@ -3,236 +3,63 @@ * 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, { memo, useMemo, useState, useCallback } from 'react'; -import styled from 'styled-components'; -import url from 'url'; -import { encode } from 'rison-node'; -import { stringify } from 'query-string'; +import React, { memo, useEffect, useState } from 'react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiSuperDatePicker, - EuiFilterGroup, - EuiPanel, - EuiButtonEmpty, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import semverGte from 'semver/functions/gte'; -import semverCoerce from 'semver/functions/coerce'; -import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; -import { LogStream } from '../../../../../../../../../infra/public'; -import { Agent } from '../../../../../types'; -import { useStartServices } from '../../../../../hooks'; -import { AGENT_DATASET, DEFAULT_DATE_RANGE } from './constants'; -import { DatasetFilter } from './filter_dataset'; -import { LogLevelFilter } from './filter_log_level'; -import { LogQueryBar } from './query_bar'; -import { buildQuery } from './build_query'; -import { SelectLogLevel } from './select_log_level'; + createStateContainer, + syncState, + createKbnUrlStateStorage, + INullableBaseStateContainer, + PureTransition, + getStateFromKbnUrl, +} from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { DEFAULT_LOGS_STATE } from './constants'; +import { AgentLogsUI, AgentLogsProps, AgentLogsState, AgentLogsUrlStateHelper } from './agent_logs'; -const WrapperFlexGroup = styled(EuiFlexGroup)` - height: 100%; -`; +const stateStorageKey = '_q'; -const DatePickerFlexItem = styled(EuiFlexItem)` - max-width: 312px; -`; +const stateContainer = createStateContainer< + AgentLogsState, + { + update: PureTransition]>; + } +>( + { + ...DEFAULT_LOGS_STATE, + ...getStateFromKbnUrl(stateStorageKey, window.location.href), + }, + { + update: (state) => (updatedState) => ({ ...state, ...updatedState }), + } +); -export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agent }) => { - const { data, application, http } = useStartServices(); +const AgentLogsConnected = AgentLogsUrlStateHelper.connect((state) => ({ + state: state || DEFAULT_LOGS_STATE, +}))(AgentLogsUI); - // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) - const getDateRangeTimestamps = useCallback( - (timeRange: TimeRange) => { - const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); - return min && max - ? { - startTimestamp: min.valueOf(), - endTimestamp: max.valueOf(), - } - : undefined; - }, - [data.query.timefilter.timefilter] - ); +export const AgentLogs: React.FunctionComponent> = memo( + ({ agent }) => { + const [isSyncReady, setIsSyncReady] = useState(false); - // Initial time range filter - const [dateRange, setDateRange] = useState<{ - startExpression: string; - endExpression: string; - startTimestamp: number; - endTimestamp: number; - }>({ - startExpression: DEFAULT_DATE_RANGE.start, - endExpression: DEFAULT_DATE_RANGE.end, - ...getDateRangeTimestamps({ from: DEFAULT_DATE_RANGE.start, to: DEFAULT_DATE_RANGE.end })!, - }); + useEffect(() => { + const stateStorage = createKbnUrlStateStorage(); + const { start, stop } = syncState({ + storageKey: stateStorageKey, + stateContainer: stateContainer as INullableBaseStateContainer, + stateStorage, + }); + start(); + setIsSyncReady(true); - const tryUpdateDateRange = useCallback( - (timeRange: TimeRange) => { - const timestamps = getDateRangeTimestamps(timeRange); - if (timestamps) { - setDateRange({ - startExpression: timeRange.from, - endExpression: timeRange.to, - ...timestamps, - }); - } - }, - [getDateRangeTimestamps] - ); + return () => { + stop(); + stateContainer.set(DEFAULT_LOGS_STATE); + }; + }, []); - // Filters - const [selectedLogLevels, setSelectedLogLevels] = useState([]); - const [selectedDatasets, setSelectedDatasets] = useState([AGENT_DATASET]); - - // User query state - const [query, setQuery] = useState(''); - const [draftQuery, setDraftQuery] = useState(''); - const [isDraftQueryValid, setIsDraftQueryValid] = useState(true); - const onUpdateDraftQuery = useCallback((newDraftQuery: string, runQuery?: boolean) => { - setDraftQuery(newDraftQuery); - try { - esKuery.fromKueryExpression(newDraftQuery); - setIsDraftQueryValid(true); - if (runQuery) { - setQuery(newDraftQuery); - } - } catch (err) { - setIsDraftQueryValid(false); - } - }, []); - - // Build final log stream query from agent id, datasets, log levels, and user input - const logStreamQuery = useMemo( - () => - buildQuery({ - agentId: agent.id, - datasets: selectedDatasets, - logLevels: selectedLogLevels, - userQuery: query, - }), - [agent.id, query, selectedDatasets, selectedLogLevels] - ); - - // Generate URL to pass page state to Logs UI - const viewInLogsUrl = useMemo( - () => - http.basePath.prepend( - url.format({ - pathname: '/app/logs/stream', - search: stringify( - { - logPosition: encode({ - start: dateRange.startExpression, - end: dateRange.endExpression, - streamLive: false, - }), - logFilter: encode({ - expression: logStreamQuery, - kind: 'kuery', - }), - }, - { sort: false, encode: false } - ), - }) - ), - [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] - ); - - const agentVersion = agent.local_metadata?.elastic?.agent?.version; - const isLogLevelSelectionAvailable = useMemo(() => { - if (!agentVersion) { - return false; - } - const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; - if (!agentVersionWithPrerelease) { - return false; - } - return semverGte(agentVersionWithPrerelease, '7.11.0'); - }, [agentVersion]); - - return ( - - - - - - - - - { - const currentLevels = [...selectedDatasets]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedDatasets(currentLevels); - } else { - setSelectedDatasets([...selectedDatasets, level]); - } - }} - /> - { - const currentLevels = [...selectedLogLevels]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedLogLevels(currentLevels); - } else { - setSelectedLogLevels([...selectedLogLevels, level]); - } - }} - /> - - - - { - tryUpdateDateRange({ - from: start, - to: end, - }); - }} - /> - - - - - - - - - - - - - - - - {isLogLevelSelectionAvailable && ( - - - - )} - - ); -}); + return ( + + {isSyncReady ? : null} + + ); + } +);