From 19818f2c7f469924c8d52cab7c545b256e6f0b1b Mon Sep 17 00:00:00 2001 From: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> Date: Wed, 15 May 2024 15:13:20 +0100 Subject: [PATCH 01/11] [Console Migration] Add action for auto indentation (#181613) Closes https://github.com/elastic/kibana/issues/180213 ## Summary This PR adds an action for applying indentation to the selected requests in the Console Monaco editor. https://github.com/elastic/kibana/assets/59341489/307af12b-6f65-4859-87bb-e1b4bf1cc331 Note: This PR doesn't auto-indent requests that contain comments. This will be part of a follow-up work (https://github.com/elastic/kibana/issues/182138). --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../editor/monaco/monaco_editor.tsx | 6 +- .../monaco/monaco_editor_actions_provider.ts | 56 +++++ .../containers/editor/monaco/utils/index.ts | 1 + .../monaco/utils/requests_utils.test.ts | 192 ++++++++++++++++++ .../editor/monaco/utils/requests_utils.ts | 62 ++++++ 5 files changed, 316 insertions(+), 1 deletion(-) 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'); +}; From d1806c979599f7d18cd0e7a9a9766feeb3a41aab Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 15 May 2024 17:50:07 +0300 Subject: [PATCH 02/11] [Alerting] Change logger level to debug when delete a rule without alerts produce an error or warn when untracking alerts (#183207) ## Summary Fixes: https://github.com/elastic/kibana/issues/182754 ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/set_alerts_to_untracked.test.ts | 97 +++++++++++++++++++ .../lib/set_alerts_to_untracked.ts | 12 ++- .../bulk_untrack/bulk_untrack_alerts.test.ts | 59 +++++++++++ .../bulk_untrack/bulk_untrack_alerts.ts | 25 +++-- .../rules_client/lib/untrack_rule_alerts.ts | 2 + x-pack/plugins/alerting/tsconfig.json | 1 + 6 files changed, 188 insertions(+), 8 deletions(-) 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/**/*" From 47cdb1ae6ed5b605abf17952eeeed536b6e5b022 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 15 May 2024 10:54:04 -0400 Subject: [PATCH 03/11] skip failing test suite (#183529) --- .../fleet_api_integration/apis/agent_policy/agent_policy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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..ab6701b6cf2c41 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 @@ -35,7 +35,8 @@ export default function (providerContext: FtrProviderContext) { return getPkgRes; }; - describe('fleet_agent_policies', () => { + // Failing: See https://github.com/elastic/kibana/issues/183529 + describe.skip('fleet_agent_policies', () => { skipIfNoDockerRegistry(providerContext); describe('GET /api/fleet/agent_policies', () => { From 3e9a8086d7652f0e62a91de6dba7fed8f1a37671 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 15 May 2024 17:28:14 +0200 Subject: [PATCH 04/11] [SLOs] Filter out stale SLOs by default from overview (#182745) ## Summary Fixes https://github.com/elastic/kibana/issues/176712 Added a way to define threshold to declare SLOs stale state , for now i have chosen if it hasn't been updated in 48 hours, need product feedback on this image Added an overview panel to indicate how many SLOs are in stale state , user can click on each EuiState component to filter SLOs by image Also added Alerts overview , clicking will take user to alerts or rules page, pref filtered with burn rate image --- .../src/rest_specs/routes/find.ts | 2 + .../src/rest_specs/routes/get_overview.ts | 35 ++++ .../kbn-slo-schema/src/schema/common.ts | 15 +- .../kbn-slo-schema/src/schema/settings.ts | 5 +- .../slo/common/constants.ts | 3 + .../slo/common/summary_indices.test.ts | 8 +- .../slo/paths/s@{spaceid}@api@slos.yaml | 5 + .../slo/public/hooks/query_key_factory.ts | 7 + .../slo/public/hooks/use_alerts_url.ts | 24 +++ .../slo/public/hooks/use_fetch_slo_list.ts | 1 + .../pages/slo_settings/settings_form.tsx | 53 +++++- .../pages/slo_settings/use_get_settings.ts | 2 + .../components/card_view/slo_card_item.tsx | 13 +- .../pages/slos/components/slo_list_empty.tsx | 22 ++- .../slos_overview/overview_item.tsx | 56 +++++++ .../slos_overview/slo_overview_alerts.tsx | 96 +++++++++++ .../slos_overview/slos_overview.tsx | 127 +++++++++++++++ .../slos/hooks/use_fetch_slos_overview.ts | 108 +++++++++++++ .../slo/public/pages/slos/slos.tsx | 6 +- .../slo/server/plugin.ts | 10 +- .../slo/server/routes/register_routes.ts | 8 +- .../slo/server/routes/slo/route.ts | 34 ++++ .../summary_search_client.test.ts.snap | 6 + .../slo/server/services/find_slo.test.ts | 2 + .../slo/server/services/find_slo.ts | 3 +- .../slo/server/services/find_slo_groups.ts | 6 +- .../slo/server/services/get_slos_overview.ts | 153 ++++++++++++++++++ .../slo/server/services/slo_settings.ts | 19 ++- .../services/summary_search_client.test.ts | 113 +++++++++++++ .../server/services/summary_search_client.ts | 43 ++++- 30 files changed, 952 insertions(+), 33 deletions(-) create mode 100644 x-pack/packages/kbn-slo-schema/src/rest_specs/routes/get_overview.ts create mode 100644 x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts create mode 100644 x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx create mode 100644 x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx create mode 100644 x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx create mode 100644 x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts create mode 100644 x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts 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/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/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/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/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]; From d982bae6ffc0d802e1421252e3c0fd7ec192f4c2 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Wed, 15 May 2024 18:30:37 +0300 Subject: [PATCH 05/11] fix: [Obs Discover][KEYBOARD]: Tooltips in the datagrid header must be keyboard accessible (#183270) Closes: https://github.com/elastic/observability-dev/issues/3353 ## Summary ## Description The Obs Discover view has a datagrid with four (or more) tooltips that cannot be reached by keyboard. These elements can be helpful to users to understand what the column represents, and must be available to all users. Screenshot attached below. This issue encompasses both the **Discover** and **Logs Explorer** tabs. There are 4 unique views (2 tabs, 2 sub-tabs each) that have the same issue. I'm consolidating for speed, but please let me know if you'd prefer they be broken out to related issues. ### Steps to recreate 1. Open the [Obs Serverless Discover](https://keepserverless-qa-oblt-b4ba07.kb.eu-west-1.aws.qa.elastic.cloud/app/observability-logs-explorer/) view 2. Click somewhere close to the datagrid to avoid having to tab through the entire page 3. Press `Tab` until the datagrid has focus 4. Use the `arrow` keys to traverse the data grid header row 5. Verify the tooltips cannot be reached by`arrow` or `Tab` keypress ### What was done?: 1. Removed `HoverPopover`. The logic might not be suitable for use outside of the `DataGrid` column. 2. Replaced `EuiToolTip` with `EuiIconTip` for text-based popovers. 3. Refactored `TooltipButton` to properly handle keyboard navigation. ### Screen https://github.com/elastic/kibana/assets/20072247/9ac9bc70-075f-41f6-a5a5-4e30a385b1c9 --- .../components/data_table_column_header.tsx | 10 ++- test/functional/services/data_grid.ts | 2 +- .../data_visualizer_stats_table.tsx | 21 +++--- .../components/common/hover_popover.tsx | 58 --------------- .../actions_column_tooltip.tsx | 10 +-- .../content_column_tooltip.tsx | 17 ++--- .../resource_column_tooltip.tsx | 17 ++--- .../column_tooltips/tooltip_button.tsx | 70 +++++++++++++++++-- 8 files changed, 91 insertions(+), 114 deletions(-) delete mode 100644 x-pack/plugins/observability_solution/logs_explorer/public/components/common/hover_popover.tsx 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/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/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/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} + + + ); +}; From 90e466ac00a5d1965e6fbbd181d822ec5ca27c94 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 15 May 2024 09:20:31 -0700 Subject: [PATCH 06/11] [ObservabilitySolution] Remove usage of deprecated modules, Part II (#182195) ## Summary Partially addresses https://github.com/elastic/kibana-team/issues/805 These changes come up from searching in the code and finding where certain kinds of deprecated AppEx-SharedUX modules are imported. **Reviewers: Please interact with critical paths through the UI components touched in this PR, ESPECIALLY in terms of testing dark mode and i18n.** This is the **2nd** PR to focus on code within **Observability**, following https://github.com/elastic/kibana/pull/180844. image Note: this also makes inclusion of `i18n` and `analytics` dependencies consistent. Analytics is an optional dependency for the SharedUX modules, which wrap `KibanaErrorBoundaryProvider` and is designed to capture telemetry about errors that are caught in the error boundary. ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../apm/public/application/index.tsx | 35 ++--- .../observability_solution/apm/tsconfig.json | 4 +- .../infra/public/apps/common_providers.tsx | 30 ++--- .../infra/public/apps/legacy_app.tsx | 124 +++++++++--------- .../infra/public/plugin.ts | 3 +- .../test_utils/use_global_storybook_theme.tsx | 4 +- .../infra/tsconfig.json | 4 +- .../test_utils/use_global_storybook_theme.tsx | 4 +- .../logs_shared/tsconfig.json | 3 +- .../public/apps/common_providers.tsx | 17 ++- .../create_lazy_container_metrics_table.tsx | 1 - .../host/create_lazy_host_metrics_table.tsx | 1 - .../pod/create_lazy_pod_metrics_table.tsx | 1 - .../test_helpers.ts | 1 - .../test_utils/use_global_storybook_theme.tsx | 4 +- .../metrics_data_access/tsconfig.json | 4 +- .../public/application/index.tsx | 31 ++--- .../components/header_menu/header_menu.tsx | 1 + .../test_utils/use_global_storybook_theme.tsx | 4 +- .../observability/tsconfig.json | 3 +- .../public/application/app.tsx | 57 ++++---- .../observability_onboarding/tsconfig.json | 4 +- .../header_menu/header_menu_portal.tsx | 1 + .../slo/public/application.tsx | 29 ++-- .../slo/public/context/plugin_context.tsx | 8 +- .../slo/alerts/handle_explicit_input.tsx | 2 +- .../error_budget_open_configuration.tsx | 2 +- .../slo/overview/slo_embeddable_factory.tsx | 6 +- .../slo_overview_open_configuration.tsx | 2 +- .../slo/public/hooks/use_plugin_context.tsx | 9 +- .../shared_flyout/get_create_slo_flyout.tsx | 8 +- .../slo/public/plugin.ts | 7 +- .../observability_solution/slo/tsconfig.json | 1 + 33 files changed, 217 insertions(+), 198 deletions(-) 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_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/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/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/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/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" ] } From cd3e684a895427652c9bea635c635905064e0b4c Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 15 May 2024 12:22:51 -0400 Subject: [PATCH 07/11] [Security Solution][Endpoint] Complete pending `get-file` response actions for SentinelOne + show file download link in UI (#181766) ## Summary - Adds logic to the SentinelOne Response Actions client for completing pending `get-file` actions (using SentinelOne `activities` data ingested via the integration) - Adds support to the "file info" and "file download" APIs for 3rd party agent types - Changes required the base class for response action client to have two new methods: `getFileInfo()` and `getFileDownload()` - Updates the UI `get-file` response component to property show the applicable zip file passcode based on the action's agent type --- .../sentinelone_data_generator.ts | 53 ++- .../service/response_actions/constants.ts | 10 + .../get_file_download_id.test.ts | 50 +++ .../response_actions/get_file_download_id.ts | 8 +- .../service/response_actions/sentinel_one.ts | 6 - .../common/endpoint/types/actions.ts | 1 + .../common/endpoint/types/sentinel_one.ts | 54 ++- ...esponse_action_file_download_link.test.tsx | 3 +- .../response_action_file_download_link.tsx | 15 +- .../mocks/response_actions_http_mocks.ts | 1 + .../actions/file_download_handler.test.ts | 70 ++-- .../routes/actions/file_download_handler.ts | 33 +- .../routes/actions/file_info_handler.test.ts | 69 ++-- .../routes/actions/file_info_handler.ts | 45 +-- .../services/actions/action_details_by_id.ts | 2 +- .../endpoint/services/actions/action_list.ts | 2 +- .../endpoint/endpoint_actions_client.test.ts | 75 +++- .../endpoint/endpoint_actions_client.ts | 54 ++- .../services/actions/clients/errors.ts | 2 + .../lib/base_response_actions_client.test.ts | 11 + .../lib/base_response_actions_client.ts | 62 ++- .../services/actions/clients/lib/types.ts | 22 ++ .../services/actions/clients/mocks.ts | 2 + .../sentinel_one_actions_client.test.ts | 362 +++++++++++++++++- .../sentinel_one_actions_client.ts | 333 +++++++++++++++- .../server/endpoint/services/actions/index.ts | 2 +- .../fetch_action_responses.test.ts | 8 +- .../{ => utils}/fetch_action_responses.ts | 105 ++++- .../utils/get_action_agent_type.test.ts | 41 ++ .../actions/utils/get_action_agent_type.ts | 42 ++ .../endpoint/services/actions/utils/index.ts | 11 + .../actions/{ => utils}/utils.test.ts | 12 +- .../services/actions/{ => utils}/utils.ts | 10 +- .../actions/{ => utils}/validate_action_id.ts | 22 +- 34 files changed, 1388 insertions(+), 210 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts rename x-pack/plugins/security_solution/server/endpoint/services/actions/{ => utils}/fetch_action_responses.test.ts (96%) rename x-pack/plugins/security_solution/server/endpoint/services/actions/{ => utils}/fetch_action_responses.ts (50%) create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts rename x-pack/plugins/security_solution/server/endpoint/services/actions/{ => utils}/utils.test.ts (98%) rename x-pack/plugins/security_solution/server/endpoint/services/actions/{ => utils}/utils.ts (98%) rename x-pack/plugins/security_solution/server/endpoint/services/actions/{ => utils}/validate_action_id.ts (59%) 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 + ); } }; From ba3b2fd643e2b234c9ee318177e64ea061594f9b Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 15 May 2024 09:29:05 -0700 Subject: [PATCH 08/11] [UII] Fix overly-verbose snapshots in agent policy test suite (#183535) ## Summary Resolves https://github.com/elastic/kibana/issues/183529. As the title says :) --- .../apis/agent_policy/__snapshots__/agent_policy.snap | 1 - .../apis/agent_policy/agent_policy.ts | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 ab6701b6cf2c41..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 @@ -35,8 +35,7 @@ export default function (providerContext: FtrProviderContext) { return getPkgRes; }; - // Failing: See https://github.com/elastic/kibana/issues/183529 - describe.skip('fleet_agent_policies', () => { + describe('fleet_agent_policies', () => { skipIfNoDockerRegistry(providerContext); describe('GET /api/fleet/agent_policies', () => { @@ -1357,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(); }); From fe6397bfed344403674ff728226c26483d35d023 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 15 May 2024 12:01:42 -0500 Subject: [PATCH 09/11] [artifacts/serverless] Fix artifact upload (#183506) `kibana` should be `kibana-serverless`. Also uploads the docker context. --- .buildkite/scripts/steps/artifacts/docker_image.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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" From ed87ecb5f80f0aeab42fe8db9e8de04eecd1b270 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 15 May 2024 18:16:44 +0100 Subject: [PATCH 10/11] [ML] Adding bucket span validation to job creation flyouts (#183510) Fixes https://github.com/elastic/kibana/issues/183455 ![image](https://github.com/elastic/kibana/assets/22172091/34279d8a-bb83-468c-b350-f7030f51ca8b) --- .../public/embeddables/job_creation/common/job_details.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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); }, From 5e95a7679681467b763b106ee5a03d0af643c8bc Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Wed, 15 May 2024 19:26:44 +0200 Subject: [PATCH 11/11] [CI] Use new-infra-type agent targeting for chainguard build (#183545) ## Summary This PR ties https://github.com/elastic/kibana/pull/183200 + https://github.com/elastic/kibana/pull/182582 together --- .buildkite/pipelines/artifacts.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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: