diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 4ff0b522325ca6..6bc611d0238878 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -1705,6 +1705,14 @@ }, { "$ref": "#/components/parameters/page_index" + }, + { + "schema": { + "type": "integer", + "default": 5 + }, + "in": "query", + "name": "errorSize" } ], "responses": { @@ -1730,7 +1738,8 @@ "EXPIRED", "CANCELLED", "FAILED", - "IN_PROGRESS" + "IN_PROGRESS", + "ROLLOUT_PASSED" ] }, "nbAgentsActioned": { @@ -1768,6 +1777,24 @@ }, "creationTime": { "type": "string" + }, + "latestErrors": { + "type": "array", + "description": "latest errors that happened when the agents executed the action", + "items": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + }, + "error": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + } } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9d6ae17a04a412..5c14990778c32f 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -1063,6 +1063,11 @@ paths: parameters: - $ref: '#/components/parameters/page_size' - $ref: '#/components/parameters/page_index' + - schema: + type: integer + default: 5 + in: query + name: errorSize responses: '200': description: OK @@ -1086,6 +1091,7 @@ paths: - CANCELLED - FAILED - IN_PROGRESS + - ROLLOUT_PASSED nbAgentsActioned: type: number nbAgentsActionCreated: @@ -1110,6 +1116,20 @@ paths: type: string creationTime: type: string + latestErrors: + type: array + description: >- + latest errors that happened when the agents executed + the action + items: + type: object + properties: + agentId: + type: string + error: + type: string + timestamp: + type: string required: - actionId - complete diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@action_status.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@action_status.yaml index 1c2d013457d6f3..6daea7e6ebb2bb 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@action_status.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@action_status.yaml @@ -5,6 +5,11 @@ get: parameters: - $ref: ../components/parameters/page_size.yaml - $ref: ../components/parameters/page_index.yaml + - schema: + type: integer + default: 5 + in: query + name: errorSize responses: '200': description: OK @@ -28,6 +33,7 @@ get: - CANCELLED - FAILED - IN_PROGRESS + - ROLLOUT_PASSED nbAgentsActioned: type: number nbAgentsActionCreated: @@ -52,6 +58,18 @@ get: type: string creationTime: type: string + latestErrors: + type: array + description: latest errors that happened when the agents executed the action + items: + type: object + properties: + agentId: + type: string + error: + type: string + timestamp: + type: string required: - actionId - complete diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 537d0f250e8282..0d5e15088219e4 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -135,6 +135,13 @@ export interface CurrentUpgrade { startTime?: string; } +export interface ActionErrorResult { + agentId: string; + error: string; + timestamp: string; + hostname?: string; +} + export interface ActionStatus { actionId: string; // how many agents are successfully included in action documents @@ -155,6 +162,7 @@ export interface ActionStatus { newPolicyId?: string; creationTime: string; hasRolloutPeriod?: boolean; + latestErrors?: ActionErrorResult[]; } export interface AgentDiagnostics { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx index b57e1aea8b39ea..de6da14fd5014d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout.tsx @@ -35,6 +35,7 @@ import { SO_SEARCH_LIMIT } from '../../../../constants'; import { Loading } from '../../components'; import { getTodayActions, getOtherDaysActions } from './agent_activity_helper'; +import { ViewErrors } from './view_errors'; const FullHeightFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflowContent { @@ -502,6 +503,11 @@ const ActivityItem: React.FunctionComponent<{ action: ActionStatus }> = ({ actio {displayByStatus[action.status].description} + + {action.status === 'FAILED' && action.latestErrors && action.latestErrors.length > 0 ? ( + + ) : null} + ); 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 new file mode 100644 index 00000000000000..f907edd52e2b43 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; + +import { I18nProvider } from '@kbn/i18n-react'; + +import type { ActionStatus } from '../../../../../../../common/types'; + +import { ViewErrors } from './view_errors'; + +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), + }, + }, + }), + }; +}); + +describe('ViewErrors', () => { + const renderComponent = (action: ActionStatus) => { + return render( + + + + ); + }; + + it('should render error message with btn to logs', () => { + 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('viewLogsBtn'); + 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)` + ); + }); +}); 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 new file mode 100644 index 00000000000000..d49be8d4cacda9 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/view_errors.tsx @@ -0,0 +1,115 @@ +/* + * 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 { 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 { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; + +import { i18n } from '@kbn/i18n'; + +import type { ActionErrorResult } from '../../../../../../../common/types'; + +import { buildQuery } from '../../agent_details_page/components/agent_logs/build_query'; + +import type { ActionStatus } from '../../../../types'; +import { useStartServices } from '../../../../hooks'; + +const TruncatedEuiText = styled(EuiText)` + overflow: hidden; + max-height: 3rem; + text-overflow: ellipsis; +`; + +export const ViewErrors: React.FunctionComponent<{ action: ActionStatus }> = ({ action }) => { + const coreStart = useStartServices(); + + const logStreamQuery = (agentId: string) => + 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}`); + }; + + const columns: EuiBasicTableProps['columns'] = [ + { + field: 'hostname', + name: i18n.translate('xpack.fleet.agentList.viewErrors.hostnameColumnTitle', { + defaultMessage: 'Host Name', + }), + render: (hostname: string) => ( + + {hostname} + + ), + }, + { + field: 'error', + name: i18n.translate('xpack.fleet.agentList.viewErrors.errorColumnTitle', { + defaultMessage: 'Error Message', + }), + render: (error: string) => ( + + + {error} + + + ), + }, + { + field: 'agentId', + name: i18n.translate('xpack.fleet.agentList.viewErrors.actionColumnTitle', { + defaultMessage: 'Action', + }), + render: (agentId: string) => { + const errorItem = (action.latestErrors ?? []).find((item) => item.agentId === agentId); + return ( + + + + + + ); + }, + }, + ]; + + return ( + <> + + + + + ); +}; diff --git a/x-pack/plugins/fleet/server/services/agents/action_status.ts b/x-pack/plugins/fleet/server/services/agents/action_status.ts index a1a86d5963f60e..0c31baab77d4ed 100644 --- a/x-pack/plugins/fleet/server/services/agents/action_status.ts +++ b/x-pack/plugins/fleet/server/services/agents/action_status.ts @@ -9,8 +9,13 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import { SO_SEARCH_LIMIT } from '../../constants'; -import type { FleetServerAgentAction, ActionStatus, ListWithKuery } from '../../types'; -import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../common'; +import type { + FleetServerAgentAction, + ActionStatus, + ActionErrorResult, + ListWithKuery, +} from '../../types'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX, AGENTS_INDEX } from '../../../common'; import { appContextService } from '..'; const PRECISION_THRESHOLD = 40000; @@ -20,7 +25,7 @@ const PRECISION_THRESHOLD = 40000; */ export async function getActionStatuses( esClient: ElasticsearchClient, - options: ListWithKuery + options: ListWithKuery & { errorSize: number } ): Promise { const actions = await _getActions(esClient, options); const cancelledActions = await getCancelledActions(esClient); @@ -84,9 +89,10 @@ export async function getActionStatuses( const cancelledAction = cancelledActions.find((a) => a.actionId === action.actionId); let errorCount = 0; + let latestErrors: ActionErrorResult[] = []; try { // query to find errors in action results, cannot do aggregation on text type - const res = await esClient.search({ + const errorResults = await esClient.search({ index: AGENT_ACTIONS_RESULTS_INDEX, track_total_hits: true, rest_total_hits_as_int: true, @@ -104,8 +110,41 @@ export async function getActionStatuses( }, }, size: 0, + aggs: { + top_error_hits: { + top_hits: { + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + _source: { + includes: ['@timestamp', 'agent_id', 'error'], + }, + size: options.errorSize, + }, + }, + }, }); - errorCount = (res.hits.total as number) ?? 0; + errorCount = (errorResults.hits.total as number) ?? 0; + latestErrors = ((errorResults.aggregations?.top_error_hits as any)?.hits.hits ?? []).map( + (hit: any) => ({ + agentId: hit._source.agent_id, + error: hit._source.error, + timestamp: hit._source['@timestamp'], + }) + ); + if (latestErrors.length > 0) { + const hostNames = await getHostNames( + esClient, + latestErrors.map((errorItem: ActionErrorResult) => errorItem.agentId) + ); + latestErrors.forEach((errorItem: ActionErrorResult) => { + errorItem.hostname = hostNames[errorItem.agentId] ?? errorItem.agentId; + }); + } } catch (err) { if (err.statusCode === 404) { // .fleet-actions-results does not yet exist @@ -129,12 +168,36 @@ export async function getActionStatuses( nbAgentsActioned, cancellationTime: cancelledAction?.timestamp, completionTime, + latestErrors, }); } return results; } +async function getHostNames(esClient: ElasticsearchClient, agentIds: string[]) { + const agentsRes = await esClient.search({ + index: AGENTS_INDEX, + query: { + bool: { + filter: { + terms: { + 'agent.id': agentIds, + }, + }, + }, + }, + size: agentIds.length, + _source: ['local_metadata.host.name'], + }); + const hostNames = agentsRes.hits.hits.reduce((acc: { [key: string]: string }, curr) => { + acc[curr._id] = (curr._source as any).local_metadata.host.name; + return acc; + }, {}); + + return hostNames; +} + export async function getCancelledActions( esClient: ElasticsearchClient ): Promise> { diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index ef8b5c639c8ace..82447d7c2ed175 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -13,6 +13,7 @@ export type { AgentType, AgentAction, ActionStatus, + ActionErrorResult, CurrentUpgrade, PackagePolicy, PackagePolicyInput, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 598ea0fa67fe11..f549774884ab6f 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -216,5 +216,6 @@ export const GetActionStatusRequestSchema = { page: schema.number({ defaultValue: 0 }), perPage: schema.number({ defaultValue: 20 }), kuery: schema.maybe(schema.string()), + errorSize: schema.number({ defaultValue: 5 }), }), }; diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 3cd28d6cb1f937..84f88ffea366f8 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -94,5 +94,6 @@ "@kbn/utils", "@kbn/core-http-request-handler-context-server", "@kbn/shared-ux-router", + "@kbn/shared-ux-link-redirect-app", ] } diff --git a/x-pack/test/fleet_api_integration/apis/agents/action_status.ts b/x-pack/test/fleet_api_integration/apis/agents/action_status.ts index 84fb3244aa0443..dac5871e702db7 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/action_status.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/action_status.ts @@ -196,6 +196,7 @@ export default function (providerContext: FtrProviderContext) { creationTime: '2022-09-15T10:00:00.000Z', nbAgentsFailed: 0, hasRolloutPeriod: false, + latestErrors: [], }, { actionId: 'action3', @@ -209,6 +210,7 @@ export default function (providerContext: FtrProviderContext) { nbAgentsFailed: 0, completionTime: '2022-09-15T12:00:00.000Z', hasRolloutPeriod: false, + latestErrors: [], }, { actionId: 'action4', @@ -221,6 +223,7 @@ export default function (providerContext: FtrProviderContext) { creationTime: '2022-09-15T10:00:00.000Z', nbAgentsFailed: 0, hasRolloutPeriod: false, + latestErrors: [], }, { actionId: 'action5', @@ -235,6 +238,7 @@ export default function (providerContext: FtrProviderContext) { nbAgentsFailed: 0, cancellationTime: '2022-09-15T11:00:00.000Z', hasRolloutPeriod: false, + latestErrors: [], }, { actionId: 'action7', @@ -249,6 +253,14 @@ export default function (providerContext: FtrProviderContext) { nbAgentsFailed: 1, completionTime: '2022-09-15T11:00:00.000Z', hasRolloutPeriod: false, + latestErrors: [ + { + agentId: 'agent1', + error: 'agent already assigned', + timestamp: '2022-09-15T11:00:00.000Z', + hostname: 'agent1', + }, + ], }, ]); });