diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.test.tsx index 6de1a3caaa2c1..6e965dc9eca6a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.test.tsx @@ -63,6 +63,8 @@ describe('AgentLogsUI', () => { const state = { datasets: ['elastic_agent'], logLevels: ['info', 'error'], + start: '2023-20-04T14:00:00.340Z', + end: '2023-20-04T14:20:00.340Z', query: '', } as any; return render(); @@ -97,7 +99,10 @@ describe('AgentLogsUI', () => { it('should render Open in Logs UI if capabilities not set', () => { mockStartServices(); const result = renderComponent(); - expect(result.getByTestId('viewInLogsBtn')).not.toBeNull(); + expect(result.getByTestId('viewInLogsBtn')).toHaveAttribute( + 'href', + `http://localhost:5620/app/logs/stream?logPosition=(end%3A'2023-20-04T14%3A20%3A00.340Z'%2Cstart%3A'2023-20-04T14%3A00%3A00.340Z'%2CstreamLive%3A!f)&logFilter=(expression%3A'elastic_agent.id%3Aagent1%20and%20(data_stream.dataset%3Aelastic_agent)%20and%20(log.level%3Ainfo%20or%20log.level%3Aerror)'%2Ckind%3Akuery)` + ); }); it('should render Open in Discover if serverless enabled', () => { @@ -106,7 +111,7 @@ describe('AgentLogsUI', () => { const viewInDiscover = result.getByTestId('viewInDiscoverBtn'); expect(viewInDiscover).toHaveAttribute( 'href', - `http://localhost:5620/app/discover#/?_a=(index:'logs-*',query:(language:kuery,query:'data_stream.dataset:elastic_agent%20AND%20elastic_agent.id:agent1'))` + `http://localhost:5620/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:'2023-20-04T14:00:00.340Z',to:'2023-20-04T14:20:00.340Z'))&_a=(columns:!(event.dataset,message),index:'logs-*',query:(language:kuery,query:'elastic_agent.id:agent1 and (data_stream.dataset:elastic_agent) and (log.level:info or log.level:error)'))` ); }); }); 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 index 8efef6592f870..05efb3abfdcaa 100644 --- 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 @@ -4,21 +4,14 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import url from 'url'; -import { stringify } from 'querystring'; - import React, { memo, useMemo, useState, useCallback, useEffect } from 'react'; import styled from 'styled-components'; -import { encode } from '@kbn/rison'; import { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiFilterGroup, EuiPanel, - EuiButton, - EuiButtonEmpty, EuiCallOut, EuiLink, } from '@elastic/eui'; @@ -42,6 +35,7 @@ import { LogLevelFilter } from './filter_log_level'; import { LogQueryBar } from './query_bar'; import { buildQuery } from './build_query'; import { SelectLogLevel } from './select_log_level'; +import { ViewLogsButton } from './view_logs_button'; const WrapperFlexGroup = styled(EuiFlexGroup)` height: 100%; @@ -118,7 +112,7 @@ const AgentPolicyLogsNotEnabledCallout: React.FunctionComponent<{ agentPolicy: A export const AgentLogsUI: React.FunctionComponent = memo( ({ agent, agentPolicy, state }) => { - const { data, application, http, cloud } = useStartServices(); + const { data, application, cloud } = useStartServices(); const { update: updateState } = AgentLogsUrlStateHelper.useTransitions(); const isLogsUIAvailable = !cloud?.isServerlessEnabled; @@ -218,37 +212,6 @@ export const AgentLogsUI: React.FunctionComponent = memo( [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', - }), - }), - }) - ), - [http.basePath, state.start, state.end, logStreamQuery] - ); - - const viewInDiscoverUrl = useMemo(() => { - const index = 'logs-*'; - const datasetQuery = 'data_stream.dataset:elastic_agent'; - const agentIdQuery = `elastic_agent.id:${agent.id}`; - return http.basePath.prepend( - `/app/discover#/?_a=(index:'${index}',query:(language:kuery,query:'${datasetQuery}%20AND%20${agentIdQuery}'))` - ); - }, [http.basePath, agent.id]); - const agentVersion = agent.local_metadata?.elastic?.agent?.version; const isLogFeatureAvailable = useMemo(() => { if (!agentVersion) { @@ -357,30 +320,12 @@ export const AgentLogsUI: React.FunctionComponent = memo( application, }} > - {isLogsUIAvailable ? ( - - - - ) : ( - - - - )} + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx new file mode 100644 index 0000000000000..762c34ad7bc36 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import url from 'url'; +import { stringify } from 'querystring'; + +import React, { useMemo } from 'react'; +import { encode } from '@kbn/rison'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useStartServices } from '../../../../../hooks'; + +interface ViewLogsProps { + viewInLogs: boolean; + logStreamQuery: string; + startTime: string; + endTime: string; +} + +/* + Button that takes to the Logs view Ui when that is available, otherwise fallback to the Discover UI + The urls are built using same logStreamQuery (provided by a prop), startTime and endTime, ensuring that they'll both will target same log lines +*/ +export const ViewLogsButton: React.FunctionComponent = ({ + viewInLogs, + logStreamQuery, + startTime, + endTime, +}) => { + const { http } = useStartServices(); + + // 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: startTime, + end: endTime, + streamLive: false, + }), + logFilter: encode({ + expression: logStreamQuery, + kind: 'kuery', + }), + }), + }) + ), + [http.basePath, startTime, endTime, logStreamQuery] + ); + + const viewInDiscoverUrl = useMemo(() => { + const index = 'logs-*'; + const query = encode({ + query: logStreamQuery, + language: 'kuery', + }); + return http.basePath.prepend( + `/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:'${startTime}',to:'${endTime}'))&_a=(columns:!(event.dataset,message),index:'${index}',query:${query})` + ); + }, [logStreamQuery, http.basePath, startTime, endTime]); + + return viewInLogs ? ( + + + + ) : ( + + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.test.tsx index f907edd52e2b4..b5018f812da4e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.test.tsx @@ -12,25 +12,51 @@ import { I18nProvider } from '@kbn/i18n-react'; import type { ActionStatus } from '../../../../../../../common/types'; +import { useStartServices } from '../../../../hooks'; + import { ViewErrors } from './view_errors'; +jest.mock('../../../../hooks', () => { + return { + ...jest.requireActual('../../../../hooks'), + useLink: jest.fn(), + useStartServices: jest.fn(), + }; +}); + +const mockUseStartServices = useStartServices as jest.Mock; + jest.mock('@kbn/shared-ux-link-redirect-app', () => ({ RedirectAppLinks: (props: any) => { return
{props.children}
; }, })); -jest.mock('../../../../hooks', () => { - return { - useStartServices: jest.fn().mockReturnValue({ - http: { - basePath: { - prepend: jest.fn().mockImplementation((str) => 'http://localhost' + str), +const mockStartServices = (isServerlessEnabled?: boolean) => { + mockUseStartServices.mockReturnValue({ + application: {}, + data: { + query: { + timefilter: { + timefilter: { + calculateBounds: jest.fn().mockReturnValue({ + min: '2023-10-04T13:08:53.340Z', + max: '2023-10-05T13:08:53.340Z', + }), + }, }, }, - }), - }; -}); + }, + http: { + basePath: { + prepend: (url: string) => 'http://localhost:5620' + url, + }, + }, + cloud: { + isServerlessEnabled, + }, + }); +}; describe('ViewErrors', () => { const renderComponent = (action: ActionStatus) => { @@ -41,7 +67,30 @@ describe('ViewErrors', () => { ); }; - it('should render error message with btn to logs', () => { + it('should render error message with btn to Logs view if serverless not enabled', () => { + mockStartServices(); + const result = renderComponent({ + actionId: 'action1', + latestErrors: [ + { + agentId: 'agent1', + error: 'Agent agent1 is not upgradeable', + timestamp: '2023-03-06T14:51:24.709Z', + }, + ], + } as any); + + const errorText = result.getByTestId('errorText'); + expect(errorText.textContent).toEqual('Agent agent1 is not upgradeable'); + + const viewErrorBtn = result.getByTestId('viewInLogsBtn'); + expect(viewErrorBtn.getAttribute('href')).toEqual( + `http://localhost:5620/app/logs/stream?logPosition=(end%3A'2023-03-06T14%3A56%3A24.709Z'%2Cstart%3A'2023-03-06T14%3A46%3A24.709Z'%2CstreamLive%3A!f)&logFilter=(expression%3A'elastic_agent.id%3Aagent1%20and%20(data_stream.dataset%3Aelastic_agent)%20and%20(log.level%3Aerror)'%2Ckind%3Akuery)` + ); + }); + + it('should render error message with btn to Discover view if serverless enabled', () => { + mockStartServices(true); const result = renderComponent({ actionId: 'action1', latestErrors: [ @@ -56,9 +105,9 @@ describe('ViewErrors', () => { const errorText = result.getByTestId('errorText'); expect(errorText.textContent).toEqual('Agent agent1 is not upgradeable'); - const viewErrorBtn = result.getByTestId('viewLogsBtn'); + const viewErrorBtn = result.getByTestId('viewInDiscoverBtn'); expect(viewErrorBtn.getAttribute('href')).toEqual( - `http://localhost/app/logs/stream?logPosition=(position%3A(time%3A1678114284709)%2CstreamLive%3A!f)&logFilter=(expression%3A'elastic_agent.id%3Aagent1%20and%20(data_stream.dataset%3Aelastic_agent)%20and%20(log.level%3Aerror)'%2Ckind%3Akuery)` + `http://localhost:5620/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:60000),time:(from:'2023-03-06T14:46:24.709Z',to:'2023-03-06T14:56:24.709Z'))&_a=(columns:!(event.dataset,message),index:'logs-*',query:(language:kuery,query:'elastic_agent.id:agent1 and (data_stream.dataset:elastic_agent) and (log.level:error)'))` ); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.tsx index d49be8d4cacda..4d43c9a60a618 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.tsx @@ -5,21 +5,19 @@ * 2.0. */ -import { stringify } from 'querystring'; - import styled from 'styled-components'; import React from 'react'; -import { encode } from '@kbn/rison'; import type { EuiBasicTableProps } from '@elastic/eui'; -import { EuiButton, EuiAccordion, EuiToolTip, EuiText, EuiBasicTable } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiAccordion, EuiToolTip, EuiText, EuiBasicTable } from '@elastic/eui'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import type { ActionErrorResult } from '../../../../../../../common/types'; import { buildQuery } from '../../agent_details_page/components/agent_logs/build_query'; +import { ViewLogsButton } from '../../agent_details_page/components/agent_logs/view_logs_button'; import type { ActionStatus } from '../../../../types'; import { useStartServices } from '../../../../hooks'; @@ -32,27 +30,26 @@ const TruncatedEuiText = styled(EuiText)` export const ViewErrors: React.FunctionComponent<{ action: ActionStatus }> = ({ action }) => { const coreStart = useStartServices(); + const isLogsUIAvailable = !coreStart.cloud?.isServerlessEnabled; + + const getLogsButton = (agentId: string, timestamp: string, viewInLogs: boolean) => { + const startTime = moment(timestamp).subtract(5, 'm').toISOString(); + const endTime = moment(timestamp).add(5, 'm').toISOString(); - const logStreamQuery = (agentId: string) => - buildQuery({ + const logStreamQuery = buildQuery({ agentId, datasets: ['elastic_agent'], logLevels: ['error'], userQuery: '', }); - - const getErrorLogsUrl = (agentId: string, timestamp: string) => { - const queryParams = stringify({ - logPosition: encode({ - position: { time: Date.parse(timestamp) }, - streamLive: false, - }), - logFilter: encode({ - expression: logStreamQuery(agentId), - kind: 'kuery', - }), - }); - return coreStart.http.basePath.prepend(`/app/logs/stream?${queryParams}`); + return ( + + ); }; const columns: EuiBasicTableProps['columns'] = [ @@ -89,16 +86,7 @@ export const ViewErrors: React.FunctionComponent<{ action: ActionStatus }> = ({ const errorItem = (action.latestErrors ?? []).find((item) => item.agentId === agentId); return ( - - - + {getLogsButton(agentId, errorItem!.timestamp, !!isLogsUIAvailable)} ); }, diff --git a/x-pack/plugins/fleet/public/custom_logs_assets_extension.tsx b/x-pack/plugins/fleet/public/custom_logs_assets_extension.tsx index 4090c4520bf2a..26668c4062981 100644 --- a/x-pack/plugins/fleet/public/custom_logs_assets_extension.tsx +++ b/x-pack/plugins/fleet/public/custom_logs_assets_extension.tsx @@ -14,8 +14,12 @@ import { useStartServices } from './hooks'; import type { PackageAssetsComponent } from './types'; export const CustomLogsAssetsExtension: PackageAssetsComponent = () => { - const { http } = useStartServices(); - const logStreamUrl = http.basePath.prepend('/app/logs/stream'); + const { http, cloud } = useStartServices(); + const isLogsUIAvailable = !cloud?.isServerlessEnabled; + // if logs ui is not available, link to discover + const logStreamUrl = isLogsUIAvailable + ? http.basePath.prepend('/app/logs/stream') + : http.basePath.prepend('/app/discover'); const views: CustomAssetsAccordionProps['views'] = [ { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c656e54ee8f5f..3f17d66656ae6 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -17101,7 +17101,6 @@ "xpack.fleet.agentActivityFlyout.inProgressTitle": "En cours", "xpack.fleet.agentActivityFlyout.noActivityDescription": "Le fil d'activités s'affichera ici au fur et à mesure que les agents seront réaffectés, mis à niveau ou désenregistrés.", "xpack.fleet.agentActivityFlyout.noActivityText": "Aucune activité à afficher", - "xpack.fleet.agentActivityFlyout.reviewErrorLogs": "Vérifier les logs d'erreur", "xpack.fleet.agentActivityFlyout.scheduledDescription": "Planifié pour ", "xpack.fleet.agentActivityFlyout.title": "Activité des agents", "xpack.fleet.agentActivityFlyout.todayTitle": "Aujourd'hui", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 157391ad9a6ff..1c6e69343b28c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17114,7 +17114,6 @@ "xpack.fleet.agentActivityFlyout.inProgressTitle": "進行中", "xpack.fleet.agentActivityFlyout.noActivityDescription": "エージェントが再割り当て、アップグレード、または登録解除されたときに、アクティビティフィードがここに表示されます。", "xpack.fleet.agentActivityFlyout.noActivityText": "表示するアクティビティがありません", - "xpack.fleet.agentActivityFlyout.reviewErrorLogs": "エラーログを確認", "xpack.fleet.agentActivityFlyout.scheduledDescription": "スケジュール済み ", "xpack.fleet.agentActivityFlyout.title": "エージェントアクティビティ", "xpack.fleet.agentActivityFlyout.todayTitle": "今日", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 192297f53e6ec..b0a78d859fb97 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17114,7 +17114,6 @@ "xpack.fleet.agentActivityFlyout.inProgressTitle": "进行中", "xpack.fleet.agentActivityFlyout.noActivityDescription": "重新分配、升级或取消注册代理时,活动源将在此处显示。", "xpack.fleet.agentActivityFlyout.noActivityText": "没有可显示的活动", - "xpack.fleet.agentActivityFlyout.reviewErrorLogs": "查看错误日志", "xpack.fleet.agentActivityFlyout.scheduledDescription": "计划进行 ", "xpack.fleet.agentActivityFlyout.title": "代理活动", "xpack.fleet.agentActivityFlyout.todayTitle": "今日",