diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index 93dcc2eaec9761..cce4ae9cfc5252 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -95,7 +95,10 @@ steps: - command: KIBANA_DOCKER_CONTEXT=chainguard .buildkite/scripts/steps/artifacts/docker_context.sh label: 'Docker Context Verification' agents: - queue: n2-2 + image: family/kibana-ubuntu-2004 + imageProject: elastic-images-qa + provider: gcp + machineType: n2-standard-2 timeout_in_minutes: 30 retry: automatic: diff --git a/.buildkite/scripts/steps/artifacts/docker_image.sh b/.buildkite/scripts/steps/artifacts/docker_image.sh index 09622bbe0f02d9..308b391118b77c 100755 --- a/.buildkite/scripts/steps/artifacts/docker_image.sh +++ b/.buildkite/scripts/steps/artifacts/docker_image.sh @@ -35,8 +35,7 @@ node scripts/build \ --skip-docker-chainguard \ --skip-docker-ubi \ --skip-docker-fips \ - --skip-docker-cloud \ - --skip-docker-contexts + --skip-docker-cloud echo "--- Tag images" docker rmi "$KIBANA_IMAGE" @@ -102,8 +101,9 @@ ts-node "$(git rev-parse --show-toplevel)/.buildkite/scripts/steps/artifacts/val echo "--- Upload archives" buildkite-agent artifact upload "kibana-$BASE_VERSION-linux-x86_64.tar.gz" buildkite-agent artifact upload "kibana-$BASE_VERSION-linux-aarch64.tar.gz" -buildkite-agent artifact upload "kibana-$BASE_VERSION-docker-image.tar.gz" -buildkite-agent artifact upload "kibana-$BASE_VERSION-docker-image-aarch64.tar.gz" +buildkite-agent artifact upload "kibana-serverless-$BASE_VERSION-docker-image.tar.gz" +buildkite-agent artifact upload "kibana-serverless-$BASE_VERSION-docker-image-aarch64.tar.gz" +buildkite-agent artifact upload "kibana-serverless-$BASE_VERSION-docker-build-context.tar.gz" buildkite-agent artifact upload "kibana-$BASE_VERSION-cdn-assets.tar.gz" buildkite-agent artifact upload "dependencies-$GIT_ABBREV_COMMIT.csv" diff --git a/packages/kbn-unified-data-table/src/components/data_table_column_header.tsx b/packages/kbn-unified-data-table/src/components/data_table_column_header.tsx index 7c84b5806bccfd..8ed5aa53daa648 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_column_header.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_column_header.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { css, CSSObject } from '@emotion/react'; -import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { EuiIconTip } from '@elastic/eui'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { FieldIcon, getFieldIconProps, getTextBasedColumnIconType } from '@kbn/field-utils'; import { isNestedFieldParent } from '@kbn/discover-utils'; @@ -129,11 +129,9 @@ export const DataTableTimeColumnHeader = ({ text-align: left; `} > - - - {timeFieldName} - - + + {timeFieldName} + ); }; diff --git a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor.tsx b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor.tsx index 3a80742615ad29..09da1876949890 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor.tsx +++ b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor.tsx @@ -74,6 +74,10 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => { return actionsProvider.current!.getDocumentationLink(docLinkVersion); }, [docLinkVersion]); + const autoIndentCallback = useCallback(async () => { + return actionsProvider.current!.autoIndent(); + }, []); + const sendRequestsCallback = useCallback(async () => { await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http); }, [dispatch, http, toasts, trackUiMetric]); @@ -125,7 +129,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => { {}} + autoIndent={autoIndentCallback} notifications={notifications} /> diff --git a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts index 38fa543b363230..de9c2289c8a291 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts @@ -34,10 +34,13 @@ import { SELECTED_REQUESTS_CLASSNAME, stringifyRequest, trackSentRequests, + getAutoIndentedRequests, } from './utils'; import type { AdjustedParsedRequest } from './types'; +const AUTO_INDENTATION_ACTION_LABEL = 'Apply indentations'; + export class MonacoEditorActionsProvider { private parsedRequestsProvider: ConsoleParsedRequestsProvider; private highlightedLines: monaco.editor.IEditorDecorationsCollection; @@ -343,4 +346,57 @@ export class MonacoEditorActionsProvider { ): monaco.languages.ProviderResult { return this.getSuggestions(model, position, context); } + + /* + This function returns the text in the provided range. + If no range is provided, it returns all text in the editor. + */ + private getTextInRange(selectionRange?: monaco.IRange): string { + const model = this.editor.getModel(); + if (!model) { + return ''; + } + if (selectionRange) { + const { startLineNumber, startColumn, endLineNumber, endColumn } = selectionRange; + return model.getValueInRange({ + startLineNumber, + startColumn, + endLineNumber, + endColumn, + }); + } + // If no range is provided, return all text in the editor + return model.getValue(); + } + + /** + * This function applies indentations to the request in the selected text. + */ + public async autoIndent() { + const parsedRequests = await this.getSelectedParsedRequests(); + const selectionStartLineNumber = parsedRequests[0].startLineNumber; + const selectionEndLineNumber = parsedRequests[parsedRequests.length - 1].endLineNumber; + const selectedRange = new monaco.Range( + selectionStartLineNumber, + 1, + selectionEndLineNumber, + this.editor.getModel()?.getLineMaxColumn(selectionEndLineNumber) ?? 1 + ); + + if (parsedRequests.length < 1) { + return; + } + + const selectedText = this.getTextInRange(selectedRange); + const allText = this.getTextInRange(); + + const autoIndentedText = getAutoIndentedRequests(parsedRequests, selectedText, allText); + + this.editor.executeEdits(AUTO_INDENTATION_ACTION_LABEL, [ + { + range: selectedRange, + text: autoIndentedText, + }, + ]); + } } diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts index 2b117bcfd558d3..a0de7b461e99a3 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts @@ -14,6 +14,7 @@ export { replaceRequestVariables, getCurlRequest, trackSentRequests, + getAutoIndentedRequests, } from './requests_utils'; export { getDocumentationLinkFromAutocomplete, diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts index 14235e57c6f016..f07a6db3a68819 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts @@ -7,6 +7,7 @@ */ import { + getAutoIndentedRequests, getCurlRequest, replaceRequestVariables, stringifyRequest, @@ -160,4 +161,195 @@ describe('requests_utils', () => { expect(mockMetricsTracker.count).toHaveBeenNthCalledWith(2, 'POST__test'); }); }); + + describe('getAutoIndentedRequests', () => { + const sampleEditorTextLines = [ + ' ', // line 1 + 'GET _search ', // line 2 + '{ ', // line 3 + ' "query": { ', // line 4 + ' "match_all": { } ', // line 5 + ' } ', // line 6 + ' } ', // line 7 + ' ', // line 8 + '// single comment before Request 2 ', // line 9 + ' GET _all ', // line 10 + ' ', // line 11 + '/* ', // line 12 + ' multi-line comment before Request 3', // line 13 + '*/ ', // line 14 + 'POST /_bulk ', // line 15 + '{ ', // line 16 + ' "index":{ ', // line 17 + ' "_index":"books" ', // line 18 + ' } ', // line 19 + ' } ', // line 20 + '{ ', // line 21 + '"name":"1984" ', // line 22 + '}{"name":"Atomic habits"} ', // line 23 + ' ', // line 24 + 'GET _search // test comment ', // line 25 + '{ ', // line 26 + ' "query": { ', // line 27 + ' "match_all": { } // comment', // line 28 + ' } ', // line 29 + '} ', // line 30 + ' // some comment ', // line 31 + ' ', // line 32 + ]; + + const TEST_REQUEST_1 = { + method: 'GET', + url: '_search', + data: [{ query: { match_all: {} } }], + // Offsets are with respect to the sample editor text + startLineNumber: 2, + endLineNumber: 7, + startOffset: 1, + endOffset: 36, + }; + + const TEST_REQUEST_2 = { + method: 'GET', + url: '_all', + data: [], + // Offsets are with respect to the sample editor text + startLineNumber: 10, + endLineNumber: 10, + startOffset: 1, + endOffset: 36, + }; + + const TEST_REQUEST_3 = { + method: 'POST', + url: '/_bulk', + // Multi-data + data: [{ index: { _index: 'books' } }, { name: '1984' }, { name: 'Atomic habits' }], + // Offsets are with respect to the sample editor text + startLineNumber: 15, + endLineNumber: 23, + startOffset: 1, + endOffset: 36, + }; + + const TEST_REQUEST_4 = { + method: 'GET', + url: '_search', + data: [{ query: { match_all: {} } }], + // Offsets are with respect to the sample editor text + startLineNumber: 24, + endLineNumber: 30, + startOffset: 1, + endOffset: 36, + }; + + it('correctly auto-indents a single request with data', () => { + const formattedData = getAutoIndentedRequests( + [TEST_REQUEST_1], + sampleEditorTextLines + .slice(TEST_REQUEST_1.startLineNumber - 1, TEST_REQUEST_1.endLineNumber) + .join('\n'), + sampleEditorTextLines.join('\n') + ); + const expectedResultLines = [ + 'GET _search', + '{', + ' "query": {', + ' "match_all": {}', + ' }', + '}', + ]; + + expect(formattedData).toBe(expectedResultLines.join('\n')); + }); + + it('correctly auto-indents a single request with no data', () => { + const formattedData = getAutoIndentedRequests( + [TEST_REQUEST_2], + sampleEditorTextLines + .slice(TEST_REQUEST_2.startLineNumber - 1, TEST_REQUEST_2.endLineNumber) + .join('\n'), + sampleEditorTextLines.join('\n') + ); + const expectedResult = 'GET _all'; + + expect(formattedData).toBe(expectedResult); + }); + + it('correctly auto-indents a single request with multiple data', () => { + const formattedData = getAutoIndentedRequests( + [TEST_REQUEST_3], + sampleEditorTextLines + .slice(TEST_REQUEST_3.startLineNumber - 1, TEST_REQUEST_3.endLineNumber) + .join('\n'), + sampleEditorTextLines.join('\n') + ); + const expectedResultLines = [ + 'POST /_bulk', + '{', + ' "index": {', + ' "_index": "books"', + ' }', + '}', + '{', + ' "name": "1984"', + '}', + '{', + ' "name": "Atomic habits"', + '}', + ]; + + expect(formattedData).toBe(expectedResultLines.join('\n')); + }); + + it('auto-indents multiple request with comments in between', () => { + const formattedData = getAutoIndentedRequests( + [TEST_REQUEST_1, TEST_REQUEST_2, TEST_REQUEST_3], + sampleEditorTextLines.slice(1, 23).join('\n'), + sampleEditorTextLines.join('\n') + ); + const expectedResultLines = [ + 'GET _search', + '{', + ' "query": {', + ' "match_all": {}', + ' }', + '}', + '', + '// single comment before Request 2', + 'GET _all', + '', + '/*', + 'multi-line comment before Request 3', + '*/', + 'POST /_bulk', + '{', + ' "index": {', + ' "_index": "books"', + ' }', + '}', + '{', + ' "name": "1984"', + '}', + '{', + ' "name": "Atomic habits"', + '}', + ]; + + expect(formattedData).toBe(expectedResultLines.join('\n')); + }); + + it('does not auto-indent a request with comments', () => { + const requestText = sampleEditorTextLines + .slice(TEST_REQUEST_4.startLineNumber - 1, TEST_REQUEST_4.endLineNumber) + .join('\n'); + const formattedData = getAutoIndentedRequests( + [TEST_REQUEST_4], + requestText, + sampleEditorTextLines.join('\n') + ); + + expect(formattedData).toBe(requestText); + }); + }); }); diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts index 7f9babb333e894..bf9c6074bb20a7 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts @@ -13,6 +13,7 @@ import type { DevToolsVariable } from '../../../../components'; import type { EditorRequest } from '../types'; import { variableTemplateRegex } from './constants'; import { removeTrailingWhitespaces } from './tokens_utils'; +import { AdjustedParsedRequest } from '../types'; /* * This function stringifies and normalizes the parsed request: @@ -130,3 +131,64 @@ const replaceVariables = (text: string, variables: DevToolsVariable[]): string = } return text; }; + +const containsComments = (text: string) => { + return text.indexOf('//') >= 0 || text.indexOf('/*') >= 0; +}; + +/** + * This function takes a string containing unformatted Console requests and + * returns a text in which the requests are auto-indented. + * @param requests The list of {@link AdjustedParsedRequest} that are in the selected text in the editor. + * @param selectedText The selected text in the editor. + * @param allText The whole text input in the editor. + */ +export const getAutoIndentedRequests = ( + requests: AdjustedParsedRequest[], + selectedText: string, + allText: string +): string => { + const selectedTextLines = selectedText.split(`\n`); + const allTextLines = allText.split(`\n`); + const formattedTextLines: string[] = []; + + let currentLineIndex = 0; + let currentRequestIndex = 0; + + while (currentLineIndex < selectedTextLines.length) { + const request = requests[currentRequestIndex]; + // Check if the current line is the start of the next request + if ( + request && + selectedTextLines[currentLineIndex] === allTextLines[request.startLineNumber - 1] + ) { + // Start of a request + const requestLines = allTextLines.slice(request.startLineNumber - 1, request.endLineNumber); + + if (requestLines.some((line) => containsComments(line))) { + // If request has comments, add it as it is - without formatting + // TODO: Format requests with comments + formattedTextLines.push(...requestLines); + } else { + // If no comments, add stringified parsed request + const stringifiedRequest = stringifyRequest(request); + const firstLine = stringifiedRequest.method + ' ' + stringifiedRequest.url; + formattedTextLines.push(firstLine); + + if (stringifiedRequest.data && stringifiedRequest.data.length > 0) { + formattedTextLines.push(...stringifiedRequest.data); + } + } + + currentLineIndex = currentLineIndex + requestLines.length; + currentRequestIndex++; + } else { + // Current line is a comment or whitespaces + // Trim white spaces and add it to the formatted text + formattedTextLines.push(selectedTextLines[currentLineIndex].trim()); + currentLineIndex++; + } + } + + return formattedTextLines.join('\n'); +}; diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 320e912bfa6a96..a6c91d91a89618 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -290,7 +290,7 @@ export class DataGridService extends FtrService { public async getControlColumnHeaderFields(): Promise { const result = await this.find.allByCssSelector( - '.euiDataGridHeaderCell--controlColumn > .euiDataGridHeaderCell__content' + '.euiDataGridHeaderCell--controlColumn .euiDataGridHeaderCell__content' ); const textArr = []; diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/find.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/find.ts index 6d6ebf7b553b02..31e8c5852a1e11 100644 --- a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/find.ts +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/find.ts @@ -5,6 +5,7 @@ * 2.0. */ import * as t from 'io-ts'; +import { toBooleanRt } from '@kbn/io-ts-utils'; import { sloWithDataResponseSchema } from '../slo'; const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]); @@ -23,6 +24,7 @@ const findSLOParamsSchema = t.partial({ perPage: t.string, sortBy: sortBySchema, sortDirection: sortDirectionSchema, + hideStale: toBooleanRt, }), }); diff --git a/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_overview.ts b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_overview.ts new file mode 100644 index 00000000000000..9983bdee41e2d6 --- /dev/null +++ b/x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_overview.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; + +const getOverviewParamsSchema = t.partial({ + query: t.partial({ + kqlQuery: t.string, + filters: t.string, + }), +}); + +const getOverviewResponseSchema = t.type({ + violated: t.number, + degrading: t.number, + stale: t.number, + healthy: t.number, + worst: t.type({ + value: t.number, + id: t.string, + }), + noData: t.number, + burnRateRules: t.number, + burnRateActiveAlerts: t.number, + burnRateRecoveredAlerts: t.number, +}); + +type GetOverviewParams = t.TypeOf; +type GetOverviewResponse = t.OutputOf; + +export { getOverviewParamsSchema, getOverviewResponseSchema }; +export type { GetOverviewParams, GetOverviewResponse }; diff --git a/x-pack/packages/kbn-slo-schema/src/schema/common.ts b/x-pack/packages/kbn-slo-schema/src/schema/common.ts index 5346b9507e0b91..a803bcf7357962 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/common.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/common.ts @@ -43,11 +43,16 @@ const statusSchema = t.union([ t.literal('VIOLATED'), ]); -const summarySchema = t.type({ - status: statusSchema, - sliValue: t.number, - errorBudget: errorBudgetSchema, -}); +const summarySchema = t.intersection([ + t.type({ + status: statusSchema, + sliValue: t.number, + errorBudget: errorBudgetSchema, + }), + t.partial({ + summaryUpdatedAt: t.union([t.string, t.null]), + }), +]); const groupingsSchema = t.record(t.string, t.union([t.string, t.number])); diff --git a/x-pack/packages/kbn-slo-schema/src/schema/settings.ts b/x-pack/packages/kbn-slo-schema/src/schema/settings.ts index c4f797e427c48e..dacf9aa08b4766 100644 --- a/x-pack/packages/kbn-slo-schema/src/schema/settings.ts +++ b/x-pack/packages/kbn-slo-schema/src/schema/settings.ts @@ -10,6 +10,9 @@ import * as t from 'io-ts'; export const sloSettingsSchema = t.type({ useAllRemoteClusters: t.boolean, selectedRemoteClusters: t.array(t.string), + staleThresholdInHours: t.number, }); -export const sloServerlessSettingsSchema = t.type({}); +export const sloServerlessSettingsSchema = t.type({ + staleThresholdInHours: t.number, +}); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts index 691fc8548c098f..49ece72c42f955 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.test.ts @@ -426,6 +426,8 @@ describe('setAlertsToUntracked()', () => { }, }); + clusterClient.updateByQuery.mockResponseOnce({ total: 2, updated: 2 }); + const result = await setAlertsToUntracked({ isUsingQuery: true, query: [ @@ -523,4 +525,99 @@ describe('setAlertsToUntracked()', () => { }, ]); }); + + test('should return an empty array if the search returns zero results', async () => { + getAuthorizedRuleTypesMock.mockResolvedValue([ + { + id: 'test-rule-type', + }, + ]); + getAlertIndicesAliasMock.mockResolvedValue(['test-alert-index']); + + clusterClient.search.mockResponseOnce({ + took: 1, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + hits: [], + }, + aggregations: { + ruleTypeIds: { + buckets: [ + { + key: 'some rule type', + consumers: { + buckets: [{ key: 'o11y' }], + }, + }, + ], + }, + }, + }); + + clusterClient.updateByQuery.mockResponseOnce({ total: 0, updated: 0 }); + + const result = await setAlertsToUntracked({ + isUsingQuery: true, + query: [ + { + bool: { + must: { + term: { + 'kibana.alert.rule.name': 'test', + }, + }, + }, + }, + ], + featureIds: ['o11y'], + spaceId: 'default', + getAuthorizedRuleTypes: getAuthorizedRuleTypesMock, + getAlertIndicesAlias: getAlertIndicesAliasMock, + ensureAuthorized: ensureAuthorizedMock, + logger, + esClient: clusterClient, + }); + + expect(getAlertIndicesAliasMock).lastCalledWith(['test-rule-type'], 'default'); + + expect(clusterClient.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: { + bool: { + must: [ + { + term: { + 'kibana.alert.status': { + value: 'active', // This has to be active + }, + }, + }, + ], + filter: [ + { + bool: { + must: { + term: { + 'kibana.alert.rule.name': 'test', + }, + }, + }, + }, + ], + }, + }, + }), + }) + ); + + expect(clusterClient.search).not.toHaveBeenCalledWith(); + expect(result).toEqual([]); + }); }); diff --git a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts index c28a760853ba62..ed0c2cb21e06b8 100644 --- a/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts +++ b/x-pack/plugins/alerting/server/alerts_service/lib/set_alerts_to_untracked.ts @@ -208,6 +208,7 @@ export async function setAlertsToUntracked( // Retry this updateByQuery up to 3 times to make sure the number of documents // updated equals the number of documents matched let total = 0; + for (let retryCount = 0; retryCount < 3; retryCount++) { const response = await esClient.updateByQuery({ index: indices, @@ -224,14 +225,18 @@ export async function setAlertsToUntracked( }); if (total === 0 && response.total === 0) { - throw new Error('No active alerts matched the query'); + logger.debug('No active alerts matched the query'); + break; } + if (response.total) { total = response.total; } + if (response.total === response.updated) { break; } + logger.warn( `Attempt ${retryCount + 1}: Failed to untrack ${ (response.total ?? 0) - (response.updated ?? 0) @@ -241,6 +246,10 @@ export async function setAlertsToUntracked( ); } + if (total === 0) { + return []; + } + // Fetch and return updated rule and alert instance UUIDs const searchResponse = await esClient.search({ index: indices, @@ -251,6 +260,7 @@ export async function setAlertsToUntracked( query: getUntrackQuery(params, ALERT_STATUS_UNTRACKED), }, }); + return searchResponse.hits.hits.map((hit) => hit._source) as UntrackedAlertsResult; } catch (err) { logger.error( diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts index 4e2077f15a899b..befd240286a061 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts @@ -70,7 +70,9 @@ const rulesClientParams: jest.Mocked = { describe('bulkUntrackAlerts()', () => { let rulesClient: RulesClient; + beforeEach(async () => { + jest.clearAllMocks(); rulesClient = new RulesClient(rulesClientParams); }); @@ -241,4 +243,61 @@ describe('bulkUntrackAlerts()', () => { } `); }); + + it('should not call bulkUpdateState with no taskIds', async () => { + alertsService.setAlertsToUntracked.mockResolvedValueOnce([]); + + await rulesClient.bulkUntrackAlerts({ + isUsingQuery: true, + indices: ['test-index'], + alertUuids: ['my-uuid'], + }); + + expect(alertsService.setAlertsToUntracked).toHaveBeenCalledTimes(1); + expect(taskManager.bulkUpdateState).not.toHaveBeenCalledWith(); + }); + + it('filters out undefined rule uuids', async () => { + alertsService.setAlertsToUntracked.mockResolvedValueOnce([{}, { foo: 'bar' }]); + + await rulesClient.bulkUntrackAlerts({ + isUsingQuery: true, + indices: ['test-index'], + alertUuids: ['my-uuid'], + }); + + expect(alertsService.setAlertsToUntracked).toHaveBeenCalledTimes(1); + expect(taskManager.bulkUpdateState).not.toHaveBeenCalledWith(); + }); + + it('should audit log success with no taskIds', async () => { + alertsService.setAlertsToUntracked.mockResolvedValueOnce([]); + + await rulesClient.bulkUntrackAlerts({ + isUsingQuery: true, + indices: ['test-index'], + alertUuids: ['my-uuid'], + }); + + expect(taskManager.bulkUpdateState).not.toHaveBeenCalledWith(); + expect(auditLogger.log.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "rule_alert_untrack", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": undefined, + }, + "message": "User has untracked a rule", + } + `); + }); }); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts index d3a1badd0ab330..304037782f6d39 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.ts @@ -9,6 +9,7 @@ import { omitBy } from 'lodash'; import Boom from '@hapi/boom'; import { withSpan } from '@kbn/apm-utils'; import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils'; +import { AuditLogger } from '@kbn/core-security-server'; import { bulkUntrackBodySchema } from './schemas'; import type { BulkUntrackBody } from './types'; import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization'; @@ -64,7 +65,13 @@ async function bulkUntrackAlertsWithOCC(context: RulesClientContext, params: Bul }); // Clear alert instances from their corresponding tasks so that they can remain untracked - const taskIds = [...new Set(result.map((doc) => doc[ALERT_RULE_UUID]))]; + const taskIds = [...new Set(result.map((doc) => doc[ALERT_RULE_UUID]).filter(Boolean))]; + + if (taskIds.length === 0) { + auditLogSuccess(context.auditLogger); + return; + } + await context.taskManager.bulkUpdateState(taskIds, (state, id) => { try { const uuidsToClear = result @@ -90,12 +97,7 @@ async function bulkUntrackAlertsWithOCC(context: RulesClientContext, params: Bul } }); - context.auditLogger?.log( - ruleAuditEvent({ - action: RuleAuditAction.UNTRACK_ALERT, - outcome: 'success', - }) - ); + auditLogSuccess(context.auditLogger); } catch (error) { context.auditLogger?.log( ruleAuditEvent({ @@ -106,3 +108,12 @@ async function bulkUntrackAlertsWithOCC(context: RulesClientContext, params: Bul throw error; } } + +const auditLogSuccess = (auditLogger?: AuditLogger) => { + auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.UNTRACK_ALERT, + outcome: 'success', + }) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/untrack_rule_alerts.ts b/x-pack/plugins/alerting/server/rules_client/lib/untrack_rule_alerts.ts index d69d84c7fd8c73..76dd34a7de68a3 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/untrack_rule_alerts.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/untrack_rule_alerts.ts @@ -24,11 +24,13 @@ export const untrackRuleAlerts = async ( ) => { return withSpan({ name: 'untrackRuleAlerts', type: 'rules' }, async () => { if (!context.eventLogger || !attributes.scheduledTaskId) return; + try { const taskInstance = taskInstanceToAlertTaskInstance( await context.taskManager.get(attributes.scheduledTaskId), attributes as unknown as SanitizedRule ); + const { state } = taskInstance; const untrackedAlerts = mapValues, Alert>( diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index f76c6d781ad830..1bc43c34e5aa8e 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -70,6 +70,7 @@ "@kbn/core-execution-context-server-mocks", "@kbn/react-kibana-context-render", "@kbn/search-types", + "@kbn/core-security-server", ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx index f597d9274e9b71..3fc125cefb0424 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/data_visualizer_stats_table.tsx @@ -15,6 +15,7 @@ import { EuiInMemoryTable, EuiText, EuiToolTip, + EuiIconTip, LEFT_ALIGNMENT, RIGHT_ALIGNMENT, EuiResizeObserver, @@ -230,19 +231,13 @@ export const DataVisualizerTable = ({ {i18n.translate('xpack.dataVisualizer.dataGrid.documentsCountColumnName', { defaultMessage: 'Documents (%)', })} - { - - - - } + ), diff --git a/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx b/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx index b3aa4c9e8b4d62..c7034e54de560f 100644 --- a/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx +++ b/x-pack/plugins/ml/public/embeddables/job_creation/common/job_details.tsx @@ -205,6 +205,12 @@ export const JobDetails: FC> = ({ if (validationResults.contains('bucket_span_invalid')) { setBucketSpanValidationError(invalidTimeIntervalMessage(bucketSpan)); + } else if (validationResults.contains('bucket_span_empty')) { + setBucketSpanValidationError( + i18n.translate('xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage', { + defaultMessage: 'Bucket span must be set', + }) + ); } setState(STATE.DEFAULT); }, diff --git a/x-pack/plugins/observability_solution/apm/public/application/index.tsx b/x-pack/plugins/observability_solution/apm/public/application/index.tsx index d33a4eec18f6c5..23dde7de81469a 100644 --- a/x-pack/plugins/observability_solution/apm/public/application/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/application/index.tsx @@ -9,7 +9,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import type { ObservabilityRuleTypeRegistry } from '@kbn/observability-plugin/public'; import { AppMountParameters, CoreStart, APP_WRAPPER_CLASS } from '@kbn/core/public'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { ConfigSchema } from '..'; import { ApmPluginSetupDeps, ApmPluginStartDeps, ApmServices } from '../plugin'; import { createCallApmApi } from '../services/rest/create_call_apm_api'; @@ -69,21 +70,23 @@ export const renderApp = ({ element.classList.add(APP_WRAPPER_CLASS); ReactDOM.render( - - - , + + + + + , element ); return () => { diff --git a/x-pack/plugins/observability_solution/apm/tsconfig.json b/x-pack/plugins/observability_solution/apm/tsconfig.json index 4a43f0821c700c..b4ada5182d952e 100644 --- a/x-pack/plugins/observability_solution/apm/tsconfig.json +++ b/x-pack/plugins/observability_solution/apm/tsconfig.json @@ -118,7 +118,9 @@ "@kbn/search-types", "@kbn/logs-data-access-plugin", "@kbn/ebt-tools", - "@kbn/presentation-publishing" + "@kbn/presentation-publishing", + "@kbn/react-kibana-context-render", + "@kbn/react-kibana-context-theme" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/infra/public/apps/common_providers.tsx b/x-pack/plugins/observability_solution/infra/public/apps/common_providers.tsx index 4cd8847695fd5d..90417274245961 100644 --- a/x-pack/plugins/observability_solution/infra/public/apps/common_providers.tsx +++ b/x-pack/plugins/observability_solution/infra/public/apps/common_providers.tsx @@ -8,7 +8,8 @@ import { AppMountParameters, CoreStart } from '@kbn/core/public'; import React, { FC, PropsWithChildren } from 'react'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { NavigationWarningPromptProvider } from '@kbn/observability-shared-plugin/public'; import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; @@ -60,7 +61,6 @@ export const CoreProviders: FC> = ({ core, pluginStart, plugins, - theme$, kibanaEnvironment, }) => { const KibanaContextProviderForPlugin = useKibanaContextForPluginProvider( @@ -72,19 +72,19 @@ export const CoreProviders: FC> = ({ const KibanaEnvContextForPluginProvider = useKibanaEnvironmentContextProvider(kibanaEnvironment); return ( - - - - - {children} - - - - + + + + + {children} + + + + ); }; diff --git a/x-pack/plugins/observability_solution/infra/public/apps/legacy_app.tsx b/x-pack/plugins/observability_solution/infra/public/apps/legacy_app.tsx index 9d91edcc8e034e..aa924ad663bc64 100644 --- a/x-pack/plugins/observability_solution/infra/public/apps/legacy_app.tsx +++ b/x-pack/plugins/observability_solution/infra/public/apps/legacy_app.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { EuiErrorBoundary } from '@elastic/eui'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { createBrowserHistory, History } from 'history'; -import { AppMountParameters } from '@kbn/core/public'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { RouteProps } from 'react-router-dom'; @@ -15,10 +15,15 @@ import { Router, Routes, Route } from '@kbn/shared-ux-router'; // This exists purely to facilitate legacy app/infra URL redirects. // It will be removed in 8.0.0. -export async function renderApp({ element }: AppMountParameters) { +export async function renderApp(core: CoreStart, { element }: AppMountParameters) { const history = createBrowserHistory(); - ReactDOM.render(, element); + ReactDOM.render( + + + , + element + ); return () => { ReactDOM.unmountComponentAtNode(element); @@ -27,72 +32,67 @@ export async function renderApp({ element }: AppMountParameters) { const LegacyApp: React.FunctionComponent<{ history: History }> = ({ history }) => { return ( - - - - { - if (!location) { - return null; - } + + + { + if (!location) { + return null; + } - let nextPath = ''; - let nextBasePath = ''; - let nextSearch; + let nextPath = ''; + let nextBasePath = ''; + let nextSearch; - if ( - location.hash.indexOf('#infrastructure') > -1 || - location.hash.indexOf('#/infrastructure') > -1 - ) { - nextPath = location.hash.replace( - new RegExp( - '#infrastructure/|#/infrastructure/|#/infrastructure|#infrastructure', - 'g' - ), - '' - ); - nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); - } else if ( - location.hash.indexOf('#logs') > -1 || - location.hash.indexOf('#/logs') > -1 - ) { - nextPath = location.hash.replace( - new RegExp('#logs/|#/logs/|#/logs|#logs', 'g'), - '' - ); - nextBasePath = location.pathname.replace('app/infra', 'app/logs'); - } else { - // This covers /app/infra and /app/infra/home (both of which used to render - // the metrics inventory page) - nextPath = 'inventory'; - nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); - nextSearch = undefined; - } + if ( + location.hash.indexOf('#infrastructure') > -1 || + location.hash.indexOf('#/infrastructure') > -1 + ) { + nextPath = location.hash.replace( + new RegExp( + '#infrastructure/|#/infrastructure/|#/infrastructure|#infrastructure', + 'g' + ), + '' + ); + nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); + } else if ( + location.hash.indexOf('#logs') > -1 || + location.hash.indexOf('#/logs') > -1 + ) { + nextPath = location.hash.replace(new RegExp('#logs/|#/logs/|#/logs|#logs', 'g'), ''); + nextBasePath = location.pathname.replace('app/infra', 'app/logs'); + } else { + // This covers /app/infra and /app/infra/home (both of which used to render + // the metrics inventory page) + nextPath = 'inventory'; + nextBasePath = location.pathname.replace('app/infra', 'app/metrics'); + nextSearch = undefined; + } - // app/infra#infrastructure/metrics/:type/:node was changed to app/metrics/detail/:type/:node, this - // accounts for that edge case - nextPath = nextPath.replace('metrics/', 'detail/'); + // app/infra#infrastructure/metrics/:type/:node was changed to app/metrics/detail/:type/:node, this + // accounts for that edge case + nextPath = nextPath.replace('metrics/', 'detail/'); - // Query parameters (location.search) will arrive as part of location.hash and not location.search - const nextPathParts = nextPath.split('?'); - nextPath = nextPathParts[0]; - nextSearch = nextPathParts[1] ? nextPathParts[1] : undefined; + // Query parameters (location.search) will arrive as part of location.hash and not location.search + const nextPathParts = nextPath.split('?'); + nextPath = nextPathParts[0]; + nextSearch = nextPathParts[1] ? nextPathParts[1] : undefined; - const builtPathname = `${nextBasePath}/${nextPath}`; - const builtSearch = nextSearch ? `?${nextSearch}` : ''; + const builtPathname = `${nextBasePath}/${nextPath}`; + const builtSearch = nextSearch ? `?${nextSearch}` : ''; - let nextUrl = `${builtPathname}${builtSearch}`; + let nextUrl = `${builtPathname}${builtSearch}`; - nextUrl = nextUrl.replace('//', '/'); + nextUrl = nextUrl.replace('//', '/'); - window.location.href = nextUrl; + window.location.href = nextUrl; - return null; - }} - /> - - - + return null; + }} + /> + + ); }; diff --git a/x-pack/plugins/observability_solution/infra/public/plugin.ts b/x-pack/plugins/observability_solution/infra/public/plugin.ts index a7965890398209..20a6d85092c866 100644 --- a/x-pack/plugins/observability_solution/infra/public/plugin.ts +++ b/x-pack/plugins/observability_solution/infra/public/plugin.ts @@ -343,9 +343,10 @@ export class Plugin implements InfraClientPluginClass { title: 'infra', visibleIn: [], mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); const { renderApp } = await import('./apps/legacy_app'); - return renderApp(params); + return renderApp(coreStart, params); }, }); diff --git a/x-pack/plugins/observability_solution/infra/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/infra/public/test_utils/use_global_storybook_theme.tsx index a24fd9b549a050..dd0f97038740ab 100644 --- a/x-pack/plugins/observability_solution/infra/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/observability_solution/infra/public/test_utils/use_global_storybook_theme.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState, FC, PropsWithChildren } from 'reac import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; type StoryContext = Parameters[1]; @@ -35,7 +35,7 @@ export const GlobalStorybookThemeProviders: FC< > = ({ children, storyContext }) => { const { theme, theme$ } = useGlobalStorybookTheme(storyContext); return ( - + {children} ); diff --git a/x-pack/plugins/observability_solution/infra/tsconfig.json b/x-pack/plugins/observability_solution/infra/tsconfig.json index dfd1a52466a9a1..d43ab3b5ccc463 100644 --- a/x-pack/plugins/observability_solution/infra/tsconfig.json +++ b/x-pack/plugins/observability_solution/infra/tsconfig.json @@ -99,7 +99,9 @@ "@kbn/aiops-log-rate-analysis", "@kbn/react-hooks", "@kbn/search-types", - "@kbn/router-utils" + "@kbn/router-utils", + "@kbn/react-kibana-context-render", + "@kbn/react-kibana-context-theme" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/common/hover_popover.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/common/hover_popover.tsx deleted file mode 100644 index 8ba78717c071af..00000000000000 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/common/hover_popover.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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, { useEffect, useRef, useState } from 'react'; -import { EuiPopover, EuiPopoverTitle } from '@elastic/eui'; - -export const HoverPopover = ({ - children, - button, - title, -}: { - children: React.ReactChild; - button: React.ReactElement; - title: string; -}) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const leaveTimer = useRef(null); - - const clearTimer = () => { - if (leaveTimer.current) { - clearTimeout(leaveTimer.current); - } - }; - - const onMouseEnter = () => { - clearTimer(); - setIsPopoverOpen(true); - }; - - const onMouseLeave = () => { - leaveTimer.current = setTimeout(() => setIsPopoverOpen(false), 100); - }; - - useEffect(() => { - return () => { - clearTimer(); - }; - }, []); - - return ( -
- - {title} - {children} - -
- ); -}; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx index 6ed03bf91b8baa..c33c514bf37892 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx @@ -17,8 +17,7 @@ import { actionsLabel, actionsLabelLowerCase, } from '../../common/translations'; -import { HoverPopover } from '../../common/hover_popover'; -import { TooltipButtonComponent } from './tooltip_button'; +import { TooltipButton } from './tooltip_button'; import * as constants from '../../../../common/constants'; import { FieldWithToken } from './field_with_token'; @@ -28,10 +27,7 @@ const spacingCSS = css` export const ActionsColumnTooltip = () => { return ( - } - title={actionsLabel} - > +

{actionsHeaderTooltipParagraph}

@@ -94,6 +90,6 @@ export const ActionsColumnTooltip = () => { ))}
-
+ ); }; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx index a845b999400a6d..df33f1f1beff32 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx @@ -14,8 +14,7 @@ import { contentHeaderTooltipParagraph2, contentLabel, } from '../../common/translations'; -import { HoverPopover } from '../../common/hover_popover'; -import { TooltipButtonComponent } from './tooltip_button'; +import { TooltipButton } from './tooltip_button'; import { FieldWithToken } from './field_with_token'; import * as constants from '../../../../common/constants'; @@ -26,14 +25,10 @@ export const ContentColumnTooltip = ({ column, headerRowHeight }: CustomGridColu `; return ( - - } - title={contentLabel} +
@@ -45,6 +40,6 @@ export const ContentColumnTooltip = ({ column, headerRowHeight }: CustomGridColu
-
+ ); }; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx index 57e51097e391af..64a64156cbaeac 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx @@ -11,8 +11,7 @@ import { EuiText } from '@elastic/eui'; import type { CustomGridColumnProps } from '@kbn/unified-data-table'; import { euiThemeVars } from '@kbn/ui-theme'; import { resourceHeaderTooltipParagraph, resourceLabel } from '../../common/translations'; -import { HoverPopover } from '../../common/hover_popover'; -import { TooltipButtonComponent } from './tooltip_button'; +import { TooltipButton } from './tooltip_button'; import * as constants from '../../../../common/constants'; import { FieldWithToken } from './field_with_token'; @@ -22,14 +21,10 @@ const spacingCSS = css` export const ResourceColumnTooltip = ({ column, headerRowHeight }: CustomGridColumnProps) => { return ( - - } - title={resourceLabel} +
@@ -45,6 +40,6 @@ export const ResourceColumnTooltip = ({ column, headerRowHeight }: CustomGridCol ))}
-
+ ); }; diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx index 3f0a6883937026..811e71d44dd000 100644 --- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx +++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx @@ -5,18 +5,74 @@ * 2.0. */ +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { EuiIcon } from '@elastic/eui'; -import React from 'react'; import ColumnHeaderTruncateContainer from '@kbn/unified-data-table/src/components/column_header_truncate_container'; -export const TooltipButtonComponent = ({ +import { EuiPopover, EuiPopoverTitle } from '@elastic/eui'; + +export const TooltipButton = ({ + children, + popoverTitle, displayText, headerRowHeight, + iconType = 'questionInCircle', }: { + children: React.ReactChild; + popoverTitle: string; displayText?: string; headerRowHeight?: number; -}) => ( - - {displayText} - -); + iconType?: string; +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const leaveTimer = useRef(null); + + const clearTimer = useMemo( + () => () => { + if (leaveTimer.current) { + clearTimeout(leaveTimer.current); + } + }, + [] + ); + + const onMouseEnter = useCallback(() => { + clearTimer(); + setIsPopoverOpen(true); + }, [clearTimer]); + + const onMouseLeave = useCallback(() => { + leaveTimer.current = setTimeout(() => setIsPopoverOpen(false), 100); + }, []); + + useEffect(() => { + return () => { + clearTimer(); + }; + }, [clearTimer]); + + return ( + + {displayText}{' '} + + } + isOpen={isPopoverOpen} + anchorPosition="upCenter" + panelPaddingSize="s" + ownFocus={false} + > + {popoverTitle} + {children} + + + ); +}; diff --git a/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx index a24fd9b549a050..dd0f97038740ab 100644 --- a/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState, FC, PropsWithChildren } from 'reac import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; type StoryContext = Parameters[1]; @@ -35,7 +35,7 @@ export const GlobalStorybookThemeProviders: FC< > = ({ children, storyContext }) => { const { theme, theme$ } = useGlobalStorybookTheme(storyContext); return ( - + {children} ); diff --git a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json index cfe5687f0404fe..927c2f0374018e 100644 --- a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json +++ b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json @@ -37,6 +37,7 @@ "@kbn/share-plugin", "@kbn/shared-ux-utility", "@kbn/search-types", - "@kbn/discover-shared-plugin" + "@kbn/discover-shared-plugin", + "@kbn/react-kibana-context-theme" ] } diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx index cd2ac05f14778f..1734b49c694f50 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx +++ b/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx @@ -5,25 +5,24 @@ * 2.0. */ -import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { CoreStart } from '@kbn/core/public'; import React from 'react'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { useKibanaContextForPluginProvider } from '../hooks/use_kibana'; export interface CoreProvidersProps { children?: React.ReactNode; core: CoreStart; - theme$: AppMountParameters['theme$']; } -export const CoreProviders: React.FC = ({ children, core, theme$ }) => { +export const CoreProviders: React.FC = ({ children, core }) => { const KibanaContextProviderForPlugin = useKibanaContextForPluginProvider(core); return ( - - - {children} - - + + + {children} + + ); }; diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx index d187c88bcbba50..4faa4320aa6a0a 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx +++ b/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx @@ -21,7 +21,6 @@ export function createLazyContainerMetricsTable(core: CoreStart, metricsClient: [coreProvidersPropsMock.core]; diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx index b813cfa563a0f9..7ed147138cce3a 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; type StoryContext = Parameters[1]; @@ -34,7 +34,7 @@ export const GlobalStorybookThemeProviders: React.FC<{ }> = ({ children, storyContext }) => { const { theme, theme$ } = useGlobalStorybookTheme(storyContext); return ( - + {children} ); diff --git a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json index 7c6c2e1bd3d958..b2ba77bff9f379 100644 --- a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json +++ b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json @@ -34,6 +34,8 @@ "@kbn/i18n-react", "@kbn/logging", "@kbn/core-http-request-handler-context-server", - "@kbn/lens-embeddable-utils" + "@kbn/lens-embeddable-utils", + "@kbn/react-kibana-context-render", + "@kbn/react-kibana-context-theme" ] } diff --git a/x-pack/plugins/observability_solution/observability/public/application/index.tsx b/x-pack/plugins/observability_solution/observability/public/application/index.tsx index 1166755ca14572..9c56ec1bb54ecd 100644 --- a/x-pack/plugins/observability_solution/observability/public/application/index.tsx +++ b/x-pack/plugins/observability_solution/observability/public/application/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import ReactDOM from 'react-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -13,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { Router, Routes, Route } from '@kbn/shared-ux-router'; import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; @@ -66,7 +66,6 @@ export const renderApp = ({ isServerless?: boolean; }) => { const { element, history, theme$ } = appMountParameters; - const i18nCore = core.i18n; const isDarkMode = core.theme.getTheme().darkMode; core.chrome.setHelpExtension({ @@ -87,8 +86,8 @@ export const renderApp = ({ const PresentationContextProvider = plugins.presentationUtil?.ContextProvider ?? React.Fragment; ReactDOM.render( - - + + @@ -113,17 +112,15 @@ export const renderApp = ({ > - - - - - - - - + + + + + + @@ -131,8 +128,8 @@ export const renderApp = ({ - - , + + , element ); return () => { diff --git a/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx b/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx index a7ca5fa38ffc3c..52ca08e40c031d 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { usePluginContext } from '../../../../hooks/use_plugin_context'; import { useKibana } from '../../../../utils/kibana_react'; +// FIXME: import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public' import HeaderMenuPortal from './header_menu_portal'; export function HeaderMenu(): React.ReactElement | null { diff --git a/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx index 2930d9de22346e..ea94e2edb6f8be 100644 --- a/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx +++ b/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; import type { CoreTheme } from '@kbn/core/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; type StoryContext = Parameters[1]; @@ -37,7 +37,7 @@ export function GlobalStorybookThemeProviders({ }) { const { theme, theme$ } = useGlobalStorybookTheme(storyContext); return ( - + {children} ); diff --git a/x-pack/plugins/observability_solution/observability/tsconfig.json b/x-pack/plugins/observability_solution/observability/tsconfig.json index fb045f31492dee..b4f617079dc58d 100644 --- a/x-pack/plugins/observability_solution/observability/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability/tsconfig.json @@ -86,7 +86,6 @@ "@kbn/content-management-plugin", "@kbn/deeplinks-observability", "@kbn/core-application-common", - "@kbn/react-kibana-mount", "@kbn/react-kibana-context-theme", "@kbn/shared-ux-link-redirect-app", "@kbn/lens-embeddable-utils", @@ -99,6 +98,8 @@ "@kbn/data-view-field-editor-plugin", "@kbn/cases-components", "@kbn/aiops-log-rate-analysis", + "@kbn/react-kibana-context-render", + "@kbn/react-kibana-mount", ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx index 5c0bce9030aa7e..2b928b37f471b9 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx @@ -8,7 +8,9 @@ import { EuiErrorBoundary } from '@elastic/eui'; import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'; import { Router } from '@kbn/shared-ux-router'; @@ -45,7 +47,6 @@ export function ObservabilityOnboardingAppRoot({ appMountParameters: AppMountParameters; } & RenderAppProps) { const { history, setHeaderActionMenu, theme$ } = appMountParameters; - const i18nCore = core.i18n; const plugins = { ...deps }; const renderFeedbackLinkAsPortal = !config.serverless.enabled; @@ -55,31 +56,31 @@ export function ObservabilityOnboardingAppRoot({ }); return ( -
- - +
+ - - + {renderFeedbackLinkAsPortal && ( @@ -90,11 +91,11 @@ export function ObservabilityOnboardingAppRoot({ - - - - -
+ +
+
+
+ ); } diff --git a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json index b03e6d6ffe6d59..eb31601928b877 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json @@ -36,7 +36,9 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/cloud-experiments-plugin", "@kbn/home-sample-data-tab", - "@kbn/analytics-client" + "@kbn/analytics-client", + "@kbn/react-kibana-context-render", + "@kbn/react-kibana-context-theme" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx b/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx index 4f7c4bb817e3e7..fcd71e1e75d420 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx +++ b/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo } from 'react'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +// FIXME use import { toMountPoint } from '@kbn/react-kibana-mount'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import type { HeaderMenuPortalProps } from '../../types'; diff --git a/x-pack/plugins/observability_solution/slo/common/constants.ts b/x-pack/plugins/observability_solution/slo/common/constants.ts index 1cf872bd0a9184..db0c91ab0ef4bd 100644 --- a/x-pack/plugins/observability_solution/slo/common/constants.ts +++ b/x-pack/plugins/observability_solution/slo/common/constants.ts @@ -88,3 +88,6 @@ export const getSLOSummaryPipelineId = (sloId: string, sloRevision: number) => export const SYNTHETICS_INDEX_PATTERN = 'synthetics-*'; export const SYNTHETICS_DEFAULT_GROUPINGS = ['monitor.name', 'observer.geo.name', 'monitor.id']; + +// in hours +export const DEFAULT_STALE_SLO_THRESHOLD_HOURS = 48; diff --git a/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts b/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts index d55107dfcd8028..64f9ad0d445400 100644 --- a/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts +++ b/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts @@ -6,13 +6,17 @@ */ import { getListOfSloSummaryIndices } from './summary_indices'; -import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from './constants'; +import { + DEFAULT_STALE_SLO_THRESHOLD_HOURS, + SLO_SUMMARY_DESTINATION_INDEX_PATTERN, +} from './constants'; describe('getListOfSloSummaryIndices', () => { it('should return default index if disabled', function () { const settings = { useAllRemoteClusters: false, selectedRemoteClusters: [], + staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS, }; const result = getListOfSloSummaryIndices(settings, []); expect(result).toBe(SLO_SUMMARY_DESTINATION_INDEX_PATTERN); @@ -22,6 +26,7 @@ describe('getListOfSloSummaryIndices', () => { const settings = { useAllRemoteClusters: true, selectedRemoteClusters: [], + staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS, }; const clustersByName = [ { name: 'cluster1', isConnected: true }, @@ -37,6 +42,7 @@ describe('getListOfSloSummaryIndices', () => { const settings = { useAllRemoteClusters: false, selectedRemoteClusters: ['cluster1'], + staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS, }; const clustersByName = [ { name: 'cluster1', isConnected: true }, diff --git a/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml b/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml index 782e8fb477f94a..0b6cd584ed3f30 100644 --- a/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml +++ b/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml @@ -97,6 +97,11 @@ get: enum: [asc, desc] default: asc example: asc + - name: hideStale + in: query + description: Hide stale SLOs from the list as defined by stale SLO threshold in SLO settings + schema: + type: boolean responses: '200': description: Successful request diff --git a/x-pack/plugins/observability_solution/slo/public/application.tsx b/x-pack/plugins/observability_solution/slo/public/application.tsx index 9cf739f5e543a2..aedfba1b1eadae 100644 --- a/x-pack/plugins/observability_solution/slo/public/application.tsx +++ b/x-pack/plugins/observability_solution/slo/public/application.tsx @@ -7,13 +7,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { EuiErrorBoundary } from '@elastic/eui'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public'; import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Router, Routes, Route } from '@kbn/shared-ux-router'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; @@ -72,7 +72,6 @@ export const renderApp = ({ experimentalFeatures: ExperimentalFeatures; }) => { const { element, history, theme$ } = appMountParameters; - const i18nCore = core.i18n; const isDarkMode = core.theme.getTheme().darkMode; // ensure all divs are .kbnAppWrappers @@ -110,8 +109,8 @@ export const renderApp = ({ }); ReactDOM.render( - - + + @@ -137,16 +136,14 @@ export const renderApp = ({ > - - - - - - - + + + + + @@ -154,8 +151,8 @@ export const renderApp = ({ - - , + + , element ); diff --git a/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx b/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx index 1f8d6f25e09619..9bdfcb2a9d0e4a 100644 --- a/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx +++ b/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx @@ -14,7 +14,7 @@ import { ExperimentalFeatures } from '../../common/config'; export interface PluginContextValue { isDev?: boolean; isServerless?: boolean; - appMountParameters?: AppMountParameters; + appMountParameters: AppMountParameters; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; ObservabilityPageTemplate: React.ComponentType; experimentalFeatures?: ExperimentalFeatures; @@ -24,6 +24,6 @@ export interface OverviewEmbeddableContextValue { observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; } -export const PluginContext = createContext( - {} as PluginContextValue | OverviewEmbeddableContextValue -); +export const OverviewEmbeddableContext = createContext(null); + +export const PluginContext = createContext(null); diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx index 0ead7cd58229ee..54af69acbe13d3 100644 --- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx +++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx @@ -47,7 +47,7 @@ export async function resolveEmbeddableSloUserInput( /> , - { i18n: coreStart.i18n, theme: coreStart.theme } + coreStart ) ); } catch (error) { diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx index 10b65cf5c342da..fbda743a951b2c 100644 --- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx +++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx @@ -42,7 +42,7 @@ export async function openSloConfiguration( /> , - { i18n: coreStart.i18n, theme: coreStart.theme } + coreStart ) ); } catch (error) { diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx index a4f26679dd1dde..e0cf5d0b138371 100644 --- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx +++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx @@ -34,7 +34,7 @@ import { GroupSloCustomInput, } from './types'; import { EDIT_SLO_OVERVIEW_ACTION } from '../../../ui_actions/edit_slo_overview_panel'; -import { PluginContext } from '../../../context/plugin_context'; +import { OverviewEmbeddableContext } from '../../../context/plugin_context'; const queryClient = new QueryClient(); export const getOverviewPanelTitle = () => @@ -191,7 +191,7 @@ export const getOverviewEmbeddableFactory = (deps: SloEmbeddableDeps) => { - + {showAllGroupByInstances ? ( @@ -199,7 +199,7 @@ export const getOverviewEmbeddableFactory = (deps: SloEmbeddableDeps) => { renderOverview() )} - + diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx index fa0f9992d718eb..df2edd134423de 100644 --- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx +++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx @@ -44,7 +44,7 @@ export async function openSloConfiguration( /> , - { i18n: coreStart.i18n, theme: coreStart.theme } + coreStart ) ); } catch (error) { diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts index dd8f3c49a4b43d..a493a9cc27066b 100644 --- a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts +++ b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts @@ -27,12 +27,19 @@ interface SloGroupListFilter { groupsFilter?: string[]; } +interface SLOOverviewFilter { + kqlQuery: string; + filters: string; + lastRefresh?: number; +} + export const sloKeys = { all: ['slo'] as const, lists: () => [...sloKeys.all, 'list'] as const, list: (filters: SloListFilter) => [...sloKeys.lists(), filters] as const, group: (filters: SloGroupListFilter) => [...sloKeys.groups(), filters] as const, groups: () => [...sloKeys.all, 'group'] as const, + overview: (filters: SLOOverviewFilter) => ['overview', filters] as const, details: () => [...sloKeys.all, 'details'] as const, detail: (sloId?: string) => [...sloKeys.details(), sloId] as const, rules: () => [...sloKeys.all, 'rules'] as const, diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts b/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts new file mode 100644 index 00000000000000..fa97a222043726 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts @@ -0,0 +1,24 @@ +/* + * 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 { observabilityPaths } from '@kbn/observability-plugin/common'; +import rison from '@kbn/rison'; +import { useKibana } from '../utils/kibana_react'; + +export const useAlertsUrl = () => { + const { basePath } = useKibana().services.http; + + const kuery = 'kibana.alert.rule.rule_type_id:("slo.rules.burnRate")'; + + return (status?: 'active' | 'recovered') => + `${basePath.prepend(observabilityPaths.alerts)}?_a=${rison.encode({ + kuery, + rangeFrom: 'now-24h', + rangeTo: 'now', + status, + })}`; +}; diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts index 27bd77a869c86c..289c8e5cbd4187 100644 --- a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts +++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts @@ -103,6 +103,7 @@ export function useFetchSloList({ ...(page !== undefined && { page }), ...(perPage !== undefined && { perPage }), ...(filters && { filters }), + hideStale: true, }, signal, }); diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx b/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx index f9a302ff64aa70..d0640deb575b21 100644 --- a/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx +++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx @@ -9,6 +9,11 @@ import { useContext } from 'react'; import { PluginContext } from '../context/plugin_context'; import type { PluginContextValue } from '../context/plugin_context'; -export function usePluginContext() { - return useContext(PluginContext) as PluginContextValue; +export function usePluginContext(): PluginContextValue { + const context = useContext(PluginContext); + if (!context) { + throw new Error('Plugin context value is missing!'); + } + + return context; } diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx index d73144d089ffd7..265b063928fda7 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query'; import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { CoreStart } from '@kbn/core-lifecycle-browser'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; import { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { RecursivePartial } from '@kbn/utility-types'; @@ -23,6 +23,7 @@ import { SloAddFormFlyout } from './slo_form'; export const getCreateSLOFlyoutLazy = ({ core, plugins, + getAppMountParameters, observabilityRuleTypeRegistry, ObservabilityPageTemplate, isDev, @@ -32,6 +33,7 @@ export const getCreateSLOFlyoutLazy = ({ }: { core: CoreStart; plugins: SloPublicPluginsStart; + getAppMountParameters: () => Promise; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; ObservabilityPageTemplate: React.ComponentType; isDev?: boolean; @@ -39,7 +41,7 @@ export const getCreateSLOFlyoutLazy = ({ isServerless?: boolean; experimentalFeatures: ExperimentalFeatures; }) => { - return ({ + return async ({ onClose, initialValues, }: { @@ -47,6 +49,7 @@ export const getCreateSLOFlyoutLazy = ({ initialValues?: RecursivePartial; }) => { const queryClient = new QueryClient(); + const appMountParameters = await getAppMountParameters(); return ( diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx index 86a2597c2188ae..895ca2c0f8e2b1 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx @@ -17,22 +17,27 @@ import { EuiButtonEmpty, EuiButton, EuiSpacer, + EuiFieldNumber, } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import { useFetcher } from '@kbn/observability-shared-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { isEqual } from 'lodash'; +import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants'; import { useGetSettings } from './use_get_settings'; import { usePutSloSettings } from './use_put_slo_settings'; export function SettingsForm() { const [useAllRemoteClusters, setUseAllRemoteClusters] = useState(false); const [selectedRemoteClusters, setSelectedRemoteClusters] = useState([]); + const [staleThresholdInHours, setStaleThresholdInHours] = useState( + DEFAULT_STALE_SLO_THRESHOLD_HOURS + ); const { http } = useKibana().services; const { data: currentSettings } = useGetSettings(); - const { mutateAsync: updateSettings } = usePutSloSettings(); + const { mutateAsync: updateSettings, isLoading: isUpdating } = usePutSloSettings(); const { data, loading } = useFetcher(() => { return http?.get>('/api/remote_clusters'); @@ -42,6 +47,7 @@ export function SettingsForm() { if (currentSettings) { setUseAllRemoteClusters(currentSettings.useAllRemoteClusters); setSelectedRemoteClusters(currentSettings.selectedRemoteClusters); + setStaleThresholdInHours(currentSettings.staleThresholdInHours); } }, [currentSettings]); @@ -50,6 +56,7 @@ export function SettingsForm() { settings: { useAllRemoteClusters, selectedRemoteClusters, + staleThresholdInHours, }, }); }; @@ -119,18 +126,57 @@ export function SettingsForm() { /> + + + {i18n.translate('xpack.slo.settingsForm.h3.staleThresholdLabel', { + defaultMessage: 'Stale SLOs threshold', + })} + + } + description={ +

+ {i18n.translate('xpack.slo.settingsForm.select.staleThresholdLabel', { + defaultMessage: + 'SLOs not updated within the defined stale threshold will be hidden by default from the overview list.', + })} +

+ } + > + + { + setStaleThresholdInHours(Number(evt.target.value)); + }} + append={i18n.translate('xpack.slo.settingsForm.euiFormRow.select.hours', { + defaultMessage: 'Hours', + })} + /> + + { setUseAllRemoteClusters(currentSettings?.useAllRemoteClusters || false); setSelectedRemoteClusters(currentSettings?.selectedRemoteClusters || []); + setStaleThresholdInHours( + currentSettings?.staleThresholdInHours ?? DEFAULT_STALE_SLO_THRESHOLD_HOURS + ); }} isDisabled={isEqual(currentSettings, { useAllRemoteClusters, selectedRemoteClusters, + staleThresholdInHours, })} > {i18n.translate('xpack.slo.settingsForm.euiButtonEmpty.cancelLabel', { @@ -140,7 +186,7 @@ export function SettingsForm() { {i18n.translate('xpack.slo.settingsForm.applyButtonEmptyLabel', { diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts index aa30659d85d3b3..88d38bc7f936dd 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts +++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts @@ -7,6 +7,7 @@ import { GetSLOSettingsResponse } from '@kbn/slo-schema'; import { useQuery } from '@tanstack/react-query'; +import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants'; import { useKibana } from '../../utils/kibana_react'; export const useGetSettings = () => { @@ -30,4 +31,5 @@ export const useGetSettings = () => { const defaultSettings: GetSLOSettingsResponse = { useAllRemoteClusters: false, selectedRemoteClusters: [], + staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS, }; diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx index c6f6e1834ff83e..deb2a1b880ad71 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx @@ -23,6 +23,7 @@ import { } from '@kbn/presentation-util-plugin/public'; import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema'; import { Rule } from '@kbn/triggers-actions-ui-plugin/public'; +import moment from 'moment'; import React, { useState } from 'react'; import { SloDeleteModal } from '../../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal'; import { SloResetConfirmationModal } from '../../../../components/slo/reset_confirmation_modal/slo_reset_confirmation_modal'; @@ -124,7 +125,17 @@ export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, refet overflow: hidden; position: relative; `} - title={slo.summary.status} + title={ + slo.summary.summaryUpdatedAt + ? i18n.translate('xpack.slo.sloCardItem.euiPanel.lastUpdatedLabel', { + defaultMessage: '{status}, Last updated: {value}', + values: { + status: slo.summary.status, + value: moment(slo.summary.summaryUpdatedAt).fromNow(), + }, + }) + : slo.summary.status + } > { + onStateChange({ + kqlQuery: '', + filters: [], + tagsFilter: undefined, + statusFilter: undefined, + }); + }} + color="primary" + > + {i18n.translate('xpack.slo.sloListEmpty.clearFiltersButtonLabel', { + defaultMessage: 'Clear filters', + })} + ); } diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx new file mode 100644 index 00000000000000..d26eea29f996ca --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import { useUrlSearchState } from '../../hooks/use_url_search_state'; + +export function OverViewItem({ + title, + description, + titleColor, + isLoading, + query, + tooltip, + onClick, +}: { + title?: string | number; + description: string; + titleColor: string; + isLoading: boolean; + query?: string; + tooltip?: string; + onClick?: () => void; +}) { + const { onStateChange } = useUrlSearchState(); + + return ( + + + { + if (onClick) { + onClick(); + return; + } + onStateChange({ + kqlQuery: query, + }); + }} + css={{ + cursor: 'pointer', + }} + /> + + + ); +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx new file mode 100644 index 00000000000000..808c9096a37934 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx @@ -0,0 +1,96 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview'; +import { rulesLocatorID, RulesParams } from '@kbn/observability-plugin/public'; +import { useAlertsUrl } from '../../../../hooks/use_alerts_url'; +import { useKibana } from '../../../../utils/kibana_react'; +import { OverViewItem } from './overview_item'; + +export function SLOOverviewAlerts({ + data, + isLoading, +}: { + data?: GetOverviewResponse; + isLoading: boolean; +}) { + const { + application, + share: { + url: { locators }, + }, + } = useKibana().services; + + const locator = locators.get(rulesLocatorID); + + const getAlertsUrl = useAlertsUrl(); + + return ( + + + + +

+ {i18n.translate('xpack.slo.sLOsOverview.h3.burnRateLabel', { + defaultMessage: 'Burn rate', + })} +

+
+
+ + + {i18n.translate('xpack.slo.sLOsOverview.lastTextLabel', { + defaultMessage: 'Last 24h', + })} + + +
+ + + + { + application.navigateToUrl(getAlertsUrl('active')); + }} + /> + { + application.navigateToUrl(getAlertsUrl('recovered')); + }} + /> + { + locator?.navigate({ + type: ['slo.rules.burnRate'], + }); + }} + /> + +
+ ); +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx new file mode 100644 index 00000000000000..c3c234e0f98a5d --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx @@ -0,0 +1,127 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../../../common/constants'; +import { SLOOverviewAlerts } from './slo_overview_alerts'; +import { useGetSettings } from '../../../slo_settings/use_get_settings'; +import { useFetchSLOsOverview } from '../../hooks/use_fetch_slos_overview'; +import { useUrlSearchState } from '../../hooks/use_url_search_state'; +import { OverViewItem } from './overview_item'; + +export function SLOsOverview() { + const { state } = useUrlSearchState(); + const { kqlQuery, filters, tagsFilter, statusFilter } = state; + + const { data, isLoading } = useFetchSLOsOverview({ + kqlQuery, + filters, + tagsFilter, + statusFilter, + }); + + const theme = useEuiTheme().euiTheme; + const { data: currentSettings } = useGetSettings(); + + return ( + + + + +

+ {i18n.translate('xpack.slo.sLOsOverview.h3.overviewLabel', { + defaultMessage: 'Overview', + })} +

+
+ + + + + + + + +
+
+ + + +
+ ); +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts b/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts new file mode 100644 index 00000000000000..783c23d49a42ba --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts @@ -0,0 +1,108 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import { buildQueryFromFilters, Filter } from '@kbn/es-query'; +import { useMemo } from 'react'; +import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview'; +import { sloKeys } from '../../../hooks/query_key_factory'; +import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../../common/constants'; +import { useCreateDataView } from '../../../hooks/use_create_data_view'; +import { useKibana } from '../../../utils/kibana_react'; +import { SearchState } from './use_url_search_state'; + +interface SLOsOverviewParams { + kqlQuery?: string; + tagsFilter?: SearchState['tagsFilter']; + statusFilter?: SearchState['statusFilter']; + filters?: Filter[]; + lastRefresh?: number; +} + +interface UseFetchSloGroupsResponse { + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + data: GetOverviewResponse | undefined; +} + +export function useFetchSLOsOverview({ + kqlQuery = '', + tagsFilter, + statusFilter, + filters: filterDSL = [], + lastRefresh, +}: SLOsOverviewParams = {}): UseFetchSloGroupsResponse { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const { dataView } = useCreateDataView({ + indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_PATTERN, + }); + + const filters = useMemo(() => { + try { + return JSON.stringify( + buildQueryFromFilters( + [ + ...filterDSL, + ...(tagsFilter ? [tagsFilter] : []), + ...(statusFilter ? [statusFilter] : []), + ], + dataView, + { + ignoreFilterIfFieldNotInIndex: true, + } + ) + ); + } catch (e) { + return ''; + } + }, [filterDSL, tagsFilter, statusFilter, dataView]); + const { data, isLoading, isSuccess, isError, isRefetching } = useQuery({ + queryKey: sloKeys.overview({ + kqlQuery, + filters, + lastRefresh, + }), + queryFn: async ({ signal }) => { + return await http.get('/internal/observability/slos/overview', { + query: { + ...(kqlQuery && { kqlQuery }), + ...(filters && { filters }), + }, + signal, + }); + }, + cacheTime: 0, + refetchOnWindowFocus: false, + retry: (failureCount, error) => { + if (String(error) === 'Error: Forbidden') { + return false; + } + return failureCount < 4; + }, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.slo.overview.list.errorNotification', { + defaultMessage: 'Something went wrong while fetching SLOs overview', + }), + }); + }, + }); + + return { + data, + isLoading, + isSuccess, + isError, + isRefetching, + }; +} diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx index 5ec9b84dddb29a..fdb40e12ba4e27 100644 --- a/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx +++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx @@ -6,8 +6,10 @@ */ import { i18n } from '@kbn/i18n'; -import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; import React, { useEffect } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; +import { SLOsOverview } from './components/slos_overview/slos_overview'; import { paths } from '../../../common/locators/paths'; import { HeaderMenu } from '../../components/header_menu/header_menu'; import { SloOutdatedCallout } from '../../components/slo/slo_outdated_callout'; @@ -66,6 +68,8 @@ export function SlosPage() { > + + ); diff --git a/x-pack/plugins/observability_solution/slo/public/plugin.ts b/x-pack/plugins/observability_solution/slo/public/plugin.ts index 5b25f97742c86c..99acdbbc733551 100644 --- a/x-pack/plugins/observability_solution/slo/public/plugin.ts +++ b/x-pack/plugins/observability_solution/slo/public/plugin.ts @@ -14,7 +14,7 @@ import { Plugin, PluginInitializerContext, } from '@kbn/core/public'; -import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { BehaviorSubject, Subject, firstValueFrom } from 'rxjs'; import { SloPublicPluginsSetup, SloPublicPluginsStart } from './types'; import { PLUGIN_NAME, sloAppId } from '../common'; import type { SloPublicSetup, SloPublicStart } from './types'; @@ -32,6 +32,7 @@ export class SloPlugin implements Plugin { private readonly appUpdater$ = new BehaviorSubject(() => ({})); + private readonly appMountParameters$ = new Subject>(); private experimentalFeatures: ExperimentalFeatures = { ruleFormV2: { enabled: false } }; constructor(private readonly initContext: PluginInitializerContext) { @@ -56,6 +57,7 @@ export class SloPlugin const [coreStart, pluginsStart] = await coreSetup.getStartServices(); const { ruleTypeRegistry, actionTypeRegistry } = pluginsStart.triggersActionsUi; const { observabilityRuleTypeRegistry } = pluginsStart.observability; + this.appMountParameters$.next(params); return renderApp({ appMountParameters: params, @@ -88,7 +90,7 @@ export class SloPlugin registerBurnRateRuleType(pluginsSetup.observability.observabilityRuleTypeRegistry); const assertPlatinumLicense = async () => { - const licensing = await pluginsSetup.licensing; + const licensing = pluginsSetup.licensing; const license = await firstValueFrom(licensing.license$); const hasPlatinumLicense = license.hasAtLeast('platinum'); @@ -164,6 +166,7 @@ export class SloPlugin observabilityRuleTypeRegistry: pluginsStart.observability.observabilityRuleTypeRegistry, ObservabilityPageTemplate: pluginsStart.observabilityShared.navigation.PageTemplate, plugins: { ...pluginsStart, ruleTypeRegistry, actionTypeRegistry }, + getAppMountParameters: () => firstValueFrom(this.appMountParameters$), isServerless: !!pluginsStart.serverless, experimentalFeatures: this.experimentalFeatures, }), diff --git a/x-pack/plugins/observability_solution/slo/server/plugin.ts b/x-pack/plugins/observability_solution/slo/server/plugin.ts index bcbd201b6bc91a..7c3dd521a5d527 100644 --- a/x-pack/plugins/observability_solution/slo/server/plugin.ts +++ b/x-pack/plugins/observability_solution/slo/server/plugin.ts @@ -17,7 +17,10 @@ import { } from '@kbn/core/server'; import { PluginSetupContract, PluginStartContract } from '@kbn/alerting-plugin/server'; import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server'; -import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; +import { + RuleRegistryPluginSetupContract, + RuleRegistryPluginStartContract, +} from '@kbn/rule-registry-plugin/server'; import { TaskManagerSetupContract, TaskManagerStartContract, @@ -56,6 +59,7 @@ export interface PluginStart { alerting: PluginStartContract; taskManager: TaskManagerStartContract; spaces?: SpacesPluginStart; + ruleRegistry: RuleRegistryPluginStartContract; } const sloRuleTypes = [SLO_BURN_RATE_RULE_TYPE_ID]; @@ -153,6 +157,10 @@ export class SloPlugin implements Plugin { const [, pluginStart] = await core.getStartServices(); return pluginStart.alerting.getRulesClientWithRequest(request); }, + getRacClientWithRequest: async (request) => { + const [, pluginStart] = await core.getStartServices(); + return pluginStart.ruleRegistry.getRacClientWithRequest(request); + }, }, logger: this.logger, repository: getSloServerRouteRepository({ diff --git a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts index 83f5ce796e70d6..e74b5abb811b98 100644 --- a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts +++ b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts @@ -8,7 +8,11 @@ import { errors } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import { RulesClientApi } from '@kbn/alerting-plugin/server/types'; import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server'; -import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server'; +import { + AlertsClient, + RuleDataPluginService, + RuleRegistryPluginSetupContract, +} from '@kbn/rule-registry-plugin/server'; import { decodeRequestParams, parseEndpoint, @@ -33,10 +37,12 @@ interface RegisterRoutes { export interface RegisterRoutesDependencies { pluginsSetup: { core: CoreSetup; + ruleRegistry: RuleRegistryPluginSetupContract; }; getSpacesStart: () => Promise; ruleDataService: RuleDataPluginService; getRulesClientWithRequest: (request: KibanaRequest) => Promise; + getRacClientWithRequest: (request: KibanaRequest) => Promise; } export function registerRoutes({ config, repository, core, logger, dependencies }: RegisterRoutes) { diff --git a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts index d71a65e95013a2..d7e6d02583376c 100644 --- a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts +++ b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts @@ -27,6 +27,8 @@ import { resetSLOParamsSchema, updateSLOParamsSchema, } from '@kbn/slo-schema'; +import { getOverviewParamsSchema } from '@kbn/slo-schema/src/rest_specs/routes/get_overview'; +import { GetSLOsOverview } from '../../services/get_slos_overview'; import type { IndicatorTypes } from '../../domain/models'; import { CreateSLO, @@ -663,6 +665,37 @@ const putSloSettings = (isServerless?: boolean) => }, }); +const getSLOsOverview = createSloServerRoute({ + endpoint: 'GET /internal/observability/slos/overview', + options: { + tags: ['access:slo_read'], + access: 'internal', + }, + params: getOverviewParamsSchema, + handler: async ({ context, params, request, logger, dependencies }) => { + await assertPlatinumLicense(context); + + const soClient = (await context.core).savedObjects.client; + const esClient = (await context.core).elasticsearch.client.asCurrentUser; + + const racClient = await dependencies.getRacClientWithRequest(request); + + const spaces = await dependencies.getSpacesStart(); + const spaceId = (await spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default'; + const rulesClient = await dependencies.getRulesClientWithRequest(request); + + const slosOverview = new GetSLOsOverview( + soClient, + esClient, + spaceId, + logger, + rulesClient, + racClient + ); + return await slosOverview.execute(params?.query ?? {}); + }, +}); + export const getSloRouteRepository = (isServerless?: boolean) => { return { ...fetchSloHealthRoute, @@ -686,5 +719,6 @@ export const getSloRouteRepository = (isServerless?: boolean) => { ...resetSLORoute, ...findSLOGroupsRoute, ...getSLOSuggestionsRoute, + ...getSLOsOverview, }; }; diff --git a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap index fc76eaeb65c386..76a022867cfcfa 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap +++ b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap @@ -1,5 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP + exports[`Summary Search Client returns the summary documents without duplicate temporary summary documents 1`] = ` Array [ Object { @@ -48,6 +49,7 @@ Object { }, "sliValue": -1, "status": "NO_DATA", + "summaryUpdatedAt": null, }, }, Object { @@ -63,6 +65,7 @@ Object { }, "sliValue": -1, "status": "NO_DATA", + "summaryUpdatedAt": null, }, }, Object { @@ -78,6 +81,7 @@ Object { }, "sliValue": -1, "status": "NO_DATA", + "summaryUpdatedAt": null, }, }, Object { @@ -93,6 +97,7 @@ Object { }, "sliValue": -1, "status": "NO_DATA", + "summaryUpdatedAt": null, }, }, Object { @@ -108,6 +113,7 @@ Object { }, "sliValue": -1, "status": "NO_DATA", + "summaryUpdatedAt": null, }, }, ], diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts index cfff12d2f503b1..239b3aaaec5188 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts @@ -45,6 +45,7 @@ describe('FindSLO', () => { "page": 1, "perPage": 25, }, + undefined, ] `); @@ -139,6 +140,7 @@ describe('FindSLO', () => { "page": 2, "perPage": 10, }, + undefined, ] `); }); diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts index 2ea0a3c44a8f95..dcd7fe44d0783d 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts @@ -27,7 +27,8 @@ export class FindSLO { params.kqlQuery ?? '', params.filters ?? '', toSort(params), - toPagination(params) + toPagination(params), + params.hideStale ); const localSloDefinitions = await this.repository.findAllByIds( diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts index 9931a8c8c6c6f0..d3e4670f025bb2 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts @@ -12,10 +12,10 @@ import { Pagination, sloGroupWithSummaryResponseSchema, } from '@kbn/slo-schema'; +import { getListOfSummaryIndices, getSloSettings } from './slo_settings'; import { DEFAULT_SLO_GROUPS_PAGE_SIZE } from '../../common/constants'; import { IllegalArgumentError } from '../errors'; import { typedSearch } from '../utils/queries'; -import { getListOfSummaryIndices } from './slo_settings'; import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary'; import { getElasticsearchQueryOrThrow } from './transform_generators'; @@ -56,8 +56,8 @@ export class FindSLOGroups { } catch (e) { this.logger.error(`Failed to parse filters: ${e.message}`); } - - const indices = await getListOfSummaryIndices(this.soClient, this.esClient); + const settings = await getSloSettings(this.soClient); + const { indices } = await getListOfSummaryIndices(this.esClient, settings); const hasSelectedTags = groupBy === 'slo.tags' && groupsFilter.length > 0; diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts new file mode 100644 index 00000000000000..f3c25f46bf71a8 --- /dev/null +++ b/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts @@ -0,0 +1,153 @@ +/* + * 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 { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { Logger } from '@kbn/logging'; +import { + GetOverviewParams, + GetOverviewResponse, +} from '@kbn/slo-schema/src/rest_specs/routes/get_overview'; +import { RulesClientApi } from '@kbn/alerting-plugin/server/types'; +import { AlertsClient } from '@kbn/rule-registry-plugin/server'; +import moment from 'moment'; +import { observabilityAlertFeatureIds } from '@kbn/observability-plugin/common'; +import { typedSearch } from '../utils/queries'; +import { getElasticsearchQueryOrThrow } from './transform_generators'; +import { getListOfSummaryIndices, getSloSettings } from './slo_settings'; + +export class GetSLOsOverview { + constructor( + private soClient: SavedObjectsClientContract, + private esClient: ElasticsearchClient, + private spaceId: string, + private logger: Logger, + private rulesClient: RulesClientApi, + private racClient: AlertsClient + ) {} + + public async execute(params: GetOverviewParams = {}): Promise { + const settings = await getSloSettings(this.soClient); + const { indices } = await getListOfSummaryIndices(this.esClient, settings); + + const kqlQuery = params.kqlQuery ?? ''; + const filters = params.filters ?? ''; + let parsedFilters: any = {}; + try { + parsedFilters = JSON.parse(filters); + } catch (e) { + this.logger.error(`Failed to parse filters: ${e.message}`); + } + + const response = await typedSearch(this.esClient, { + index: indices, + size: 0, + query: { + bool: { + filter: [ + { term: { spaceId: this.spaceId } }, + getElasticsearchQueryOrThrow(kqlQuery), + ...(parsedFilters.filter ?? []), + ], + must_not: [...(parsedFilters.must_not ?? [])], + }, + }, + body: { + aggs: { + worst: { + top_hits: { + sort: { + errorBudgetRemaining: { + order: 'asc', + }, + }, + _source: { + includes: ['sliValue', 'status', 'slo.id', 'slo.instanceId', 'slo.name'], + }, + size: 1, + }, + }, + stale: { + filter: { + range: { + summaryUpdatedAt: { + lt: `now-${settings.staleThresholdInHours}h`, + }, + }, + }, + }, + violated: { + filter: { + term: { + status: 'VIOLATED', + }, + }, + }, + healthy: { + filter: { + term: { + status: 'HEALTHY', + }, + }, + }, + degrading: { + filter: { + term: { + status: 'DEGRADING', + }, + }, + }, + noData: { + filter: { + term: { + status: 'NO_DATA', + }, + }, + }, + }, + }, + }); + + const [rules, alerts] = await Promise.all([ + this.rulesClient.find({ + options: { + search: 'alert.attributes.alertTypeId:("slo.rules.burnRate")', + }, + }), + + this.racClient.getAlertSummary({ + featureIds: observabilityAlertFeatureIds, + gte: moment().subtract(24, 'hours').toISOString(), + lte: moment().toISOString(), + filter: [ + { + term: { + 'kibana.alert.rule.rule_type_id': 'slo.rules.burnRate', + }, + }, + ], + }), + ]); + + const aggs = response.aggregations; + + return { + violated: aggs?.violated.doc_count ?? 0, + degrading: aggs?.degrading.doc_count ?? 0, + healthy: aggs?.healthy.doc_count ?? 0, + noData: aggs?.noData.doc_count ?? 0, + stale: aggs?.stale.doc_count ?? 0, + worst: { + value: 0, + id: 'id', + }, + burnRateRules: rules.total, + burnRateActiveAlerts: alerts.activeAlertCount, + burnRateRecoveredAlerts: alerts.recoveredAlertCount, + }; + } +} diff --git a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts index 407fd692cc646a..e3bce05843374e 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts @@ -9,7 +9,10 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { PutSLOSettingsParams, sloSettingsSchema } from '@kbn/slo-schema'; -import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants'; +import { + DEFAULT_STALE_SLO_THRESHOLD_HOURS, + SLO_SUMMARY_DESTINATION_INDEX_PATTERN, +} from '../../common/constants'; import { getListOfSloSummaryIndices } from '../../common/summary_indices'; import { StoredSLOSettings } from '../domain/models'; import { sloSettingsObjectId, SO_SLO_SETTINGS_TYPE } from '../saved_objects/slo_settings'; @@ -20,12 +23,15 @@ export const getSloSettings = async (soClient: SavedObjectsClientContract) => { SO_SLO_SETTINGS_TYPE, sloSettingsObjectId(soClient.getCurrentNamespace()) ); + // set if it's not there + soObject.attributes.staleThresholdInHours = soObject.attributes.staleThresholdInHours ?? 2; return sloSettingsSchema.encode(soObject.attributes); } catch (e) { if (SavedObjectsErrorHelpers.isNotFoundError(e)) { return { useAllRemoteClusters: false, selectedRemoteClusters: [], + staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS, }; } throw e; @@ -49,15 +55,12 @@ export const storeSloSettings = async ( }; export const getListOfSummaryIndices = async ( - soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient + esClient: ElasticsearchClient, + settings: StoredSLOSettings ) => { - const indices: string[] = [SLO_SUMMARY_DESTINATION_INDEX_PATTERN]; - - const settings = await getSloSettings(soClient); const { useAllRemoteClusters, selectedRemoteClusters } = settings; if (!useAllRemoteClusters && selectedRemoteClusters.length === 0) { - return indices; + return { indices: [SLO_SUMMARY_DESTINATION_INDEX_PATTERN], settings }; } const clustersByName = await esClient.cluster.remoteInfo(); @@ -67,5 +70,5 @@ export const getListOfSummaryIndices = async ( isConnected: clustersByName[clusterName].connected, })); - return getListOfSloSummaryIndices(settings, clusterInfo); + return { indices: getListOfSloSummaryIndices(settings, clusterInfo) }; }; diff --git a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts index 8ad2f657d926d3..a522bd287d0458 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts @@ -121,4 +121,117 @@ describe('Summary Search Client', () => { expect(results).toMatchSnapshot(); expect(results.total).toBe(5); }); + + it('handles hideStale filter', async () => { + await service.search('', '', defaultSort, defaultPagination, true); + expect(esClientMock.search.mock.calls[0]).toEqual([ + { + from: 0, + index: ['.slo-observability.summary-v3*'], + query: { + bool: { + filter: [ + { + term: { + spaceId: 'default', + }, + }, + { + bool: { + should: [ + { + term: { + isTempDoc: true, + }, + }, + { + range: { + summaryUpdatedAt: { + gte: 'now-2h', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + must_not: [], + }, + }, + size: 40, + sort: { + isTempDoc: { + order: 'asc', + }, + sliValue: { + order: 'asc', + }, + }, + track_total_hits: true, + }, + ]); + + await service.search('', '', defaultSort, defaultPagination); + expect(esClientMock.search.mock.calls[1]).toEqual([ + { + from: 0, + index: ['.slo-observability.summary-v3*'], + query: { + bool: { + filter: [ + { + term: { + spaceId: 'default', + }, + }, + { + match_all: {}, + }, + ], + must_not: [], + }, + }, + size: 40, + sort: { + isTempDoc: { + order: 'asc', + }, + sliValue: { + order: 'asc', + }, + }, + track_total_hits: true, + }, + ]); + }); + + it('handles summaryUpdate kql filter override', async () => { + await service.search('summaryUpdatedAt > now-2h', '', defaultSort, defaultPagination, true); + expect(esClientMock.search.mock.calls[0]).toEqual([ + { + from: 0, + index: ['.slo-observability.summary-v3*'], + query: { + bool: { + filter: [ + { term: { spaceId: 'default' } }, + { + bool: { + minimum_should_match: 1, + should: [{ range: { summaryUpdatedAt: { gt: 'now-2h' } } }], + }, + }, + ], + must_not: [], + }, + }, + size: 40, + sort: { isTempDoc: { order: 'asc' }, sliValue: { order: 'asc' } }, + track_total_hits: true, + }, + ]); + }); }); diff --git a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts index e5f9992cc0f214..19bccfe72c537b 100644 --- a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts +++ b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts @@ -10,11 +10,12 @@ import { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/co import { ALL_VALUE, Paginated, Pagination } from '@kbn/slo-schema'; import { assertNever } from '@kbn/std'; import { partition } from 'lodash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants'; -import { Groupings, SLODefinition, SLOId, Summary } from '../domain/models'; +import { Groupings, SLODefinition, SLOId, StoredSLOSettings, Summary } from '../domain/models'; import { toHighPrecision } from '../utils/number'; import { createEsParams, typedSearch } from '../utils/queries'; -import { getListOfSummaryIndices } from './slo_settings'; +import { getListOfSummaryIndices, getSloSettings } from './slo_settings'; import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary'; import { getElasticsearchQueryOrThrow } from './transform_generators'; import { fromRemoteSummaryDocumentToSloDefinition } from './unsafe_federated/remote_summary_doc_to_slo'; @@ -44,7 +45,8 @@ export interface SummarySearchClient { kqlQuery: string, filters: string, sort: Sort, - pagination: Pagination + pagination: Pagination, + hideStale?: boolean ): Promise>; } @@ -60,7 +62,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient { kqlQuery: string, filters: string, sort: Sort, - pagination: Pagination + pagination: Pagination, + hideStale?: boolean ): Promise> { let parsedFilters: any = {}; @@ -69,8 +72,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient { } catch (e) { this.logger.error(`Failed to parse filters: ${e.message}`); } - - const indices = await getListOfSummaryIndices(this.soClient, this.esClient); + const settings = await getSloSettings(this.soClient); + const { indices } = await getListOfSummaryIndices(this.esClient, settings); const esParams = createEsParams({ index: indices, track_total_hits: true, @@ -78,6 +81,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient { bool: { filter: [ { term: { spaceId: this.spaceId } }, + ...excludeStaleSummaryFilter(settings, kqlQuery, hideStale), getElasticsearchQueryOrThrow(kqlQuery), ...(parsedFilters.filter ?? []), ], @@ -159,6 +163,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient { }, sliValue: toHighPrecision(doc._source.sliValue), status: summaryDoc.status, + summaryUpdatedAt: summaryDoc.summaryUpdatedAt, }, groupings: getFlattenedGroupings({ groupings: summaryDoc.slo.groupings, @@ -189,6 +194,32 @@ export class DefaultSummarySearchClient implements SummarySearchClient { } } +function excludeStaleSummaryFilter( + settings: StoredSLOSettings, + kqlFilter: string, + hideStale?: boolean +): estypes.QueryDslQueryContainer[] { + if (kqlFilter.includes('summaryUpdatedAt') || !settings.staleThresholdInHours || !hideStale) { + return []; + } + return [ + { + bool: { + should: [ + { term: { isTempDoc: true } }, + { + range: { + summaryUpdatedAt: { + gte: `now-${settings.staleThresholdInHours}h`, + }, + }, + }, + ], + }, + }, + ]; +} + function getRemoteClusterName(index: string) { if (index.includes(':')) { return index.split(':')[0]; diff --git a/x-pack/plugins/observability_solution/slo/tsconfig.json b/x-pack/plugins/observability_solution/slo/tsconfig.json index 34ce93e23f5746..d16328c73a35f3 100644 --- a/x-pack/plugins/observability_solution/slo/tsconfig.json +++ b/x-pack/plugins/observability_solution/slo/tsconfig.json @@ -95,5 +95,6 @@ "@kbn/dashboard-plugin", "@kbn/monaco", "@kbn/code-editor", + "@kbn/react-kibana-context-render" ] } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts index 19aedd173abbfc..3870710456ad07 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts @@ -22,6 +22,7 @@ import type { SentinelOneActivityEsDoc, EndpointActionDataParameterTypes, EndpointActionResponseDataOutput, + SentinelOneActivityDataForType80, } from '../types'; export class SentinelOneDataGenerator extends EndpointActionGenerator { @@ -41,12 +42,13 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator { } /** Generate a SentinelOne activity index ES doc */ - generateActivityEsDoc( + generateActivityEsDoc( overrides: DeepPartial = {} - ): SentinelOneActivityEsDoc { + ): SentinelOneActivityEsDoc { const doc: SentinelOneActivityEsDoc = { sentinel_one: { activity: { + data: {}, agent: { id: this.seededUUIDv4(), }, @@ -60,13 +62,13 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator { }, }; - return merge(doc, overrides); + return merge(doc, overrides) as SentinelOneActivityEsDoc; } - generateActivityEsSearchHit( - overrides: DeepPartial = {} - ): SearchHit { - const hit = this.toEsSearchHit( + generateActivityEsSearchHit( + overrides: DeepPartial> = {} + ): SearchHit> { + const hit = this.toEsSearchHit>( this.generateActivityEsDoc(overrides), SENTINEL_ONE_ACTIVITY_INDEX_PATTERN ); @@ -81,10 +83,39 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator { return hit; } - generateActivityEsSearchResponse( - docs: Array> = [this.generateActivityEsSearchHit()] - ): SearchResponse { - return this.toEsSearchResponse(docs); + generateActivityEsSearchResponse( + docs: Array>> = [this.generateActivityEsSearchHit()] + ): SearchResponse> { + return this.toEsSearchResponse>(docs); + } + + generateActivityFetchFileResponseData( + overrides: DeepPartial = {} + ): SentinelOneActivityDataForType80 { + const data: SentinelOneActivityDataForType80 = { + flattened: { + commandId: Number([...this.randomNGenerator(1000, 2)].join('')), + commandBatchUuid: this.seededUUIDv4(), + filename: 'file.zip', + sourceType: 'API', + uploadedFilename: 'file_fetch.zip', + }, + site: { name: 'Default site' }, + group_name: 'Default Group', + scope: { level: 'Group', name: 'Default Group' }, + fullscope: { + details: 'Group Default Group in Site Default site of Account Foo', + details_path: 'Global / Foo / Default site / Default Group', + }, + downloaded: { + url: `/agents/${[...this.randomNGenerator(100, 4)].join('')}/uploads/${[ + ...this.randomNGenerator(100, 4), + ].join('')}`, + }, + account: { name: 'Foo' }, + }; + + return merge(data, overrides); } generateSentinelOneApiActivityResponse( diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index 57df36e32a893e..6e6b6de839344e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -155,3 +155,13 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze< // 4 hrs in seconds // 4 * 60 * 60 export const DEFAULT_EXECUTE_ACTION_TIMEOUT = 14400; + +/** + * The passcodes used for accessing the content of a zip file (ex. from a `get-file` response action) + */ +export const RESPONSE_ACTIONS_ZIP_PASSCODE: Readonly> = + Object.freeze({ + endpoint: 'elastic', + sentinel_one: 'Elastic@123', + crowdstrike: 'tbd..', + }); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts new file mode 100644 index 00000000000000..035c7c4e6d2092 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { EndpointActionGenerator } from '../../data_generators/endpoint_action_generator'; +import type { ActionDetails } from '../../types'; +import { getFileDownloadId } from './get_file_download_id'; + +describe('getFileDownloadId()', () => { + let action: ActionDetails; + let agentId: string; + + beforeEach(() => { + action = new EndpointActionGenerator().generateActionDetails(); + agentId = action.agents[0]; + }); + + it('should throw if agentId is not listed in the action', () => { + action.agents = ['foo']; + + expect(() => getFileDownloadId(action, agentId)).toThrow( + `Action [${action.id}] was not sent to agent id [${agentId}]` + ); + }); + + it('Should return expected id for Endpoint agent type when agentId is passed as an argument', () => { + expect(getFileDownloadId(action, agentId)).toEqual(`${action.id}.${agentId}`); + }); + + it('Should return expected id for Endpoint agent type when agentId is NOT passed as an argument', () => { + action.agents = ['foo', 'foo2']; + + expect(getFileDownloadId(action)).toEqual(`${action.id}.foo`); + }); + + it('should return expected ID for non-endpoint agent types when agentId is passed as an argument', () => { + action.agentType = 'sentinel_one'; + expect(getFileDownloadId(action, agentId)).toEqual(agentId); + }); + + it('should return expected ID for non-endpoint agent types when agentId is NOT passed as an argument', () => { + action.agentType = 'sentinel_one'; + action.agents = ['foo', 'foo2']; + + expect(getFileDownloadId(action)).toEqual(`foo`); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts index 12d74207c57b9c..3b249490cfea47 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts @@ -13,11 +13,17 @@ import type { ActionDetails } from '../../types'; * @param agentId */ export const getFileDownloadId = (action: ActionDetails, agentId?: string): string => { - const { id: actionId, agents } = action; + const { id: actionId, agents, agentType } = action; if (agentId && !agents.includes(agentId)) { throw new Error(`Action [${actionId}] was not sent to agent id [${agentId}]`); } + // If not an Endpoint agent type, then return the agent id. Agent ID will be used as the + // file identifier for non-endpoint agents + if (agentType !== 'endpoint') { + return agentId ?? agents[0]; + } + return `${actionId}.${agentId ?? agents[0]}`; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts index 5fe865488347c4..786e43dae61ace 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts @@ -9,9 +9,3 @@ * Index pattern where the SentinelOne activity log is written to by the SentinelOne integration */ export const SENTINEL_ONE_ACTIVITY_INDEX_PATTERN = 'logs-sentinel_one.activity-*'; - -/** - * The passcode to be used when initiating actions in SentinelOne that require a passcode to be - * set for the resulting zip file - */ -export const SENTINEL_ONE_ZIP_PASSCODE = 'Elastic@123'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 34c0a8bafe10b4..c2279ec7b8be64 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -522,6 +522,7 @@ export type UploadedFileInfo = Pick< > & { actionId: string; agentId: string; + agentType: ResponseActionAgentType; }; export interface ActionFileInfoApiResponse { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts b/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts index c7e0b1d9a45815..91a06ffc5ffca4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts @@ -11,7 +11,7 @@ * NOTE: not all properties are currently mapped below. Check the index definition if wanting to * see what else is available and add it bellow if needed */ -export interface SentinelOneActivityEsDoc { +export interface SentinelOneActivityEsDoc { sentinel_one: { activity: { agent: { @@ -26,10 +26,50 @@ export interface SentinelOneActivityEsDoc { id: string; /** The activity type. Valid values can be retrieved from S1 via API: `/web/api/v2.1/activities/types` */ type: number; + /** Activity specific data */ + data: TData; }; }; } +/** + * Activity data for file uploaded to S1 by an Agent: + * ``` + * { + * "action": "Agent Uploaded Fetched Files", + * "descriptionTemplate": "Agent {{ computer_name }} ({{ external_ip }}) successfully uploaded {{ filename }}.", + * "id": 80 + * }, + * ``` + */ +export interface SentinelOneActivityDataForType80 { + flattened: { + commandId: number; + commandBatchUuid: string; + filename: string; + sourceType: string; + uploadedFilename: string; + }; + site: { + name: string; + }; + group_name: string; + scope: { + level: string; + name: string; + }; + fullscope: { + details: string; + details_path: string; + }; + downloaded: { + url: string; + }; + account: { + name: string; + }; +} + export interface SentinelOneActionRequestCommonMeta { /** The S1 agent id */ agentId: string; @@ -63,3 +103,15 @@ export interface SentinelOneGetFileRequestMeta extends SentinelOneActionRequestC */ commandBatchUuid: string; } + +export interface SentinelOneGetFileResponseMeta { + /** The document ID in the Elasticsearch S1 activity index that was used to complete the response action */ + elasticDocId: string; + /** The SentinelOne activity log entry ID */ + activityLogEntryId: string; + /** The S1 download url (relative URI) for the file that was retrieved */ + downloadUrl: string; + /** When the file was created/uploaded to SentinelOne */ + createdAt: string; + filename: string; +} diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx index c0f30b7b640fc5..873e816e12dce8 100644 --- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx @@ -26,6 +26,7 @@ import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mock import { getDeferred } from '../../mocks/utils'; import { waitFor } from '@testing-library/react'; import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../common/endpoint/service/response_actions/constants'; describe('When using the `ResponseActionFileDownloadLink` component', () => { let render: () => ReturnType; @@ -66,7 +67,7 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => { '/api/endpoint/action/123/file/123.agent-a/download?apiVersion=2023-10-31' ); expect(renderResult.getByTestId('test-passcodeMessage')).toHaveTextContent( - FILE_PASSCODE_INFO_MESSAGE + FILE_PASSCODE_INFO_MESSAGE(RESPONSE_ACTIONS_ZIP_PASSCODE.endpoint) ); expect(renderResult.getByTestId('test-fileDeleteMessage')).toHaveTextContent( FILE_DELETED_MESSAGE diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx index 2d679d4b3479e6..f4db61a270ff82 100644 --- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx +++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx @@ -17,6 +17,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../common/endpoint/service/response_actions/constants'; import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id'; import { resolvePathVariables } from '../../../common/utils/resolve_path_variables'; import { FormattedError } from '../formatted_error'; @@ -48,15 +49,11 @@ export const FILE_DELETED_MESSAGE = i18n.translate( } ); -export const FILE_PASSCODE_INFO_MESSAGE = i18n.translate( - 'xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo', - { +export const FILE_PASSCODE_INFO_MESSAGE = (passcode: string) => + i18n.translate('xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo', { defaultMessage: '(ZIP file passcode: {passcode}).', - values: { - passcode: 'elastic', - }, - } -); + values: { passcode }, + }); export const FILE_TRUNCATED_MESSAGE = i18n.translate( 'xpack.securitySolution.responseActionFileDownloadLink.fileTruncated', @@ -189,7 +186,7 @@ export const ResponseActionFileDownloadLink = memo - {FILE_PASSCODE_INFO_MESSAGE} + {FILE_PASSCODE_INFO_MESSAGE(RESPONSE_ACTIONS_ZIP_PASSCODE[action.agentType])} {FILE_DELETED_MESSAGE} diff --git a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts index 2d775cfb3ff8a3..d05c2920f508ad 100644 --- a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts @@ -208,6 +208,7 @@ export const responseActionsHttpMocks = httpHandlerMockFactory { + const actual = jest.requireActual('../../services'); + return { + ...actual, + validateActionIdMock: jest.fn(async () => {}), + getActionAgentType: jest.fn(async () => ({ agentType: 'endpoint' })), + }; +}); describe('Response Actions file download API', () => { - const validateActionIdMock = _validateActionId as jest.Mock; - let apiTestSetup: HttpApiTestSetupMock; let httpRequestMock: ReturnType< HttpApiTestSetupMock['createRequestMock'] @@ -34,6 +42,17 @@ describe('Response Actions file download API', () => { beforeEach(() => { apiTestSetup = createHttpApiTestSetupMock(); + const esClientMock = apiTestSetup.getEsClientMock(); + const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock(); + + actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654'; + + applyEsClientSearchMock({ + esClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: actionRequestEsSearchResponse, + }); + ({ httpHandlerContextMock, httpResponseMock } = apiTestSetup); httpRequestMock = apiTestSetup.createRequestMock({ params: { action_id: '321-654', file_id: '123-456-789' }, @@ -75,42 +94,12 @@ describe('Response Actions file download API', () => { describe('Route handler', () => { let fileDownloadHandler: ReturnType; - let fleetFilesClientMock: jest.Mocked; beforeEach(async () => { fileDownloadHandler = getActionFileDownloadRouteHandler(apiTestSetup.endpointAppContextMock); - - validateActionIdMock.mockImplementation(async () => {}); - - fleetFilesClientMock = - (await apiTestSetup.endpointAppContextMock.service.getFleetFromHostFilesClient()) as jest.Mocked; - }); - - it('should error if action ID is invalid', async () => { - validateActionIdMock.mockRejectedValueOnce(new NotFoundError('not found')); - await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(httpResponseMock.notFound).toHaveBeenCalled(); - }); - - it('should error if file ID is invalid', async () => { - // @ts-expect-error assignment to readonly value - httpRequestMock.params.file_id = 'invalid'; - await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(httpResponseMock.customError).toHaveBeenCalledWith({ - statusCode: 400, - body: expect.any(CustomHttpRequestError), - }); - }); - - it('should retrieve the download Stream using correct file ID', async () => { - await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(fleetFilesClientMock.download).toHaveBeenCalledWith('123-456-789'); }); - it('should respond with expected HTTP headers', async () => { + it('should respond with expected Body and HTTP headers', async () => { await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); expect(httpResponseMock.ok).toHaveBeenCalledWith( @@ -121,6 +110,7 @@ describe('Response Actions file download API', () => { 'content-type': 'application/octet-stream', 'x-content-type-options': 'nosniff', }, + body: expect.any(Readable), }) ); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts index a9ece70bb214bb..cb4afe44964722 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts @@ -8,8 +8,12 @@ import type { RequestHandler } from '@kbn/core/server'; import type { EndpointActionFileDownloadParams } from '../../../../common/api/endpoint'; import { EndpointActionFileDownloadSchema } from '../../../../common/api/endpoint'; -import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; -import { validateActionId } from '../../services'; +import type { ResponseActionsClient } from '../../services'; +import { + getResponseActionsClient, + NormalizedExternalConnectorClient, + getActionAgentType, +} from '../../services'; import { errorHandler } from '../error_handler'; import { ACTION_AGENT_FILE_DOWNLOAD_ROUTE } from '../../../../common/endpoint/constants'; import { withEndpointAuthz } from '../with_endpoint_authz'; @@ -61,22 +65,23 @@ export const getActionFileDownloadRouteHandler = ( const logger = endpointContext.logFactory.get('actionFileDownload'); return async (context, req, res) => { - const fleetFiles = await endpointContext.service.getFleetFromHostFilesClient(); - const esClient = (await context.core).elasticsearch.client.asInternalUser; const { action_id: actionId, file_id: fileId } = req.params; try { - await validateActionId(esClient, actionId); - const file = await fleetFiles.get(fileId); - - if (file.id !== fileId) { - throw new CustomHttpRequestError( - `Invalid file id [${fileId}] for action [${actionId}]`, - 400 - ); - } + const esClient = (await context.core).elasticsearch.client.asInternalUser; + const { agentType } = await getActionAgentType(esClient, actionId); + const user = endpointContext.service.security?.authc.getCurrentUser(req); + const casesClient = await endpointContext.service.getCasesClient(req); + const connectorActions = (await context.actions).getActionsClient(); + const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, { + esClient, + casesClient, + endpointService: endpointContext.service, + username: user?.username || 'unknown', + connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger), + }); - const { stream, fileName } = await fleetFiles.download(fileId); + const { stream, fileName } = await responseActionsClient.getFileDownload(actionId, fileId); return res.ok({ body: stream, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts index 83462931b53177..e6554ee14ad6d8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts @@ -5,22 +5,29 @@ * 2.0. */ -import { validateActionId as _validateActionId } from '../../services'; import type { HttpApiTestSetupMock } from '../../mocks'; import { createHttpApiTestSetupMock } from '../../mocks'; import type { EndpointActionFileDownloadParams } from '../../../../common/api/endpoint'; import { getActionFileInfoRouteHandler, registerActionFileInfoRoute } from './file_info_handler'; -import { ACTION_AGENT_FILE_INFO_ROUTE } from '../../../../common/endpoint/constants'; -import { EndpointAuthorizationError, NotFoundError } from '../../errors'; -import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; +import { + ACTION_AGENT_FILE_INFO_ROUTE, + ENDPOINT_ACTIONS_INDEX, +} from '../../../../common/endpoint/constants'; +import { EndpointAuthorizationError } from '../../errors'; import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks'; -import type { FleetFromHostFileClientInterface } from '@kbn/fleet-plugin/server'; - -jest.mock('../../services'); +import { createActionRequestsEsSearchResultsMock } from '../../services/actions/mocks'; +import { applyEsClientSearchMock } from '../../mocks/utils.mock'; + +jest.mock('../../services', () => { + const actual = jest.requireActual('../../services'); + return { + ...actual, + validateActionIdMock: jest.fn(async () => {}), + getActionAgentType: jest.fn(async () => ({ agentType: 'endpoint' })), + }; +}); describe('Response Action file info API', () => { - const validateActionIdMock = _validateActionId as jest.Mock; - let apiTestSetup: HttpApiTestSetupMock; let httpRequestMock: ReturnType< HttpApiTestSetupMock['createRequestMock'] @@ -31,6 +38,17 @@ describe('Response Action file info API', () => { beforeEach(() => { apiTestSetup = createHttpApiTestSetupMock(); + const esClientMock = apiTestSetup.getEsClientMock(); + const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock(); + + actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654'; + + applyEsClientSearchMock({ + esClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: actionRequestEsSearchResponse, + }); + ({ httpHandlerContextMock, httpResponseMock } = apiTestSetup); httpRequestMock = apiTestSetup.createRequestMock({ params: { action_id: '321-654', file_id: '123-456-789' }, @@ -65,41 +83,9 @@ describe('Response Action file info API', () => { describe('Route handler', () => { let fileInfoHandler: ReturnType; - let fleetFilesClientMock: jest.Mocked; beforeEach(async () => { fileInfoHandler = getActionFileInfoRouteHandler(apiTestSetup.endpointAppContextMock); - - validateActionIdMock.mockImplementation(async () => {}); - - fleetFilesClientMock = - (await apiTestSetup.endpointAppContextMock.service.getFleetFromHostFilesClient()) as jest.Mocked; - }); - - it('should error if action ID is invalid', async () => { - validateActionIdMock.mockImplementationOnce(async () => { - throw new NotFoundError('not found'); - }); - await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(httpResponseMock.notFound).toHaveBeenCalled(); - }); - - it('should error if file ID is invalid', async () => { - // @ts-expect-error assignment to readonly value - httpRequestMock.params.file_id = 'invalid'; - await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(httpResponseMock.customError).toHaveBeenCalledWith({ - statusCode: 400, - body: expect.any(CustomHttpRequestError), - }); - }); - - it('should retrieve the file info with correct file id', async () => { - await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); - - expect(fleetFilesClientMock.get).toHaveBeenCalledWith('123-456-789'); }); it('should respond with expected output', async () => { @@ -110,6 +96,7 @@ describe('Response Action file info API', () => { data: { actionId: '321-654', agentId: '111-222', + agentType: 'endpoint', created: '2023-05-12T19:47:33.702Z', id: '123-456-789', mimeType: 'text/plain', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts index e6e38f27f9cda6..0576cec69b01ca 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts @@ -8,8 +8,12 @@ import type { RequestHandler } from '@kbn/core/server'; import type { EndpointActionFileInfoParams } from '../../../../common/api/endpoint'; import { EndpointActionFileInfoSchema } from '../../../../common/api/endpoint'; -import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; -import { validateActionId } from '../../services'; +import type { ResponseActionsClient } from '../../services'; +import { + getResponseActionsClient, + NormalizedExternalConnectorClient, + getActionAgentType, +} from '../../services'; import { ACTION_AGENT_FILE_INFO_ROUTE } from '../../../../common/endpoint/constants'; import type { EndpointAppContext } from '../../types'; import type { @@ -31,34 +35,23 @@ export const getActionFileInfoRouteHandler = ( const logger = endpointContext.logFactory.get('actionFileInfo'); return async (context, req, res) => { - const fleetFiles = await endpointContext.service.getFleetFromHostFilesClient(); const { action_id: requestActionId, file_id: fileId } = req.params; - const esClient = (await context.core).elasticsearch.client.asInternalUser; try { - await validateActionId(esClient, requestActionId); - const { actionId, mimeType, status, size, name, id, agents, created } = await fleetFiles.get( - fileId - ); - - if (id !== fileId) { - throw new CustomHttpRequestError( - `Invalid file id [${fileId}] for action [${requestActionId}]`, - 400 - ); - } - + const esClient = (await context.core).elasticsearch.client.asInternalUser; + const { agentType } = await getActionAgentType(esClient, requestActionId); + const user = endpointContext.service.security?.authc.getCurrentUser(req); + const casesClient = await endpointContext.service.getCasesClient(req); + const connectorActions = (await context.actions).getActionsClient(); + const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, { + esClient, + casesClient, + endpointService: endpointContext.service, + username: user?.username || 'unknown', + connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger), + }); const response: ActionFileInfoApiResponse = { - data: { - name, - id, - mimeType, - size, - status, - created, - actionId, - agentId: agents.at(0) ?? '', - }, + data: await responseActionsClient.getFileInfo(requestActionId, fileId), }; return res.ok({ body: response }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts index 35b807de65daec..7a4246e7b1a2fe 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts @@ -7,7 +7,7 @@ import type { ElasticsearchClient } from '@kbn/core/server'; -import { fetchActionResponses } from './fetch_action_responses'; +import { fetchActionResponses } from './utils/fetch_action_responses'; import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants'; import { formatEndpointActionResults, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts index efcbd6fb49a929..1ebe20eb393b65 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts @@ -7,7 +7,7 @@ import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; -import { fetchActionResponses } from './fetch_action_responses'; +import { fetchActionResponses } from './utils/fetch_action_responses'; import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../../common/endpoint/constants'; import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; import type { ActionListApiResponse } from '../../../../common/endpoint/types'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts index 73948a4daf398b..9fce2a5e609f3e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts @@ -13,6 +13,10 @@ import { responseActionsClientMock } from '../mocks'; import { ENDPOINT_ACTIONS_INDEX } from '../../../../../../common/endpoint/constants'; import type { ResponseActionRequestBody } from '../../../../../../common/endpoint/types'; import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../../common/endpoint/service/response_actions/constants'; +import { applyEsClientSearchMock } from '../../../../mocks/utils.mock'; +import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { BaseDataGenerator } from '../../../../../../common/endpoint/data_generators/base_data_generator'; +import { Readable } from 'stream'; describe('EndpointActionsClient', () => { let classConstructorOptions: ResponseActionsClientOptions; @@ -229,7 +233,10 @@ describe('EndpointActionsClient', () => { ]); }); - type ResponseActionsMethodsOnly = keyof Omit; + type ResponseActionsMethodsOnly = keyof Omit< + ResponseActionsClient, + 'processPendingActions' | 'getFileDownload' | 'getFileInfo' + >; // eslint-disable-next-line @typescript-eslint/no-explicit-any const responseActionMethods: Record = { @@ -257,7 +264,7 @@ describe('EndpointActionsClient', () => { }; it.each(Object.keys(responseActionMethods) as ResponseActionsMethodsOnly[])( - 'should handle call to %s() method', + 'should dispatch a fleet action request calling %s() method', async (methodName) => { await endpointActionsClient[methodName](responseActionMethods[methodName]); @@ -295,4 +302,68 @@ describe('EndpointActionsClient', () => { ); } ); + + describe('#getFileDownload()', () => { + it('should throw error if agent type for the action id is not endpoint', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: BaseDataGenerator.toEsSearchResponse([]), + }); + + await expect(endpointActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Action id [abc] not found with an agent type of [endpoint]' + ); + }); + + it('should throw error if file id not associated with action id', async () => { + await expect(endpointActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Invalid file id [123] for action [abc]' + ); + }); + + it('should return expected response', async () => { + await expect( + endpointActionsClient.getFileDownload('321-654', '123-456-789') + ).resolves.toEqual({ + stream: expect.any(Readable), + fileName: expect.any(String), + mimeType: expect.any(String), + }); + }); + }); + + describe('#getFileInfo()', () => { + it('should throw error if agent type for the action id is not endpoint', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: BaseDataGenerator.toEsSearchResponse([]), + }); + + await expect(endpointActionsClient.getFileInfo('abc', '123')).rejects.toThrow( + 'Action id [abc] not found with an agent type of [endpoint]' + ); + }); + + it('should throw error if file id not associated with action id', async () => { + await expect(endpointActionsClient.getFileInfo('abc', '123')).rejects.toThrow( + 'Invalid file ID. File [123] not associated with action ID [abc]' + ); + }); + + it('should return expected response', async () => { + await expect(endpointActionsClient.getFileInfo('321-654', '123-456-789')).resolves.toEqual({ + actionId: '321-654', + agentId: '111-222', + agentType: 'endpoint', + created: '2023-05-12T19:47:33.702Z', + id: '123-456-789', + mimeType: 'text/plain', + name: 'foo.txt', + size: 45632, + status: 'READY', + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts index 08c1869256b2f6..96e77be833e3c5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts @@ -7,6 +7,7 @@ import type { FleetActionRequest } from '@kbn/fleet-plugin/server/services/actions'; import { v4 as uuidv4 } from 'uuid'; +import { CustomHttpRequestError } from '../../../../../utils/custom_http_request_error'; import { getActionRequestExpiration } from '../../utils'; import { ResponseActionsClientError } from '../errors'; import { stringify } from '../../../../utils/stringify'; @@ -40,8 +41,12 @@ import type { SuspendProcessActionOutputContent, LogsEndpointAction, EndpointActionDataParameterTypes, + UploadedFileInfo, } from '../../../../../../common/endpoint/types'; -import type { CommonResponseActionMethodOptions } from '../lib/types'; +import type { + CommonResponseActionMethodOptions, + GetFileDownloadMethodResponse, +} from '../lib/types'; import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../../common/endpoint/service/response_actions/constants'; export class EndpointActionsClient extends ResponseActionsClientImpl { @@ -342,4 +347,51 @@ export class EndpointActionsClient extends ResponseActionsClientImpl { throw err; } } + + async getFileDownload(actionId: string, fileId: string): Promise { + await this.ensureValidActionId(actionId); + + const fleetFiles = await this.options.endpointService.getFleetFromHostFilesClient(); + const file = await fleetFiles.get(fileId); + + if (file.actionId !== actionId) { + throw new CustomHttpRequestError(`Invalid file id [${fileId}] for action [${actionId}]`, 400); + } + + return fleetFiles.download(fileId); + } + + async getFileInfo(actionId: string, fileId: string): Promise { + await this.ensureValidActionId(actionId); + + const fleetFiles = await this.options.endpointService.getFleetFromHostFilesClient(); + const { + name, + id, + mimeType, + size, + status, + created, + agents, + actionId: fileActionId, + } = await fleetFiles.get(fileId); + + if (fileActionId !== actionId) { + throw new ResponseActionsClientError( + `Invalid file ID. File [${fileId}] not associated with action ID [${actionId}]` + ); + } + + return { + name, + id, + mimeType, + size, + status, + created, + actionId, + agentId: agents[0], + agentType: this.agentType, + }; + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts index 3c0190a459e35f..c22045fae97efe 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts @@ -54,3 +54,5 @@ export class ResponseActionsConnectorNotConfiguredError extends ResponseActionsC super(`No stack connector instance configured for [${connectorTypeId}]`, statusCode, meta); } } + +export class ResponseActionAgentResponseEsDocNotFound extends ResponseActionsClientError {} diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts index a41e651ee34494..a027f8e662c851 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts @@ -116,6 +116,17 @@ describe('ResponseActionsClientImpl base class', () => { await expect(responsePromise).rejects.toBeInstanceOf(ResponseActionsNotSupportedError); await expect(responsePromise).rejects.toHaveProperty('statusCode', 405); }); + + it.each(['getFileDownload', 'getFileInfo'])( + 'should throw not implemented error for %s()', + async (method) => { + // @ts-expect-error ignoring input type to method since they all should throw + const responsePromise = baseClassMock[method]({}); + + await expect(responsePromise).rejects.toThrow(`Method ${method}() not implemented`); + await expect(responsePromise).rejects.toHaveProperty('statusCode', 501); + } + ); }); describe('#updateCases()', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts index c784c5e5eef5bb..148f04a587990d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts @@ -13,7 +13,11 @@ import { AttachmentType, ExternalReferenceStorageType } from '@kbn/cases-plugin/ import type { CaseAttachments } from '@kbn/cases-plugin/public/types'; import { i18n } from '@kbn/i18n'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; -import { fetchActionResponses } from '../../fetch_action_responses'; +import { validateActionId } from '../../utils/validate_action_id'; +import { + fetchActionResponses, + fetchEndpointActionResponses, +} from '../../utils/fetch_action_responses'; import { createEsSearchIterable } from '../../../../utils/create_es_search_iterable'; import { categorizeResponseResults, getActionRequestExpiration } from '../../utils'; import { isActionSupportedByAgentType } from '../../../../../../common/endpoint/service/response_actions/is_response_action_supported'; @@ -33,6 +37,7 @@ import type { CommonResponseActionMethodOptions, ProcessPendingActionsMethodOptions, ResponseActionsClient, + GetFileDownloadMethodResponse, } from './types'; import type { ActionDetails, @@ -52,6 +57,7 @@ import type { ResponseActionUploadParameters, SuspendProcessActionOutputContent, WithAllKeys, + UploadedFileInfo, } from '../../../../../../common/endpoint/types'; import type { ExecuteActionRequestBody, @@ -141,6 +147,13 @@ export type ResponseActionsClientValidateRequestResponse = error: ResponseActionsClientError; }; +export interface FetchActionResponseEsDocsResponse< + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = {} +> { + [agentId: string]: LogsEndpointActionResponse; +} + /** * Base class for a Response Actions client */ @@ -284,6 +297,38 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient ); } + /** + * Fetches the Response Action ES response documents for a given action id + * @param actionId + * @param agentIds + * @protected + */ + protected async fetchActionResponseEsDocs< + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TMeta extends {} = {} + >( + actionId: string, + /** Specific Agent IDs to retrieve. default is to retrieve all */ + agentIds?: string[] + ): Promise> { + const responseDocs = await fetchEndpointActionResponses({ + esClient: this.options.esClient, + actionIds: [actionId], + agentIds, + }); + + return responseDocs.reduce>( + (acc, response) => { + const agentId = Array.isArray(response.agent.id) ? response.agent.id[0] : response.agent.id; + + acc[agentId] = response; + + return acc; + }, + {} + ); + } + /** * Provides validations against a response action request and returns the result. * Checks made should be generic to all response actions and not specific to any one action. @@ -499,6 +544,10 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient usageService.notifyUsage(featureKey); } + protected async ensureValidActionId(actionId: string): Promise { + return validateActionId(this.options.esClient, actionId, this.agentType); + } + protected fetchAllPendingActions(): AsyncIterable { const esClient = this.options.esClient; const query: QueryDslQueryContainer = { @@ -653,4 +702,15 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient public async processPendingActions(_: ProcessPendingActionsMethodOptions): Promise { this.log.debug(`#processPendingActions() method is not implemented for ${this.agentType}!`); } + + public async getFileDownload( + actionId: string, + fileId: string + ): Promise { + throw new ResponseActionsClientError(`Method getFileDownload() not implemented`, 501); + } + + public async getFileInfo(actionId: string, fileId: string): Promise { + throw new ResponseActionsClientError(`Method getFileInfo() not implemented`, 501); + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts index 8285450298deaa..9cc7f088c38403 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Readable } from 'stream'; import type { ActionDetails, KillOrSuspendProcessRequestBody, @@ -20,6 +21,7 @@ import type { ResponseActionUploadParameters, EndpointActionData, LogsEndpointActionResponse, + UploadedFileInfo, } from '../../../../../../common/endpoint/types'; import type { IsolationRouteRequestBody, @@ -62,6 +64,12 @@ export interface ProcessPendingActionsMethodOptions { abortSignal: AbortSignal; } +export interface GetFileDownloadMethodResponse { + stream: Readable; + fileName: string; + mimeType?: string; +} + /** * The interface required for a Response Actions provider */ @@ -118,4 +126,18 @@ export interface ResponseActionsClient { * the time of this writing, is being controlled by the background task. */ processPendingActions: (options: ProcessPendingActionsMethodOptions) => Promise; + + /** + * Retrieve a file for download + * @param actionId + * @param fileId + */ + getFileDownload(actionId: string, fileId: string): Promise; + + /** + * Retrieve info about a file + * @param actionId + * @param fileId + */ + getFileInfo(actionId: string, fileId: string): Promise; } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts index 71090d5af98feb..c64b107b86761e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts @@ -66,6 +66,8 @@ const createResponseActionClientMock = (): jest.Mocked => release: jest.fn().mockReturnValue(Promise.resolve()), runningProcesses: jest.fn().mockReturnValue(Promise.resolve()), processPendingActions: jest.fn().mockReturnValue(Promise.resolve()), + getFileInfo: jest.fn().mockReturnValue(Promise.resolve()), + getFileDownload: jest.fn().mockReturnValue(Promise.resolve()), }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts index 9c0e60ec837fef..6fbcb6ff3350b2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts @@ -17,7 +17,10 @@ import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, ENDPOINT_ACTIONS_INDEX, } from '../../../../../../common/endpoint/constants'; -import type { NormalizedExternalConnectorClient } from '../../..'; +import type { + NormalizedExternalConnectorClient, + NormalizedExternalConnectorClientExecuteOptions, +} from '../../..'; import { applyEsClientSearchMock } from '../../../../mocks/utils.mock'; import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../../../../../../common'; import { SentinelOneDataGenerator } from '../../../../../../common/endpoint/data_generators/sentinelone_data_generator'; @@ -27,11 +30,18 @@ import type { LogsEndpointActionResponse, SentinelOneActivityEsDoc, SentinelOneIsolationRequestMeta, + SentinelOneActivityDataForType80, + ResponseActionGetFileOutputContent, + ResponseActionGetFileParameters, + SentinelOneGetFileRequestMeta, } from '../../../../../../common/endpoint/types'; -import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { ResponseActionGetFileRequestBody } from '../../../../../../common/api/endpoint'; -import { SENTINEL_ONE_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/sentinel_one'; import { SUB_ACTION } from '@kbn/stack-connectors-plugin/common/sentinelone/constants'; +import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants'; +import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { Readable } from 'stream'; +import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/constants'; jest.mock('../../action_details_by_id', () => { const originalMod = jest.requireActual('../../action_details_by_id'); @@ -509,7 +519,7 @@ describe('SentinelOneActionsClient class', () => { ], }, }, - size: 1000, + size: ACTIONS_SEARCH_PAGE_SIZE, sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }], }); }); @@ -549,11 +559,172 @@ describe('SentinelOneActionsClient class', () => { ], }, }, - size: 1000, + size: ACTIONS_SEARCH_PAGE_SIZE, sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }], }); }); }); + + describe('for get-file response action', () => { + let actionRequestsSearchResponse: SearchResponse< + LogsEndpointAction + >; + + beforeEach(() => { + const s1DataGenerator = new SentinelOneDataGenerator('seed'); + actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([ + s1DataGenerator.generateActionEsHit< + ResponseActionGetFileParameters, + ResponseActionGetFileOutputContent, + SentinelOneGetFileRequestMeta + >({ + agent: { id: 'agent-uuid-1' }, + EndpointActions: { data: { command: 'get-file' } }, + meta: { + agentId: 's1-agent-a', + agentUUID: 'agent-uuid-1', + hostName: 's1-host-name', + commandBatchUuid: 'batch-111', + activityId: 'activity-222', + }, + }), + ]); + const actionResponsesSearchResponse = s1DataGenerator.toEsSearchResponse< + LogsEndpointActionResponse | EndpointActionResponse + >([]); + const s1ActivitySearchResponse = s1DataGenerator.generateActivityEsSearchResponse([ + s1DataGenerator.generateActivityEsSearchHit({ + sentinel_one: { + activity: { + id: 'activity-222', + data: s1DataGenerator.generateActivityFetchFileResponseData({ + flattened: { + commandBatchUuid: 'batch-111', + }, + }), + agent: { + id: 's1-agent-a', + }, + type: 80, + }, + }, + }), + ]); + + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: ENDPOINT_ACTIONS_INDEX, + response: actionRequestsSearchResponse, + pitUsage: true, + }); + + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: actionResponsesSearchResponse, + }); + + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN, + response: s1ActivitySearchResponse, + }); + }); + + it('should search for S1 activity with correct query', async () => { + await s1ActionsClient.processPendingActions(processPendingActionsOptions); + + expect(classConstructorOptions.esClient.search).toHaveBeenNthCalledWith(4, { + index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN, + size: ACTIONS_SEARCH_PAGE_SIZE, + query: { + bool: { + minimum_should_match: 1, + must: [ + { + term: { + 'sentinel_one.activity.type': 80, + }, + }, + ], + should: [ + { + bool: { + filter: [ + { + term: { + 'sentinel_one.activity.agent.id': 's1-agent-a', + }, + }, + { + term: { + 'sentinel_one.activity.data.flattened.commandBatchUuid': 'batch-111', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + it('should complete action as a failure if no S1 agentId/commandBatchUuid present in action request doc', async () => { + actionRequestsSearchResponse.hits.hits[0]!._source!.meta = { + agentId: 's1-agent-a', + agentUUID: 'agent-uuid-1', + hostName: 's1-host-name', + }; + await s1ActionsClient.processPendingActions(processPendingActionsOptions); + + expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith( + expect.objectContaining({ + error: { + message: + 'Unable to very if action completed. SentinelOne agent id or commandBatchUuid missing on action request document!', + }, + }) + ); + }); + + it('should generate an action success response doc', async () => { + await s1ActionsClient.processPendingActions(processPendingActionsOptions); + + expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({ + '@timestamp': expect.any(String), + EndpointActions: { + action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + completed_at: expect.any(String), + data: { + command: 'get-file', + comment: 'Some description here', + output: { + content: { + code: '', + contents: [], + zip_size: 0, + }, + type: 'json', + }, + }, + input_type: 'sentinel_one', + started_at: expect.any(String), + }, + agent: { + id: 'agent-uuid-1', + }, + error: undefined, + meta: { + activityLogEntryId: 'activity-222', + downloadUrl: '/agents/5173897/uploads/40558796', + elasticDocId: '16ae44fc-4be7-446c-8e8f-a5c082dda918', + createdAt: expect.any(String), + filename: 'file.zip', + }, + }); + }); + }); }); describe('#getFile()', () => { @@ -587,7 +758,7 @@ describe('SentinelOneActionsClient class', () => { subActionParams: { agentUUID: '1-2-3', files: [getFileReqOptions.parameters.path], - zipPassCode: SENTINEL_ONE_ZIP_PASSCODE, + zipPassCode: RESPONSE_ACTIONS_ZIP_PASSCODE.sentinel_one, }, }, }); @@ -769,4 +940,183 @@ describe('SentinelOneActionsClient class', () => { expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalled(); }); }); + + describe('#getFileInfo()', () => { + beforeEach(() => { + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled = + true; + }); + + it('should throw error if feature flag is disabled', async () => { + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled = + false; + + await expect(s1ActionsClient.getFileInfo('acb', '123')).rejects.toThrow( + 'File downloads are not supported for sentinel_one agent type. Feature disabled' + ); + }); + + it('should throw error if action id is not for an agent type of sentinelOne', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: SentinelOneDataGenerator.toEsSearchResponse([]), + }); + + await expect(s1ActionsClient.getFileInfo('abc', '123')).rejects.toThrow( + 'Action id [abc] not found with an agent type of [sentinel_one]' + ); + }); + + it('should return file info with with status of AWAITING_UPLOAD if action is still pending', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: SentinelOneDataGenerator.toEsSearchResponse([]), + }); + + await expect(s1ActionsClient.getFileInfo('abc', '123')).resolves.toEqual({ + actionId: 'abc', + agentId: '123', + agentType: 'sentinel_one', + created: '', + id: '123', + mimeType: '', + name: '', + size: 0, + status: 'AWAITING_UPLOAD', + }); + }); + + it('should return expected file information', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: SentinelOneDataGenerator.toEsSearchResponse([]), + }); + }); + }); + + describe('#getFileDownload()', () => { + let s1DataGenerator: SentinelOneDataGenerator; + + beforeEach(() => { + s1DataGenerator = new SentinelOneDataGenerator('seed'); + + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled = + true; + + const esHit = s1DataGenerator.generateResponseEsHit({ + agent: { id: '123' }, + EndpointActions: { data: { command: 'get-file' } }, + meta: { + activityLogEntryId: 'activity-1', + elasticDocId: 'esdoc-1', + downloadUrl: '/some/url', + createdAt: '2024-05-09', + filename: 'foo.zip', + }, + }); + + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: s1DataGenerator.toEsSearchResponse([esHit]), + }); + + (connectorActionsMock.execute as jest.Mock).mockImplementation( + (options: NormalizedExternalConnectorClientExecuteOptions) => { + if (options.params.subAction === SUB_ACTION.DOWNLOAD_AGENT_FILE) { + return { + data: Readable.from(['test']), + }; + } + } + ); + }); + + it('should throw error if feature flag is disabled', async () => { + // @ts-expect-error updating readonly attribute + classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled = + false; + + await expect(s1ActionsClient.getFileDownload('acb', '123')).rejects.toThrow( + 'File downloads are not supported for sentinel_one agent type. Feature disabled' + ); + }); + + it('should throw error if action id is not for an agent type of sentinelOne', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: SentinelOneDataGenerator.toEsSearchResponse([]), + }); + + await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Action id [abc] not found with an agent type of [sentinel_one]' + ); + }); + + it('should throw error if action is still pending for the given agent id', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: s1DataGenerator.toEsSearchResponse([]), + }); + await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Action ID [abc] for agent ID [abc] is still pending' + ); + }); + + it('should throw error if the action response ES Doc is missing required data', async () => { + applyEsClientSearchMock({ + esClientMock: classConstructorOptions.esClient, + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + response: s1DataGenerator.toEsSearchResponse([ + s1DataGenerator.generateResponseEsHit({ + agent: { id: '123' }, + EndpointActions: { data: { command: 'get-file' } }, + meta: { activityLogEntryId: undefined }, + }), + ]), + }); + + await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]' + ); + }); + + it('should call SentinelOne connector to get file download Readable stream', async () => { + await s1ActionsClient.getFileDownload('abc', '123'); + + expect(connectorActionsMock.execute).toHaveBeenCalledWith({ + params: { + subAction: 'downloadAgentFile', + subActionParams: { + activityId: 'activity-1', + agentUUID: '123', + }, + }, + }); + }); + + it('should throw an error if call to SentinelOne did not return a Readable stream', async () => { + (connectorActionsMock.execute as jest.Mock).mockReturnValue({ data: undefined }); + + await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow( + 'Unable to establish a readable stream for file with SentinelOne' + ); + }); + + it('should return expected data', async () => { + await expect(s1ActionsClient.getFileDownload('abc', '123')).resolves.toEqual({ + stream: expect.any(Readable), + fileName: 'foo.zip', + mimeType: undefined, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts index 89d35353ed1d0a..a757eb16b63bd9 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts @@ -12,17 +12,19 @@ import { import { groupBy } from 'lodash'; import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; import type { - SentinelOneGetAgentsParams, - SentinelOneGetAgentsResponse, SentinelOneGetActivitiesParams, SentinelOneGetActivitiesResponse, + SentinelOneGetAgentsParams, + SentinelOneGetAgentsResponse, + SentinelOneDownloadAgentFileParams, } from '@kbn/stack-connectors-plugin/common/sentinelone/types'; import type { QueryDslQueryContainer, SearchHit, SearchRequest, } from '@elastic/elasticsearch/lib/api/types'; -import { SENTINEL_ONE_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/sentinel_one'; +import type { Readable } from 'stream'; +import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants'; import type { NormalizedExternalConnectorClient, NormalizedExternalConnectorClientExecuteOptions, @@ -31,14 +33,15 @@ import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../../../../../../common'; import { catchAndWrapError } from '../../../../utils'; import type { CommonResponseActionMethodOptions, + GetFileDownloadMethodResponse, ProcessPendingActionsMethodOptions, -} from '../../..'; +} from '../lib/types'; import type { ResponseActionAgentType, ResponseActionsApiCommandNames, } from '../../../../../../common/endpoint/service/response_actions/constants'; import { stringify } from '../../../../utils/stringify'; -import { ResponseActionsClientError } from '../errors'; +import { ResponseActionAgentResponseEsDocNotFound, ResponseActionsClientError } from '../errors'; import type { ActionDetails, EndpointActionDataParameterTypes, @@ -48,10 +51,13 @@ import type { ResponseActionGetFileOutputContent, ResponseActionGetFileParameters, SentinelOneActionRequestCommonMeta, + SentinelOneActivityDataForType80, SentinelOneActivityEsDoc, SentinelOneGetFileRequestMeta, + SentinelOneGetFileResponseMeta, SentinelOneIsolationRequestMeta, SentinelOneIsolationResponseMeta, + UploadedFileInfo, } from '../../../../../../common/endpoint/types'; import type { IsolationRouteRequestBody, @@ -63,6 +69,7 @@ import type { ResponseActionsClientWriteActionRequestToEndpointIndexOptions, } from '../lib/base_response_actions_client'; import { ResponseActionsClientImpl } from '../lib/base_response_actions_client'; +import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/constants'; export type SentinelOneActionsClientOptions = ResponseActionsClientOptions & { connectorActions: NormalizedExternalConnectorClient; @@ -393,7 +400,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { await this.sendAction(SUB_ACTION.FETCH_AGENT_FILES, { agentUUID: actionRequest.endpoint_ids[0], files: [actionRequest.parameters.path], - zipPassCode: SENTINEL_ONE_ZIP_PASSCODE, + zipPassCode: RESPONSE_ACTIONS_ZIP_PASSCODE.sentinel_one, }); } catch (err) { error = err; @@ -460,6 +467,95 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { ).actionDetails; } + async getFileInfo(actionId: string, agentId: string): Promise { + if ( + !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled + ) { + throw new ResponseActionsClientError( + `File downloads are not supported for ${this.agentType} agent type. Feature disabled`, + 400 + ); + } + await this.ensureValidActionId(actionId); + + const fileInfo: UploadedFileInfo = { + actionId, + agentId, + id: agentId, + agentType: this.agentType, + status: 'AWAITING_UPLOAD', + created: '', + name: '', + size: 0, + mimeType: '', + }; + + try { + const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId); + + // Unfortunately, there is no way to determine if a file is still available in SentinelOne without actually + // calling the download API, which would return the following error: + // { "errors":[ { + // "code":4100010, + // "detail":"The requested files do not exist. Fetched files are deleted after 3 days, or earlier if more than 30 files are fetched.", + // "title":"Resource not found" + // } ] } + fileInfo.status = 'READY'; + fileInfo.created = agentResponse.meta?.createdAt ?? ''; + fileInfo.name = agentResponse.meta?.filename ?? ''; + fileInfo.mimeType = 'application/octet-stream'; + } catch (e) { + // Ignore "no response doc" error for the agent and just return the file info with the status of 'AWAITING_UPLOAD' + if (!(e instanceof ResponseActionAgentResponseEsDocNotFound)) { + throw e; + } + } + + return fileInfo; + } + + async getFileDownload(actionId: string, agentId: string): Promise { + if ( + !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled + ) { + throw new ResponseActionsClientError( + `File downloads are not supported for ${this.agentType} agent type. Feature disabled`, + 400 + ); + } + + await this.ensureValidActionId(actionId); + + const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId); + + if (!agentResponse.meta?.activityLogEntryId) { + throw new ResponseActionsClientError( + `Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]` + ); + } + + const downloadAgentFileMethodOptions: SentinelOneDownloadAgentFileParams = { + agentUUID: agentId, + activityId: agentResponse.meta?.activityLogEntryId, + }; + const { data } = await this.sendAction( + SUB_ACTION.DOWNLOAD_AGENT_FILE, + downloadAgentFileMethodOptions + ); + + if (!data) { + throw new ResponseActionsClientError( + `Unable to establish a readable stream for file with SentinelOne` + ); + } + + return { + stream: data, + fileName: agentResponse.meta.filename, + mimeType: undefined, + }; + } + async processPendingActions({ abortSignal, addToQueue, @@ -495,11 +591,58 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { } } break; + + case 'get-file': + { + const responseDocsForGetFile = await this.checkPendingGetFileActions( + typePendingActions as Array< + LogsEndpointAction< + ResponseActionGetFileParameters, + ResponseActionGetFileOutputContent, + SentinelOneGetFileRequestMeta + > + > + ); + if (responseDocsForGetFile.length) { + addToQueue(...responseDocsForGetFile); + } + } + break; } } } } + private async fetchGetFileResponseEsDocForAgentId( + actionId: string, + agentId: string + ): Promise< + LogsEndpointActionResponse + > { + const agentResponse = ( + await this.fetchActionResponseEsDocs< + ResponseActionGetFileOutputContent, + SentinelOneGetFileResponseMeta + >(actionId, [agentId]) + )[agentId]; + + if (!agentResponse) { + throw new ResponseActionAgentResponseEsDocNotFound( + `Action ID [${actionId}] for agent ID [${actionId}] is still pending`, + 404 + ); + } + + if (agentResponse.EndpointActions.data.command !== 'get-file') { + throw new ResponseActionsClientError( + `Invalid action ID [${actionId}] - Not a get-file action: [${agentResponse.EndpointActions.data.command}]`, + 400 + ); + } + + return agentResponse; + } + /** * Checks if the provided Isolate or Unisolate actions are complete and if so, then it builds the Response * document for them and returns it. (NOTE: the response is NOT written to ES - only returned) @@ -617,7 +760,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { // due to use of `collapse _source: false, sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }], - size: 1000, + size: ACTIONS_SEARCH_PAGE_SIZE, }; this.log.debug( @@ -691,4 +834,180 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl { return completedResponses; } + + private async checkPendingGetFileActions( + actionRequests: Array< + LogsEndpointAction< + ResponseActionGetFileParameters, + ResponseActionGetFileOutputContent, + SentinelOneGetFileRequestMeta + > + > + ): Promise { + const warnings: string[] = []; + const completedResponses: LogsEndpointActionResponse[] = []; + const actionsByAgentAndBatchId: { + [agentIdAndCommandBatchUuid: string]: LogsEndpointAction< + ResponseActionGetFileParameters, + ResponseActionGetFileOutputContent, + SentinelOneGetFileRequestMeta + >; + } = {}; + // Utility to create the key to lookup items in the `actionByAgentAndBatchId` grouping above + const getLookupKey = (agentId: string, commandBatchUuid: string): string => + `${agentId}:${commandBatchUuid}`; + const searchRequestOptions: SearchRequest = { + index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN, + size: ACTIONS_SEARCH_PAGE_SIZE, + query: { + bool: { + must: [ + { + term: { + // Activity Types can be retrieved from S1 via API: `/web/api/v2.1/activities/types` + // { + // "action": "Agent Uploaded Fetched Files", + // "descriptionTemplate": "Agent {{ computer_name }} ({{ external_ip }}) successfully uploaded {{ filename }}.", + // "id": 80 + // }, + 'sentinel_one.activity.type': 80, + }, + }, + ], + should: actionRequests.reduce((acc, action) => { + const s1AgentId = action.meta?.agentId; + const s1CommandBatchUUID = action.meta?.commandBatchUuid; + + if (s1AgentId && s1CommandBatchUUID) { + actionsByAgentAndBatchId[getLookupKey(s1AgentId, s1CommandBatchUUID)] = action; + + acc.push({ + bool: { + filter: [ + { term: { 'sentinel_one.activity.agent.id': s1AgentId } }, + { + term: { + 'sentinel_one.activity.data.flattened.commandBatchUuid': s1CommandBatchUUID, + }, + }, + ], + }, + }); + } else { + // This is an edge case and should never happen. But just in case :-) + warnings.push( + `get-file response action ID [${action.EndpointActions.action_id}] missing SentinelOne agent ID or commandBatchUuid value(s). Unable to check on it's status - forcing it to complete as a failure.` + ); + + completedResponses.push( + this.buildActionResponseEsDoc<{}, {}>({ + actionId: action.EndpointActions.action_id, + agentId: Array.isArray(action.agent.id) ? action.agent.id[0] : action.agent.id, + data: { command: 'get-file' }, + error: { + message: `Unable to very if action completed. SentinelOne agent id or commandBatchUuid missing on action request document!`, + }, + }) + ); + } + + return acc; + }, [] as QueryDslQueryContainer[]), + minimum_should_match: 1, + }, + }, + }; + + if (Object.keys(actionsByAgentAndBatchId).length) { + this.log.debug( + `searching for get-file responses from [${SENTINEL_ONE_ACTIVITY_INDEX_PATTERN}] index with:\n${stringify( + searchRequestOptions, + 15 + )}` + ); + + const searchResults = await this.options.esClient + .search>(searchRequestOptions) + .catch(catchAndWrapError); + + this.log.debug( + `Search results for SentinelOne get-file activity documents:\n${stringify(searchResults)}` + ); + + for (const s1Hit of searchResults.hits.hits) { + const s1ActivityDoc = s1Hit._source; + const s1AgentId = s1ActivityDoc?.sentinel_one.activity.agent.id; + const s1CommandBatchUuid = + s1ActivityDoc?.sentinel_one.activity.data.flattened.commandBatchUuid ?? ''; + const activityLogEntryId = s1ActivityDoc?.sentinel_one.activity.id ?? ''; + + if (s1AgentId && s1CommandBatchUuid) { + const actionRequest = + actionsByAgentAndBatchId[getLookupKey(s1AgentId, s1CommandBatchUuid)]; + + if (actionRequest) { + const downloadUrl = s1ActivityDoc?.sentinel_one.activity.data.downloaded.url ?? ''; + const error = !downloadUrl + ? { + message: `File retrieval failed (No download URL defined in SentinelOne activity log id [${activityLogEntryId}])`, + } + : undefined; + + completedResponses.push( + this.buildActionResponseEsDoc< + ResponseActionGetFileOutputContent, + SentinelOneGetFileResponseMeta + >({ + actionId: actionRequest.EndpointActions.action_id, + agentId: Array.isArray(actionRequest.agent.id) + ? actionRequest.agent.id[0] + : actionRequest.agent.id, + data: { + command: 'get-file', + comment: s1ActivityDoc?.sentinel_one.activity.description.primary ?? '', + output: { + type: 'json', + content: { + // code applies only to Endpoint agents + code: '', + // We don't know the file size for S1 retrieved files + zip_size: 0, + // We don't have the contents of the zip file for S1 + contents: [], + }, + }, + }, + error, + meta: { + activityLogEntryId, + elasticDocId: s1Hit._id, + downloadUrl, + createdAt: s1ActivityDoc?.sentinel_one.activity.updated_at ?? '', + filename: s1ActivityDoc?.sentinel_one.activity.data.flattened.filename ?? '', + }, + }) + ); + } else { + warnings.push( + `Activity log entry ${s1Hit._id} was a matched, but no action request for it (should not happen)` + ); + } + } + } + } else { + this.log.debug(`Nothing to search for. No pending get-file actions`); + } + + this.log.debug( + `${completedResponses.length} get-file action responses generated:\n${stringify( + completedResponses + )}` + ); + + if (warnings.length > 0) { + this.log.warn(warnings.join('\n')); + } + + return completedResponses; + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts index e9f0ed89eb2ac7..1490da7b018a09 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts @@ -9,5 +9,5 @@ export * from './actions'; export { getActionDetailsById } from './action_details_by_id'; export { getActionList, getActionListByStatus } from './action_list'; export { getPendingActionsSummary } from './pending_actions_summary'; -export { validateActionId } from './validate_action_id'; export * from './clients'; +export * from './utils'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts similarity index 96% rename from x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts index f6cde845fc8299..6c366142adfb9b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { applyActionListEsSearchMock } from './mocks'; +import { applyActionListEsSearchMock } from '../mocks'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { fetchActionResponses } from './fetch_action_responses'; -import { BaseDataGenerator } from '../../../../common/endpoint/data_generators/base_data_generator'; +import { BaseDataGenerator } from '../../../../../common/endpoint/data_generators/base_data_generator'; import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; -import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants'; -import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; +import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../../common/endpoint/constants'; +import { ACTIONS_SEARCH_PAGE_SIZE } from '../constants'; describe('fetchActionResponses()', () => { let esClientMock: ElasticsearchClientMock; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts similarity index 50% rename from x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts index 7ed0bad9a42be9..eb49c6c67216e8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts @@ -11,10 +11,11 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { EndpointActionResponse, LogsEndpointActionResponse, -} from '../../../../common/endpoint/types'; -import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; -import { catchAndWrapError } from '../../utils'; -import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants'; + EndpointActionResponseDataOutput, +} from '../../../../../common/endpoint/types'; +import { ACTIONS_SEARCH_PAGE_SIZE } from '../constants'; +import { catchAndWrapError } from '../../../utils'; +import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../../common/endpoint/constants'; interface FetchActionResponsesOptions { esClient: ElasticsearchClient; @@ -28,28 +29,35 @@ interface FetchActionResponsesResult { data: Array>; } +/** @private */ +const buildSearchQuery = ( + actionIds: string[] = [], + agentIds: string[] = [] +): estypes.QueryDslQueryContainer => { + const filter: estypes.QueryDslQueryContainer[] = []; + const query: estypes.QueryDslQueryContainer = { bool: { filter } }; + + if (agentIds?.length) { + filter.push({ terms: { agent_id: agentIds } }); + } + if (actionIds?.length) { + filter.push({ terms: { action_id: actionIds } }); + } + + return query; +}; + /** - * Fetch Response Action responses + * Fetch Response Action responses from both the Endpoint and the Fleet indexes */ export const fetchActionResponses = async ({ esClient, actionIds = [], agentIds = [], }: FetchActionResponsesOptions): Promise => { - const filter = []; - - if (agentIds?.length) { - filter.push({ terms: { agent_id: agentIds } }); - } - if (actionIds.length) { - filter.push({ terms: { action_id: actionIds } }); - } + const query = buildSearchQuery(actionIds, agentIds); - const query: estypes.QueryDslQueryContainer = { - bool: { - filter, - }, - }; + // TODO:PT refactor this method to use new `fetchFleetActionResponses()` and `fetchEndpointActionResponses()` // Get the Action Response(s) from both the Fleet action response index and the Endpoint // action response index. @@ -87,3 +95,64 @@ export const fetchActionResponses = async ({ data: [...(fleetResponses?.hits?.hits ?? []), ...(endpointResponses?.hits?.hits ?? [])], }; }; + +/** + * Fetch Response Action response documents from the Endpoint index + * @param esClient + * @param actionIds + * @param agentIds + */ +export const fetchEndpointActionResponses = async < + TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput, + TResponseMeta extends {} = {} +>({ + esClient, + actionIds, + agentIds, +}: FetchActionResponsesOptions): Promise< + Array> +> => { + const searchResponse = await esClient + .search>( + { + index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + size: ACTIONS_SEARCH_PAGE_SIZE, + query: buildSearchQuery(actionIds, agentIds), + }, + { ignore: [404] } + ) + .catch(catchAndWrapError); + + return searchResponse.hits.hits.map((esHit) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return esHit._source!; + }); +}; + +/** + * Fetch Response Action response documents from the Fleet index + * @param esClient + * @param actionIds + * @param agentIds + */ +export const fetchFleetActionResponses = async ({ + esClient, + actionIds, + agentIds, +}: FetchActionResponsesOptions): Promise => { + const searchResponse = await esClient + .search( + { + index: AGENT_ACTIONS_RESULTS_INDEX, + size: ACTIONS_SEARCH_PAGE_SIZE, + query: buildSearchQuery(actionIds, agentIds), + }, + { ignore: [404] } + ) + .catch(catchAndWrapError); + + return searchResponse.hits.hits.map((esHit) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return esHit._source!; + }); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts new file mode 100644 index 00000000000000..2d933b2c1d8f78 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { getActionAgentType } from './get_action_agent_type'; +import { applyEsClientSearchMock } from '../../../mocks/utils.mock'; +import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; + +describe('getActionAgentType()', () => { + let esClientMock: ElasticsearchClientMock; + + beforeEach(() => { + esClientMock = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; + }); + + it('should throw error if action is not found', async () => { + await expect(getActionAgentType(esClientMock, '123')).rejects.toThrow( + 'Action id [123] not found' + ); + }); + + it('should return agent type', async () => { + const generator = new EndpointActionGenerator('seed'); + + applyEsClientSearchMock({ + esClientMock, + index: ENDPOINT_ACTIONS_INDEX, + response: EndpointActionGenerator.toEsSearchResponse([generator.generateActionEsHit()]), + }); + + await expect(getActionAgentType(esClientMock, '123')).resolves.toEqual({ + agentType: 'endpoint', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts new file mode 100644 index 00000000000000..f87deeb729660e --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts @@ -0,0 +1,42 @@ +/* + * 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 type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { LogsEndpointAction } from '../../../../../common/endpoint/types'; +import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants'; +import { catchAndWrapError } from '../../../utils'; +import { NotFoundError } from '../../../errors'; +import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants'; + +/** + * Returns the `agentType` for a given response action + */ +export const getActionAgentType = async ( + esClient: ElasticsearchClient, + actionId: string +): Promise<{ agentType: ResponseActionAgentType }> => { + const response = await esClient + .search({ + index: ENDPOINT_ACTIONS_INDEX, + body: { + query: { + bool: { + filter: [{ term: { action_id: actionId } }], + }, + }, + }, + _source: ['EndpointActions.input_type'], + size: 1, + }) + .catch(catchAndWrapError); + + if (!response?.hits?.hits[0]._source?.EndpointActions.input_type) { + throw new NotFoundError(`Action id [${actionId}] not found`, response); + } + + return { agentType: response.hits.hits[0]._source.EndpointActions.input_type }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts new file mode 100644 index 00000000000000..354086031b3dd0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from './utils'; +export * from './fetch_action_responses'; +export * from './validate_action_id'; +export * from './get_action_agent_type'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts similarity index 98% rename from x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts index e63caeb222be8c..a2e69696b557cb 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts @@ -6,8 +6,8 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; -import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; import type { NormalizedActionRequest } from './utils'; import { categorizeActionResults, @@ -31,11 +31,11 @@ import type { LogsEndpointAction, LogsEndpointActionResponse, EndpointActionResponseDataOutput, -} from '../../../../common/endpoint/types'; +} from '../../../../../common/endpoint/types'; import { v4 as uuidv4 } from 'uuid'; -import type { Results } from '../../routes/actions/mocks'; -import { mockAuditLogSearchResult } from '../../routes/actions/mocks'; -import { ActivityLogItemTypes } from '../../../../common/endpoint/types'; +import type { Results } from '../../../routes/actions/mocks'; +import { mockAuditLogSearchResult } from '../../../routes/actions/mocks'; +import { ActivityLogItemTypes } from '../../../../../common/endpoint/types'; describe('When using Actions service utilities', () => { let fleetActionGenerator: FleetActionGenerator; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts similarity index 98% rename from x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts index 856c1ede8b218e..1c64d1f59a0621 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts @@ -13,12 +13,12 @@ import { i18n } from '@kbn/i18n'; import type { ResponseActionAgentType, ResponseActionsApiCommandNames, -} from '../../../../common/endpoint/service/response_actions/constants'; +} from '../../../../../common/endpoint/service/response_actions/constants'; import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_DS, failedFleetActionErrorCode, -} from '../../../../common/endpoint/constants'; +} from '../../../../../common/endpoint/constants'; import type { ActionDetails, ActivityLogAction, @@ -33,9 +33,9 @@ import type { LogsEndpointAction, LogsEndpointActionResponse, WithAllKeys, -} from '../../../../common/endpoint/types'; -import { ActivityLogItemTypes } from '../../../../common/endpoint/types'; -import type { EndpointMetadataService } from '../metadata'; +} from '../../../../../common/endpoint/types'; +import { ActivityLogItemTypes } from '../../../../../common/endpoint/types'; +import type { EndpointMetadataService } from '../../metadata'; /** * Type guard to check if a given Action is in the shape of the Endpoint Action. diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts similarity index 59% rename from x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts index 8ba75deb297977..91027522365205 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts @@ -7,10 +7,11 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; -import { NotFoundError } from '../../errors'; -import { catchAndWrapError } from '../../utils'; -import type { LogsEndpointAction } from '../../../../common/endpoint/types'; -import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants'; +import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants'; +import { NotFoundError } from '../../../errors'; +import { catchAndWrapError } from '../../../utils'; +import type { LogsEndpointAction } from '../../../../../common/endpoint/types'; +import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants'; /** * Validates that a given action ID is a valid Endpoint action @@ -19,7 +20,8 @@ import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants'; */ export const validateActionId = async ( esClient: ElasticsearchClient, - actionId: string + actionId: string, + agentType?: ResponseActionAgentType ): Promise => { const response = await esClient .search({ @@ -29,17 +31,23 @@ export const validateActionId = async ( bool: { filter: [ { term: { action_id: actionId } }, - { term: { input_type: 'endpoint' } }, { term: { type: 'INPUT_ACTION' } }, + ...(agentType ? [{ term: { 'EndpointActions.input_type': agentType } }] : []), ], }, }, }, _source: false, + size: 1, }) .catch(catchAndWrapError); if (!(response.hits?.total as SearchTotalHits)?.value) { - throw new NotFoundError(`Action id [${actionId}] not found`, response); + throw new NotFoundError( + `Action id [${actionId}] not found${ + agentType ? ` with an agent type of [${agentType}]` : '' + }`, + response + ); } }; diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap b/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap index a31d1d0f176e65..b8e043638fdee3 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap @@ -33,7 +33,6 @@ Object { "package": Object { "name": "system", "title": "System", - "version": "1.56.0", }, "revision": 1, "updated_by": "elastic", diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 5c64bec5623c7b..3d13f43531f3fc 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -1356,8 +1356,12 @@ export default function (providerContext: FtrProviderContext) { created_at: ppcreatedAt, updated_at: ppupdatedAt, version, + package: { version: pkgVersion, ...pkgRest }, ...ppRest - }: any) => ppRest + }: any) => ({ + ...ppRest, + package: pkgRest, + }) ), }).toMatch(); });