diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 4db97c1b9e256..b85572e650f26 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -350,19 +350,19 @@ export class DocLinksService { anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-overview.html`, anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`, - anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, - anomalyDetectionCardinality: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, - anomalyDetectionCreateJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, - anomalyDetectionDetectors: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, - anomalyDetectionInfluencers: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, + anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-bucket-span`, + anomalyDetectionCardinality: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-cardinality`, + anomalyDetectionCreateJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-create-job`, + anomalyDetectionDetectors: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-detectors`, + anomalyDetectionInfluencers: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-influencers`, anomalyDetectionJobResource: `${ELASTICSEARCH_DOCS}ml-put-job.html#ml-put-job-path-parms`, anomalyDetectionJobResourceAnalysisConfig: `${ELASTICSEARCH_DOCS}ml-put-job.html#put-analysisconfig`, - anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, + anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-job-tips`, alertingRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-alerts.html`, - anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, - calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, + anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-model-memory-limits`, + calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-calendars`, classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-classification.html#ml-dfanalytics-classification-evaluation`, - customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html`, + customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-run-jobs.html#ml-ad-rules`, customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 54a48685275e6..896ed6b0bb536 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -17,6 +17,7 @@ export const storybookAliases = { dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', dashboard: 'src/plugins/dashboard/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', + discover: 'src/plugins/discover/.storybook', embeddable: 'src/plugins/embeddable/.storybook', expression_error: 'src/plugins/expression_error/.storybook', expression_image: 'src/plugins/expression_image/.storybook', diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts index 67ebf8d5a1c23..21afa03c18920 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts @@ -22,6 +22,7 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 1)); const setup = () => { const { xhr, XMLHttpRequest } = mockXMLHttpRequest(); window.XMLHttpRequest = XMLHttpRequest; + (xhr as any).status = 200; return { xhr }; }; diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts index bc5bf28183a50..d73f7a9b5099b 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts @@ -15,7 +15,7 @@ const createXhr = (): XMLHttpRequest => onreadystatechange: () => {}, readyState: 0, responseText: '', - status: 0, + status: 200, } as unknown as XMLHttpRequest); test('returns observable', () => { @@ -156,6 +156,36 @@ test('errors observable if request returns with error', () => { ); }); +test('does not emit when gets error response', () => { + const xhr = createXhr(); + const observable = fromStreamingXhr(xhr); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + observable.subscribe({ + next, + complete, + error, + }); + + (xhr as any).responseText = 'error'; + (xhr as any).status = 400; + xhr.onprogress!({} as any); + + expect(next).toHaveBeenCalledTimes(0); + + (xhr as any).readyState = 4; + xhr.onreadystatechange!({} as any); + + expect(next).toHaveBeenCalledTimes(0); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect(error.mock.calls[0][0].message).toMatchInlineSnapshot( + `"Batch request failed with status 400"` + ); +}); + test('when .onprogress called multiple times with same text, does not create new observable events', () => { const xhr = createXhr(); const observable = fromStreamingXhr(xhr); diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts index c630554c73fa0..d81c7bfa694c2 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts @@ -23,8 +23,12 @@ export const fromStreamingXhr = ( let index = 0; let aborted = false; + // 0 indicates a network failure. 400+ messages are considered server errors + const isErrorStatus = () => xhr.status === 0 || xhr.status >= 400; + const processBatch = () => { if (aborted) return; + if (isErrorStatus()) return; const { responseText } = xhr; if (index >= responseText.length) return; @@ -56,8 +60,7 @@ export const fromStreamingXhr = ( if (xhr.readyState === 4) { if (signal) signal.removeEventListener('abort', onBatchAbort); - // 0 indicates a network failure. 400+ messages are considered server errors - if (xhr.status === 0 || xhr.status >= 400) { + if (isErrorStatus()) { subject.error(new Error(`Batch request failed with status ${xhr.status}`)); } else { subject.complete(); diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index f7e8bd1314ab5..a342c3429d03d 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -361,7 +361,7 @@ describe('Integration', () => { cursor: { lineNumber: 7, column: 1 }, initialValue: '', addTemplate: true, - prefixToAdd: ', ', + prefixToAdd: '', suffixToAdd: '', rangeToReplace: { start: { lineNumber: 7, column: 1 }, @@ -374,7 +374,7 @@ describe('Integration', () => { cursor: { lineNumber: 6, column: 15 }, initialValue: '', addTemplate: true, - prefixToAdd: ', ', + prefixToAdd: '', suffixToAdd: '', rangeToReplace: { start: { lineNumber: 6, column: 15 }, diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index a85e53d44d1a3..43fdde3f02349 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -762,7 +762,18 @@ export default function ({ break; default: if (nonEmptyToken && nonEmptyToken.type.indexOf('url') < 0) { - context.prefixToAdd = ', '; + const { position, value } = nonEmptyToken; + + // We can not rely on prefixToAdd here, because it adds a comma at the beginning of the new token + // Since we have access to the position of the previous token here, this could be a good place to insert a comma manually + context.prefixToAdd = ''; + editor.insert( + { + column: position.column + value.length, + lineNumber: position.lineNumber, + }, + ', ' + ); } } diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx index 3c3872226ffb0..84c968e1c2b24 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.test.tsx @@ -33,7 +33,8 @@ setup.registerEmbeddableFactory( const start = doStart(); let container: DashboardContainer; -let embeddable: ContactCardEmbeddable; +let byRefOrValEmbeddable: ContactCardEmbeddable; +let genericEmbeddable: ContactCardEmbeddable; let coreStart: CoreStart; beforeEach(async () => { coreStart = coreMock.createStart(); @@ -70,18 +71,38 @@ beforeEach(async () => { }); container = new DashboardContainer(input, options); - const contactCardEmbeddable = await container.addNewEmbeddable< + const refOrValContactCardEmbeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, ContactCardEmbeddableOutput, ContactCardEmbeddable >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Kibana', + firstName: 'RefOrValEmbeddable', + }); + const genericContactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'NotRefOrValEmbeddable', }); - if (isErrorEmbeddable(contactCardEmbeddable)) { - throw new Error('Failed to create embeddable'); + if ( + isErrorEmbeddable(refOrValContactCardEmbeddable) || + isErrorEmbeddable(genericContactCardEmbeddable) + ) { + throw new Error('Failed to create embeddables'); } else { - embeddable = contactCardEmbeddable; + byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(refOrValContactCardEmbeddable, { + mockedByReferenceInput: { + savedObjectId: 'testSavedObjectId', + id: refOrValContactCardEmbeddable.id, + }, + mockedByValueInput: { firstName: 'Kibanana', id: refOrValContactCardEmbeddable.id }, + }); + genericEmbeddable = genericContactCardEmbeddable; } }); @@ -90,17 +111,17 @@ test('Clone is incompatible with Error Embeddables', async () => { const errorEmbeddable = new ErrorEmbeddable( 'Wow what an awful error', { id: ' 404' }, - embeddable.getRoot() as IContainer + byRefOrValEmbeddable.getRoot() as IContainer ); expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false); }); test('Clone adds a new embeddable', async () => { - const dashboard = embeddable.getRoot() as IContainer; + const dashboard = byRefOrValEmbeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new ClonePanelAction(coreStart); - await action.execute({ embeddable }); + await action.execute({ embeddable: byRefOrValEmbeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1); const newPanelId = Object.keys(container.getInput().panels).find( (key) => !originalPanelKeySet.has(key) @@ -113,56 +134,159 @@ test('Clone adds a new embeddable', async () => { await new Promise((r) => process.nextTick(r)); // Allow the current loop of the event loop to run to completion // now wait for the full embeddable to replace it const loadedPanel = await dashboard.untilEmbeddableLoaded(newPanelId!); - expect(loadedPanel.type).toEqual(embeddable.type); + expect(loadedPanel.type).toEqual(byRefOrValEmbeddable.type); }); -test('Clones an embeddable without a saved object ID', async () => { - const dashboard = embeddable.getRoot() as IContainer; - const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; +test('Clones a RefOrVal embeddable by value', async () => { + const dashboard = byRefOrValEmbeddable.getRoot() as IContainer; + const panel = dashboard.getInput().panels[byRefOrValEmbeddable.id] as DashboardPanelState; const action = new ClonePanelAction(coreStart); // @ts-ignore - const newPanel = await action.cloneEmbeddable(panel, embeddable.type); - expect(newPanel.type).toEqual(embeddable.type); + const newPanel = await action.cloneEmbeddable(panel, byRefOrValEmbeddable); + expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(0); + expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(0); + expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(0); + expect(newPanel.type).toEqual(byRefOrValEmbeddable.type); }); -test('Clones an embeddable with a saved object ID', async () => { - const dashboard = embeddable.getRoot() as IContainer; - const panel = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; +test('Clones a non-RefOrVal embeddable by value if the panel does not have a savedObjectId', async () => { + const dashboard = genericEmbeddable.getRoot() as IContainer; + const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState; + const action = new ClonePanelAction(coreStart); + // @ts-ignore + const newPanelWithoutId = await action.cloneEmbeddable(panel, genericEmbeddable); + expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(0); + expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(0); + expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(0); + expect(newPanelWithoutId.type).toEqual(genericEmbeddable.type); +}); + +test('Clones a non-RefOrVal embeddable by reference if the panel has a savedObjectId', async () => { + const dashboard = genericEmbeddable.getRoot() as IContainer; + const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState; panel.explicitInput.savedObjectId = 'holySavedObjectBatman'; const action = new ClonePanelAction(coreStart); // @ts-ignore - const newPanel = await action.cloneEmbeddable(panel, embeddable.type); + const newPanel = await action.cloneEmbeddable(panel, genericEmbeddable); expect(coreStart.savedObjects.client.get).toHaveBeenCalledTimes(1); expect(coreStart.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(coreStart.savedObjects.client.create).toHaveBeenCalledTimes(1); - expect(newPanel.type).toEqual(embeddable.type); + expect(newPanel.type).toEqual(genericEmbeddable.type); }); -test('Gets a unique title ', async () => { +test('Gets a unique title from the saved objects library', async () => { + const dashboard = genericEmbeddable.getRoot() as IContainer; + const panel = dashboard.getInput().panels[genericEmbeddable.id] as DashboardPanelState; + panel.explicitInput.savedObjectId = 'holySavedObjectBatman'; coreStart.savedObjects.client.find = jest.fn().mockImplementation(({ search }) => { - if (search === '"testFirstTitle"') return { total: 1 }; - else if (search === '"testSecondTitle"') return { total: 41 }; - else if (search === '"testThirdTitle"') return { total: 90 }; + if (search === '"testFirstClone"') { + return { + savedObjects: [ + { + attributes: { title: 'testFirstClone' }, + get: jest.fn().mockReturnValue('testFirstClone'), + }, + ], + total: 1, + }; + } else if (search === '"testBeforePageLimit"') { + return { + savedObjects: [ + { + attributes: { title: 'testBeforePageLimit (copy 9)' }, + get: jest.fn().mockReturnValue('testBeforePageLimit (copy 9)'), + }, + ], + total: 10, + }; + } else if (search === '"testMaxLogic"') { + return { + savedObjects: [ + { + attributes: { title: 'testMaxLogic (copy 10000)' }, + get: jest.fn().mockReturnValue('testMaxLogic (copy 10000)'), + }, + ], + total: 2, + }; + } else if (search === '"testAfterPageLimit"') { + return { total: 11 }; + } }); + + const action = new ClonePanelAction(coreStart); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testFirstClone')).toEqual( + 'testFirstClone (copy)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testBeforePageLimit')).toEqual( + 'testBeforePageLimit (copy 10)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testBeforePageLimit (copy 9)')).toEqual( + 'testBeforePageLimit (copy 10)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testMaxLogic')).toEqual( + 'testMaxLogic (copy 10001)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit')).toEqual( + 'testAfterPageLimit (copy 11)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit (copy 10)')).toEqual( + 'testAfterPageLimit (copy 11)' + ); + // @ts-ignore + expect(await action.getCloneTitle(genericEmbeddable, 'testAfterPageLimit (copy 10000)')).toEqual( + 'testAfterPageLimit (copy 11)' + ); +}); + +test('Gets a unique title from the dashboard', async () => { + const dashboard = genericEmbeddable.getRoot() as DashboardContainer; const action = new ClonePanelAction(coreStart); + // @ts-ignore - expect(await action.getUniqueTitle('testFirstTitle', embeddable.type)).toEqual( - 'testFirstTitle (copy)' + expect(await action.getCloneTitle(byRefOrValEmbeddable, '')).toEqual(''); + + dashboard.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle']; + }); + // @ts-ignore + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual( + 'testUniqueTitle (copy)' ); // @ts-ignore - expect(await action.getUniqueTitle('testSecondTitle (copy 39)', embeddable.type)).toEqual( - 'testSecondTitle (copy 40)' + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 1)' ); + + dashboard.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat( + Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`) + ); + }); // @ts-ignore - expect(await action.getUniqueTitle('testSecondTitle (copy 20)', embeddable.type)).toEqual( - 'testSecondTitle (copy 40)' + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 40)' ); // @ts-ignore - expect(await action.getUniqueTitle('testThirdTitle', embeddable.type)).toEqual( - 'testThirdTitle (copy 89)' + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual( + 'testDuplicateTitle (copy 40)' + ); + + dashboard.getPanelTitles = jest.fn().mockImplementation(() => { + return ['testDuplicateTitle (copy 100)']; + }); + // @ts-ignore + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual( + 'testDuplicateTitle (copy 101)' ); // @ts-ignore - expect(await action.getUniqueTitle('testThirdTitle (copy 10000)', embeddable.type)).toEqual( - 'testThirdTitle (copy 89)' + expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual( + 'testDuplicateTitle (copy 101)' ); }); diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 829344504b16b..4b2e1e39818ad 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -20,6 +20,7 @@ import { EmbeddableInput, SavedObjectEmbeddableInput, isErrorEmbeddable, + isReferenceOrValueEmbeddable, } from '../../services/embeddable'; import { placePanelBeside, @@ -78,7 +79,7 @@ export class ClonePanelAction implements Action { } dashboard.showPlaceholderUntil( - this.cloneEmbeddable(panelToClone, embeddable.type), + this.cloneEmbeddable(panelToClone, embeddable), placePanelBeside, { width: panelToClone.gridData.w, @@ -89,56 +90,106 @@ export class ClonePanelAction implements Action { ); } - private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise { + private async getCloneTitle(embeddable: IEmbeddable, rawTitle: string) { + if (rawTitle === '') return ''; // If + const clonedTag = dashboardClonePanelAction.getClonedTag(); const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); + let similarTitles: string[]; + if ( + isReferenceOrValueEmbeddable(embeddable) || + !_.has(embeddable.getExplicitInput(), 'savedObjectId') + ) { + const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer; + similarTitles = _.filter(await dashboard.getPanelTitles(), (title: string) => { + return title.startsWith(baseTitle); + }); + } else { + const perPage = 10; + const similarSavedObjects = await this.core.savedObjects.client.find({ + type: embeddable.type, + perPage, + fields: ['title'], + searchFields: ['title'], + search: `"${baseTitle}"`, + }); + if (similarSavedObjects.total <= perPage) { + similarTitles = similarSavedObjects.savedObjects.map((savedObject) => { + return savedObject.get('title'); + }); + } else { + similarTitles = [baseTitle + ` (${clonedTag} ${similarSavedObjects.total - 1})`]; + } + } - const similarSavedObjects = await this.core.savedObjects.client.find({ - type: embeddableType, - perPage: 0, - fields: ['title'], - searchFields: ['title'], - search: `"${baseTitle}"`, + const cloneNumbers = _.map(similarTitles, (title: string) => { + if (title.match(cloneRegex)) return 0; + const cloneTag = title.match(cloneNumberRegex); + return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1; }); - const similarBaseTitlesCount: number = similarSavedObjects.total - 1; + const similarBaseTitlesCount = _.max(cloneNumbers) || 0; - return similarBaseTitlesCount <= 0 + return similarBaseTitlesCount < 0 ? baseTitle + ` (${clonedTag})` - : baseTitle + ` (${clonedTag} ${similarBaseTitlesCount})`; + : baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`; + } + + private async addCloneToLibrary( + embeddable: IEmbeddable, + objectIdToClone: string + ): Promise { + const savedObjectToClone = await this.core.savedObjects.client.get( + embeddable.type, + objectIdToClone + ); + + // Clone the saved object + const newTitle = await this.getCloneTitle(embeddable, savedObjectToClone.attributes.title); + const clonedSavedObject = await this.core.savedObjects.client.create( + embeddable.type, + { + ..._.cloneDeep(savedObjectToClone.attributes), + title: newTitle, + }, + { references: _.cloneDeep(savedObjectToClone.references) } + ); + return clonedSavedObject.id; } private async cloneEmbeddable( panelToClone: DashboardPanelState, - embeddableType: string + embeddable: IEmbeddable ): Promise> { - const panelState: PanelState = { - type: embeddableType, - explicitInput: { - ...panelToClone.explicitInput, - id: uuid.v4(), - }, - }; - let newTitle: string = ''; - if (panelToClone.explicitInput.savedObjectId) { - // Fetch existing saved object - const savedObjectToClone = await this.core.savedObjects.client.get( - embeddableType, - panelToClone.explicitInput.savedObjectId - ); - - // Clone the saved object - newTitle = await this.getUniqueTitle(savedObjectToClone.attributes.title, embeddableType); - const clonedSavedObject = await this.core.savedObjects.client.create( - embeddableType, - { - ..._.cloneDeep(savedObjectToClone.attributes), + let panelState: PanelState; + if (isReferenceOrValueEmbeddable(embeddable)) { + const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || ''); + panelState = { + type: embeddable.type, + explicitInput: { + ...(await embeddable.getInputAsValueType()), + id: uuid.v4(), title: newTitle, + hidePanelTitles: panelToClone.explicitInput.hidePanelTitles, + }, + }; + } else { + panelState = { + type: embeddable.type, + explicitInput: { + ...panelToClone.explicitInput, + id: uuid.v4(), }, - { references: _.cloneDeep(savedObjectToClone.references) } - ); - (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = clonedSavedObject.id; + }; + if (panelToClone.explicitInput.savedObjectId) { + const clonedSavedObjectId = await this.addCloneToLibrary( + embeddable, + panelToClone.explicitInput.savedObjectId + ); + (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = + clonedSavedObjectId; + } } this.core.notifications.toasts.addSuccess({ title: dashboardClonePanelAction.getSuccessMessage(), diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index 68a5950269517..4eae24bc892f7 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -79,9 +79,6 @@ export class UnlinkFromLibraryAction implements Action { + const titles: string[] = []; + const ids: string[] = Object.keys(this.getInput().panels); + for (const panelId of ids) { + await this.untilEmbeddableLoaded(panelId); + const child: IEmbeddable = this.getChild(panelId); + const title = child.getTitle(); + if (title) { + titles.push(title); + } + } + return titles; + } + constructor( initialInput: DashboardContainerInput, private readonly services: DashboardContainerServices, diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index d44e793d4ef49..e5f4e6cec057e 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -98,7 +98,7 @@ export const EmptyPrompts: FC = ({ allSources, onCancel, children, loadSo setGoToForm(true)} indexPatternsIntroUrl={docLinks.links.indexPatterns.introduction} - canSaveIndexPattern={application.capabilities.indexPatterns.save as boolean} + canSaveIndexPattern={dataViews.getCanSaveSync()} /> diff --git a/src/plugins/data_view_editor/public/plugin.tsx b/src/plugins/data_view_editor/public/plugin.tsx index 68a0191836291..a8aa33620c456 100644 --- a/src/plugins/data_view_editor/public/plugin.tsx +++ b/src/plugins/data_view_editor/public/plugin.tsx @@ -60,9 +60,7 @@ export class DataViewEditorPlugin * @returns boolean */ userPermissions: { - editDataView: () => { - return application.capabilities.management.kibana.indexPatterns; - }, + editDataView: () => dataViews.getCanSaveSync(), }, }; } diff --git a/src/plugins/data_view_field_editor/public/plugin.ts b/src/plugins/data_view_field_editor/public/plugin.ts index c0f09cdace9ba..3b13bcd82c110 100644 --- a/src/plugins/data_view_field_editor/public/plugin.ts +++ b/src/plugins/data_view_field_editor/public/plugin.ts @@ -30,10 +30,7 @@ export class IndexPatternFieldEditorPlugin public start(core: CoreStart, plugins: StartPlugins) { const { fieldFormatEditors } = this.formatEditorService.start(); - const { - application: { capabilities }, - http, - } = core; + const { http } = core; const { data, usageCollection, dataViews, fieldFormats } = plugins; const openDeleteModal = getFieldDeleteModalOpener({ core, @@ -53,9 +50,7 @@ export class IndexPatternFieldEditorPlugin }), openDeleteModal, userPermissions: { - editIndexPattern: () => { - return capabilities.management.kibana.indexPatterns; - }, + editIndexPattern: () => dataViews.getCanSaveSync(), }, DeleteRuntimeFieldProvider: getDeleteFieldProvider(openDeleteModal), }; diff --git a/src/plugins/data_view_management/public/components/field_editor/field_editor.test.tsx b/src/plugins/data_view_management/public/components/field_editor/field_editor.test.tsx index 39f8469dc0989..875b21ac52444 100644 --- a/src/plugins/data_view_management/public/components/field_editor/field_editor.test.tsx +++ b/src/plugins/data_view_management/public/components/field_editor/field_editor.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { DataView, DataViewField, DataViewsService } from 'src/plugins/data_views/public'; +import { DataView, DataViewField, DataViewsContract } from 'src/plugins/data_views/public'; import { FieldFormatInstanceType } from 'src/plugins/field_formats/common'; import { findTestSubject } from '@elastic/eui/lib/test'; @@ -95,7 +95,7 @@ const field = { const services = { redirectAway: () => {}, saveIndexPattern: async () => {}, - indexPatternService: {} as DataViewsService, + indexPatternService: {} as DataViewsContract, }; describe('FieldEditor', () => { @@ -202,7 +202,7 @@ describe('FieldEditor', () => { redirectAway: () => {}, indexPatternService: { updateSavedObject: jest.fn(() => Promise.resolve()), - } as unknown as DataViewsService, + } as unknown as DataViewsContract, }, }, mockContext diff --git a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx index 4f8e98d382d37..1b876e34a42fb 100644 --- a/src/plugins/data_view_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_view_management/public/management_app/mount_management_section.tsx @@ -43,7 +43,7 @@ export async function mountManagementSection( { data, dataViewFieldEditor, dataViewEditor, dataViews, fieldFormats }, indexPatternManagementStart, ] = await getStartServices(); - const canSave = Boolean(application.capabilities.indexPatterns.save); + const canSave = dataViews.getCanSaveSync(); if (!canSave) { chrome.setBadge(readOnlyBadge); diff --git a/src/plugins/data_views/common/data_views/data_views.ts b/src/plugins/data_views/common/data_views/data_views.ts index ae7c9f03afc18..26e1683b60006 100644 --- a/src/plugins/data_views/common/data_views/data_views.ts +++ b/src/plugins/data_views/common/data_views/data_views.ts @@ -59,7 +59,7 @@ export interface DataViewListItem { export type IndexPatternListItem = DataViewListItem; -interface IndexPatternsServiceDeps { +export interface DataViewsServiceDeps { uiSettings: UiSettingsCommon; savedObjectsClient: SavedObjectsClientCommon; apiClient: IDataViewsApiClient; @@ -79,7 +79,7 @@ export class DataViewsService { private onNotification: OnNotification; private onError: OnError; private dataViewCache: ReturnType; - private getCanSave: () => Promise; + public getCanSave: () => Promise; /** * @deprecated Use `getDefaultDataView` instead (when loading data view) and handle @@ -96,7 +96,7 @@ export class DataViewsService { onError, onRedirectNoIndexPattern = () => {}, getCanSave = () => Promise.resolve(false), - }: IndexPatternsServiceDeps) { + }: DataViewsServiceDeps) { this.apiClient = apiClient; this.config = uiSettings; this.savedObjectsClient = savedObjectsClient; diff --git a/src/plugins/data_views/public/data_views_service_public.ts b/src/plugins/data_views/public/data_views_service_public.ts new file mode 100644 index 0000000000000..d89c4e37e872d --- /dev/null +++ b/src/plugins/data_views/public/data_views_service_public.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DataViewsService } from '.'; + +import { DataViewsServiceDeps } from '../common/data_views/data_views'; + +interface DataViewsServicePublicDeps extends DataViewsServiceDeps { + getCanSaveSync: () => boolean; +} + +export class DataViewsServicePublic extends DataViewsService { + public getCanSaveSync: () => boolean; + + constructor(deps: DataViewsServicePublicDeps) { + super(deps); + this.getCanSaveSync = deps.getCanSaveSync; + } +} diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index b15e6da7d940b..9cb07d4f3c54f 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -18,7 +18,7 @@ export { onRedirectNoIndexPattern } from './data_views'; export type { IIndexPatternFieldList, TypeMeta } from '../common'; export { IndexPatternField, DataViewField, DataViewType, META_FIELDS } from '../common'; -export type { IndexPatternsContract, DataViewsContract } from './data_views'; +export type { IndexPatternsContract } from './data_views'; export type { DataViewListItem } from './data_views'; export { IndexPatternsService, @@ -40,7 +40,11 @@ export function plugin() { return new DataViewsPublicPlugin(); } -export type { DataViewsPublicPluginSetup, DataViewsPublicPluginStart } from './types'; +export type { + DataViewsPublicPluginSetup, + DataViewsPublicPluginStart, + DataViewsContract, +} from './types'; // Export plugin after all other imports export type { DataViewsPublicPlugin as DataViewsPlugin }; diff --git a/src/plugins/data_views/public/plugin.ts b/src/plugins/data_views/public/plugin.ts index bf092d3fae177..c2d0a70502b22 100644 --- a/src/plugins/data_views/public/plugin.ts +++ b/src/plugins/data_views/public/plugin.ts @@ -16,13 +16,14 @@ import { } from './types'; import { - DataViewsService, onRedirectNoIndexPattern, DataViewsApiClient, UiSettingsPublicToCommon, SavedObjectsClientPublicToCommon, } from '.'; +import { DataViewsServicePublic } from './data_views_service_public'; + export class DataViewsPublicPlugin implements Plugin< @@ -47,7 +48,7 @@ export class DataViewsPublicPlugin ): DataViewsPublicPluginStart { const { uiSettings, http, notifications, savedObjects, theme, overlays, application } = core; - return new DataViewsService({ + return new DataViewsServicePublic({ uiSettings: new UiSettingsPublicToCommon(uiSettings), savedObjectsClient: new SavedObjectsClientPublicToCommon(savedObjects.client), apiClient: new DataViewsApiClient(http), @@ -63,6 +64,7 @@ export class DataViewsPublicPlugin theme ), getCanSave: () => Promise.resolve(application.capabilities.indexPatterns.save === true), + getCanSaveSync: () => application.capabilities.indexPatterns.save === true, }); } diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index 20b1cbaf090fa..e012695dfc6b5 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -26,7 +26,13 @@ export interface DataViewsPublicStartDependencies { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DataViewsPublicPluginSetup {} +export interface DataViewsServicePublic extends DataViewsService { + getCanSaveSync: () => boolean; +} + +export type DataViewsContract = PublicMethodsOf; + /** - * Data plugin public Start contract + * Data views plugin public Start contract */ -export type DataViewsPublicPluginStart = PublicMethodsOf; +export type DataViewsPublicPluginStart = PublicMethodsOf; diff --git a/src/plugins/data_views/server/register_index_pattern_usage_collection.ts b/src/plugins/data_views/server/register_index_pattern_usage_collection.ts index 6af2f6df6725e..5b42db0f75a4d 100644 --- a/src/plugins/data_views/server/register_index_pattern_usage_collection.ts +++ b/src/plugins/data_views/server/register_index_pattern_usage_collection.ts @@ -8,7 +8,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { StartServicesAccessor } from 'src/core/server'; -import { DataViewsService } from '../common'; +import { DataViewsContract } from '../common'; import { SavedObjectsClient } from '../../../core/server'; import { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from './types'; @@ -57,7 +57,7 @@ export const updateMax = (currentMax: number | undefined, newVal: number): numbe } }; -export async function getIndexPatternTelemetry(indexPatterns: DataViewsService) { +export async function getIndexPatternTelemetry(indexPatterns: DataViewsContract) { const ids = await indexPatterns.getIds(); const countSummaryDefaults: CountSummary = { diff --git a/src/plugins/discover/.storybook/discover.webpack.ts b/src/plugins/discover/.storybook/discover.webpack.ts new file mode 100644 index 0000000000000..7b978a4e7110e --- /dev/null +++ b/src/plugins/discover/.storybook/discover.webpack.ts @@ -0,0 +1,14 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { defaultConfig } from '@kbn/storybook'; + +export const discoverStorybookConfig = { + ...defaultConfig, + stories: ['../**/*.stories.tsx'], + addons: [...(defaultConfig.addons || []), './addon/target/register'], +}; diff --git a/src/plugins/discover/.storybook/main.js b/src/plugins/discover/.storybook/main.js new file mode 100644 index 0000000000000..85ea71a7f08f7 --- /dev/null +++ b/src/plugins/discover/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { discoverStorybookConfig } from './discover.webpack'; + +module.exports = discoverStorybookConfig; diff --git a/src/plugins/discover/public/__mocks__/index_patterns.ts b/src/plugins/discover/public/__mocks__/index_patterns.ts index 0e665890fedba..0d6f3fa723682 100644 --- a/src/plugins/discover/public/__mocks__/index_patterns.ts +++ b/src/plugins/discover/public/__mocks__/index_patterns.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { DataViewsService } from '../../../data/common'; +import { DataViewsContract } from '../../../data_views/public'; import { indexPatternMock } from './index_pattern'; export const indexPatternsMock = { @@ -21,4 +21,4 @@ export const indexPatternsMock = { } }, updateSavedObject: jest.fn(), -} as unknown as jest.Mocked; +} as unknown as jest.Mocked; diff --git a/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx b/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx new file mode 100644 index 0000000000000..cecf02c016676 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { KBN_FIELD_TYPES } from '@kbn/field-types'; +import { DiscoverFieldDetails } from '../discover_field_details'; +import { DataView, IndexPatternField } from '../../../../../../../data_views/common'; +import { fieldSpecMap } from './fields'; +import { numericField as field } from './fields'; +import { Bucket } from '../types'; + +const buckets = [ + { count: 1, display: 'Stewart', percent: 50.0, value: 'Stewart' }, + { count: 1, display: 'Perry', percent: 50.0, value: 'Perry' }, +] as Bucket[]; +const details = { buckets, error: '', exists: 1, total: 2, columns: [] }; + +const fieldFormatInstanceType = {}; +const defaultMap = { + [KBN_FIELD_TYPES.NUMBER]: { id: KBN_FIELD_TYPES.NUMBER, params: {} }, +}; + +const fieldFormat = { + getByFieldType: (fieldType: KBN_FIELD_TYPES) => { + return [fieldFormatInstanceType]; + }, + getDefaultConfig: () => { + return defaultMap.number; + }, + defaultMap, +}; + +const scriptedField = new IndexPatternField({ + name: 'machine.os', + type: 'string', + esTypes: ['long'], + count: 10, + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: true, +}); + +const dataView = new DataView({ + spec: { + id: 'logstash-*', + fields: fieldSpecMap, + title: 'logstash-*', + timeFieldName: '@timestamp', + }, + metaFields: ['_id', '_type', '_source'], + shortDotsEnable: false, + // @ts-expect-error + fieldFormats: fieldFormat, +}); + +storiesOf('components/sidebar/DiscoverFieldDetails', module) + .add('default', () => ( +
+ { + alert('On add filter clicked'); + }} + /> +
+ )) + .add('scripted', () => ( +
+ {}} + /> +
+ )) + .add('error', () => ( + {}} + /> + )); diff --git a/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_visualize.stories.tsx b/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_visualize.stories.tsx new file mode 100644 index 0000000000000..397ada8da109d --- /dev/null +++ b/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_visualize.stories.tsx @@ -0,0 +1,29 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { DiscoverFieldVisualizeInner } from '../discover_field_visualize_inner'; +import { numericField as field } from './fields'; + +const visualizeInfo = { + href: 'http://localhost:9001/', + field, +}; + +const handleVisualizeLinkClick = () => { + alert('Clicked'); +}; + +storiesOf('components/sidebar/DiscoverFieldVisualizeInner', module).add('default', () => ( + +)); diff --git a/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts b/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts new file mode 100644 index 0000000000000..950ea5b328e6f --- /dev/null +++ b/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts @@ -0,0 +1,51 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FieldSpec, IndexPatternField } from '../../../../../../../data_views/common'; + +export const fieldSpecMap: Record = { + 'machine.os': { + name: 'machine.os', + esTypes: ['text'], + type: 'string', + aggregatable: false, + searchable: false, + }, + 'machine.os.raw': { + name: 'machine.os.raw', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + 'not.filterable': { + name: 'not.filterable', + type: 'string', + esTypes: ['text'], + aggregatable: true, + searchable: false, + }, + bytes: { + name: 'bytes', + type: 'number', + esTypes: ['long'], + aggregatable: true, + searchable: true, + }, +}; + +export const numericField = new IndexPatternField({ + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, +}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize.tsx index b0e10afde9c56..fb845828d62ab 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize.tsx @@ -7,14 +7,13 @@ */ import React, { useEffect, useState } from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import type { DataView, DataViewField } from 'src/plugins/data/common'; import { triggerVisualizeActions, VisualizeInformation } from './lib/visualize_trigger_utils'; import type { FieldDetails } from './types'; import { getVisualizeInformation } from './lib/visualize_trigger_utils'; +import { DiscoverFieldVisualizeInner } from './discover_field_visualize_inner'; interface Props { field: DataViewField; @@ -46,19 +45,11 @@ export const DiscoverFieldVisualize: React.FC = React.memo( }; return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - - + ); } ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize_inner.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize_inner.tsx new file mode 100644 index 0000000000000..f4c7205f25026 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_visualize_inner.tsx @@ -0,0 +1,38 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { IndexPatternField } from '../../../../../../data_views/common'; +import { VisualizeInformation } from './lib/visualize_trigger_utils'; + +interface DiscoverFieldVisualizeInnerProps { + field: IndexPatternField; + visualizeInfo: VisualizeInformation; + handleVisualizeLinkClick: (event: React.MouseEvent) => void; +} + +export const DiscoverFieldVisualizeInner = (props: DiscoverFieldVisualizeInnerProps) => { + const { field, visualizeInfo, handleVisualizeLinkClick } = props; + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + + ); +}; diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx index a102622402d02..4479e051b1f26 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx @@ -600,4 +600,64 @@ describe('Discover grid cell rendering', function () { ); expect(component.html()).toMatchInlineSnapshot(`"-"`); }); + + it('renders unmapped fields correctly', () => { + (indexPatternMock.getFieldByName as jest.Mock).mockReturnValueOnce(undefined); + const rowsFieldsUnmapped: ElasticSearchHit[] = [ + { + _id: '1', + _index: 'test', + _score: 1, + _source: undefined, + fields: { unmapped: ['.gz'] }, + highlight: { + extension: ['@kibana-highlighted-field.gz@/kibana-highlighted-field'], + }, + }, + ]; + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsUnmapped, + rowsFieldsUnmapped.map(flatten), + true, + ['unmapped'], + 100 + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + .gz + + `); + + const componentWithDetails = shallow( + + ); + expect(componentWithDetails).toMatchInlineSnapshot(` + + `); + }); }); diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx index 89e4b431ae124..436281b119bff 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx @@ -166,10 +166,15 @@ export const getRenderCellValueFn = if (!field?.type && rowFlattened && typeof rowFlattened[columnId] === 'object') { if (isDetails) { // nicely formatted JSON for the expanded view - return {JSON.stringify(rowFlattened[columnId], null, 2)}; + return ( + } + width={defaultMonacoEditorWidth} + /> + ); } - return {JSON.stringify(rowFlattened[columnId])}; + return <>{formatFieldValue(rowFlattened[columnId], row, indexPattern, field)}; } return ( diff --git a/src/plugins/discover/public/components/field_name/__stories__/field_name.stories.tsx b/src/plugins/discover/public/components/field_name/__stories__/field_name.stories.tsx new file mode 100644 index 0000000000000..91ef279405b61 --- /dev/null +++ b/src/plugins/discover/public/components/field_name/__stories__/field_name.stories.tsx @@ -0,0 +1,47 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; +import { FieldName } from '../field_name'; +import { IndexPatternField } from '../../../../../data_views/common'; + +const field = new IndexPatternField({ + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, +}); + +const renderFieldName = (fldName: {} | null | undefined) => { + return ( + + {fldName} + + ); +}; + +storiesOf('components/FieldName/FieldNameStories', module) + .add('default', () => renderFieldName()) + .add('with field type', () => + renderFieldName() + ) + .add('with field mapping', () => + renderFieldName( + + ) + ); diff --git a/src/plugins/discover/public/utils/popularize_field.test.ts b/src/plugins/discover/public/utils/popularize_field.test.ts index 084a147b5c9b6..bf81163463b87 100644 --- a/src/plugins/discover/public/utils/popularize_field.test.ts +++ b/src/plugins/discover/public/utils/popularize_field.test.ts @@ -7,7 +7,7 @@ */ import { Capabilities } from 'kibana/public'; -import { DataView, DataViewsService } from '../../../data/common'; +import { DataView, DataViewsContract } from '../../../data_views/public'; import { popularizeField } from './popularize_field'; const capabilities = { @@ -20,7 +20,7 @@ describe('Popularize field', () => { test('returns undefined if index pattern lacks id', async () => { const indexPattern = {} as unknown as DataView; const fieldName = '@timestamp'; - const dataViewsService = {} as unknown as DataViewsService; + const dataViewsService = {} as unknown as DataViewsContract; const result = await popularizeField(indexPattern, fieldName, dataViewsService, capabilities); expect(result).toBeUndefined(); }); @@ -32,7 +32,7 @@ describe('Popularize field', () => { }, } as unknown as DataView; const fieldName = '@timestamp'; - const dataViewsService = {} as unknown as DataViewsService; + const dataViewsService = {} as unknown as DataViewsContract; const result = await popularizeField(indexPattern, fieldName, dataViewsService, capabilities); expect(result).toBeUndefined(); }); @@ -50,7 +50,7 @@ describe('Popularize field', () => { const fieldName = '@timestamp'; const dataViewsService = { updateSavedObject: async () => {}, - } as unknown as DataViewsService; + } as unknown as DataViewsContract; const result = await popularizeField(indexPattern, fieldName, dataViewsService, capabilities); expect(result).toBeUndefined(); expect(field.count).toEqual(1); @@ -71,7 +71,7 @@ describe('Popularize field', () => { updateSavedObject: async () => { throw new Error('unknown error'); }, - } as unknown as DataViewsService; + } as unknown as DataViewsContract; const result = await popularizeField(indexPattern, fieldName, dataViewsService, capabilities); expect(result).toBeUndefined(); }); @@ -89,7 +89,7 @@ describe('Popularize field', () => { const fieldName = '@timestamp'; const dataViewsService = { updateSavedObject: jest.fn(), - } as unknown as DataViewsService; + } as unknown as DataViewsContract; const result = await popularizeField(indexPattern, fieldName, dataViewsService, { indexPatterns: { save: false }, } as unknown as Capabilities); diff --git a/src/plugins/discover/public/utils/use_data_grid_columns.ts b/src/plugins/discover/public/utils/use_data_grid_columns.ts index 8df97e84dcf2b..ab492dc10f617 100644 --- a/src/plugins/discover/public/utils/use_data_grid_columns.ts +++ b/src/plugins/discover/public/utils/use_data_grid_columns.ts @@ -7,7 +7,7 @@ */ import { useMemo } from 'react'; -import type { DataView, DataViewsContract } from 'src/plugins/data/common'; +import type { DataView, DataViewsContract } from 'src/plugins/data_views/public'; import { Capabilities, IUiSettingsClient } from 'kibana/public'; import { diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 961d265ab7ff9..4ff6f0598d7d8 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -6,7 +6,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*"], + "include": ["common/**/*", "public/**/*", "server/**/*", "../../../typings/**/*", ".storybook/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../charts/tsconfig.json" }, diff --git a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx index 3826162aa6590..81b836796d31d 100644 --- a/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx +++ b/src/plugins/embeddable/public/lib/attribute_service/attribute_service.tsx @@ -137,10 +137,14 @@ export class AttributeService< return input as ValType; } const { attributes } = await this.unwrapAttributes(input); + const libraryTitle = attributes.title; const { savedObjectId, ...originalInputToPropagate } = input; + return { ...originalInputToPropagate, - attributes, + // by value visualizations should not have default titles and/or descriptions + ...{ attributes: omit(attributes, ['title', 'description']) }, + title: libraryTitle, } as unknown as ValType; }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index 9c328a175c10b..c3b8834605d1d 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -455,10 +455,8 @@ export class VisualizeEmbeddable const input = { savedVis: this.vis.serialize(), }; - if (this.getTitle()) { - input.savedVis.title = this.getTitle(); - } delete input.savedVis.id; + _.unset(input, 'savedVis.title'); return new Promise((resolve) => { resolve({ ...(input as VisualizeByValueInput) }); }); diff --git a/test/functional/apps/console/_console.ts b/test/functional/apps/console/_console.ts index 72e0b0a0123c6..3f873850ff193 100644 --- a/test/functional/apps/console/_console.ts +++ b/test/functional/apps/console/_console.ts @@ -85,12 +85,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Ensure that the text area can be interacted with await PageObjects.console.dismissTutorial(); expect(await PageObjects.console.hasAutocompleter()).to.be(false); + await PageObjects.console.enterRequest(); await PageObjects.console.promptAutocomplete(); await retry.waitFor('autocomplete to be visible', () => PageObjects.console.hasAutocompleter() ); }); + it('should add comma after previous non empty line on autocomplete', async () => { + const LINE_NUMBER = 2; + + await PageObjects.console.dismissTutorial(); + await PageObjects.console.clearTextArea(); + await PageObjects.console.enterText(`{\n\t"query": {\n\t\t"match": {}`); + await PageObjects.console.pressEnter(); + await PageObjects.console.pressEnter(); + await PageObjects.console.pressEnter(); + await PageObjects.console.promptAutocomplete(); + + await retry.try(async () => { + const textOfPreviousNonEmptyLine = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); + log.debug(textOfPreviousNonEmptyLine); + const lastChar = textOfPreviousNonEmptyLine.charAt(textOfPreviousNonEmptyLine.length - 1); + expect(lastChar).to.be.equal(','); + }); + }); + describe('with a data URI in the load_from query', () => { it('loads the data from the URI', async () => { await PageObjects.common.navigateToApp('console', { diff --git a/test/functional/apps/dashboard/panel_cloning.ts b/test/functional/apps/dashboard/panel_cloning.ts index 1be9175e45ad9..a2cadd89f486a 100644 --- a/test/functional/apps/dashboard/panel_cloning.ts +++ b/test/functional/apps/dashboard/panel_cloning.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects([ 'dashboard', 'header', @@ -53,6 +54,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(panelDimensions[0]).to.eql(panelDimensions[1]); }); + it('clone of a by reference embeddable is by value', async () => { + const panelName = PIE_CHART_VIS_NAME.replace(/\s+/g, ''); + const clonedPanel = await testSubjects.find(`embeddablePanelHeading-${panelName}(copy)`); + const descendants = await testSubjects.findAllDescendant( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + clonedPanel + ); + expect(descendants.length).to.equal(0); + }); + it('gives a correct title to the clone of a clone', async () => { const initialPanelTitles = await PageObjects.dashboard.getPanelTitles(); const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1]; @@ -65,5 +76,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { PIE_CHART_VIS_NAME + ' (copy 1)' ); }); + + it('clone of a by value embeddable is by value', async () => { + const panelName = PIE_CHART_VIS_NAME.replace(/\s+/g, ''); + const clonedPanel = await testSubjects.find(`embeddablePanelHeading-${panelName}(copy1)`); + const descendants = await testSubjects.findAllDescendant( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + clonedPanel + ); + expect(descendants.length).to.equal(0); + }); }); } diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 77c87f6066e85..f8a64c0032bb2 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -84,13 +84,8 @@ export class ConsolePageObject extends FtrService { } public async promptAutocomplete() { - // This focusses the cursor on the bottom of the text area - const editor = await this.getEditor(); - const content = await editor.findByCssSelector('.ace_content'); - await content.click(); const textArea = await this.testSubjects.find('console-textarea'); // There should be autocomplete for this on all license levels - await textArea.pressKeys('\nGET s'); await textArea.pressKeys([Key.CONTROL, Key.SPACE]); } @@ -101,4 +96,47 @@ export class ConsolePageObject extends FtrService { return false; } } + + public async enterRequest(request: string = '\nGET _search') { + const textArea = await this.getEditorTextArea(); + await textArea.pressKeys(request); + await textArea.pressKeys(Key.ENTER); + } + + public async enterText(text: string) { + const textArea = await this.getEditorTextArea(); + await textArea.pressKeys(text); + } + + private async getEditorTextArea() { + // This focusses the cursor on the bottom of the text area + const editor = await this.getEditor(); + const content = await editor.findByCssSelector('.ace_content'); + await content.click(); + return await this.testSubjects.find('console-textarea'); + } + + public async getVisibleTextAt(lineIndex: number) { + const editor = await this.getEditor(); + const lines = await editor.findAllByClassName('ace_line_group'); + + if (lines.length < lineIndex) { + throw new Error(`No line with index: ${lineIndex}`); + } + + const line = lines[lineIndex]; + const text = await line.getVisibleText(); + + return text.trim(); + } + + public async pressEnter() { + const textArea = await this.testSubjects.find('console-textarea'); + await textArea.pressKeys(Key.ENTER); + } + + public async clearTextArea() { + const textArea = await this.getEditorTextArea(); + await textArea.clearValueWithKeyboard(); + } } diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index 0f4034a218748..2b7a71ba13acf 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -66,6 +66,8 @@ const servicesRoute = createApmServerRoute({ const searchAggregatedTransactions = await getSearchAggregatedTransactions({ ...setup, kuery, + start, + end, }); return getServices({ diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx index ca45191dd4cb1..df3374c848e56 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -49,6 +49,23 @@ describe('createCommentUserActionBuilder', () => { expect(screen.getByText('edited comment')).toBeInTheDocument(); }); + it('renders correctly when deleting a comment', async () => { + const userAction = getUserAction('comment', Actions.delete); + const builder = createCommentUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('removed comment')).toBeInTheDocument(); + }); + it('renders correctly a user comment', async () => { const userAction = getUserAction('comment', Actions.create, { commentId: basicCase.comments[0].id, diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 79df2aaca9978..9c0b539720748 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -17,6 +17,24 @@ import { createAlertAttachmentUserActionBuilder } from './alert'; import { createActionAttachmentUserActionBuilder } from './actions'; const getUpdateLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; +const getDeleteLabelTitle = () => `${i18n.REMOVED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + +const getDeleteCommentUserAction = ({ + userAction, + handleOutlineComment, +}: { + userAction: UserActionResponse; +} & Pick): EuiCommentProps[] => { + const label = getDeleteLabelTitle(); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'cross', + }); + + return commonBuilder.build(); +}; const getCreateCommentUserAction = ({ userAction, @@ -101,8 +119,12 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({ }) => ({ build: () => { const commentUserAction = userAction as UserActionResponse; - const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId); + if (commentUserAction.action === Actions.delete) { + return getDeleteCommentUserAction({ userAction: commentUserAction, handleOutlineComment }); + } + + const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId); if (comment == null) { return []; } diff --git a/x-pack/plugins/cases/public/containers/translations.ts b/x-pack/plugins/cases/public/containers/translations.ts index eb7e5ff99e518..72aeb66772c52 100644 --- a/x-pack/plugins/cases/public/containers/translations.ts +++ b/x-pack/plugins/cases/public/containers/translations.ts @@ -81,6 +81,6 @@ export const SYNC_CASE = (caseTitle: string) => export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate( 'xpack.cases.containers.statusChangeToasterText', { - defaultMessage: 'Alerts in this case have been also had their status updated', + defaultMessage: 'Updated the statuses of attached alerts.', } ); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index fd7b2eddfe7c9..23b57004ca4d7 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -20,12 +20,15 @@ import { UpdateKey } from './types'; import { allCases, basicCase } from './mock'; import * as api from './api'; import { TestProviders } from '../common/mock'; +import { useToasts } from '../common/lib/kibana'; jest.mock('./api'); jest.mock('../common/lib/kibana'); describe('useGetCases', () => { const abortCtrl = new AbortController(); + const addSuccess = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() }); beforeEach(() => { jest.clearAllMocks(); @@ -113,6 +116,9 @@ describe('useGetCases', () => { abortCtrl.signal ); }); + expect(addSuccess).toHaveBeenCalledWith({ + title: `Updated "${basicCase.title}"`, + }); }); it('refetch cases', async () => { diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index eacad3c8ca020..de0f0f514da1b 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -212,6 +212,7 @@ export const useGetCases = ( const dispatchUpdateCaseProperty = useCallback( async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { + const caseData = state.data.cases.find((caseInfo) => caseInfo.id === caseId); try { didCancelUpdateCases.current = false; abortCtrlUpdateCases.current.abort(); @@ -230,6 +231,15 @@ export const useGetCases = ( dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); fetchCases(state.filterOptions, state.queryParams); refetchCasesStatus(); + if (caseData) { + toasts.addSuccess({ + title: i18n.UPDATED_CASE(caseData.title), + text: + updateKey === 'status' && caseData.totalAlerts > 0 + ? i18n.STATUS_CHANGED_TOASTER_TEXT + : undefined, + }); + } } } catch (error) { if (!didCancelUpdateCases.current) { @@ -240,8 +250,7 @@ export const useGetCases = ( } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [state.filterOptions, state.queryParams] + [fetchCases, state.data, state.filterOptions, state.queryParams, toasts] ); const refetchCases = useCallback(() => { diff --git a/x-pack/plugins/cases/public/containers/utils.test.ts b/x-pack/plugins/cases/public/containers/utils.test.ts index 3ee6182cb053d..0dd55fbe8aaca 100644 --- a/x-pack/plugins/cases/public/containers/utils.test.ts +++ b/x-pack/plugins/cases/public/containers/utils.test.ts @@ -97,7 +97,7 @@ describe('utils', () => { expect(toast).toEqual({ title: 'Updated "My case"', - text: 'Alerts in this case have been also had their status updated', + text: 'Updated the statuses of attached alerts.', }); }); diff --git a/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts b/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts index f8336424be17d..239277259539e 100644 --- a/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/actions/actions.test.ts @@ -5,13 +5,13 @@ * 2.0. */ +import { CaseResponse } from '../../../../common/api'; import { createCasesClientMock } from '../../mocks'; import { CasesClientArgs } from '../../types'; import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; import { createAttachmentServiceMock } from '../../../services/mocks'; import { Actions } from './actions'; -import { ICaseResponse } from '../../typedoc_interfaces'; const clientMock = createCasesClientMock(); const attachmentService = createAttachmentServiceMock(); @@ -30,7 +30,7 @@ const constructorOptions = { caseId: 'test-id', casesClient: clientMock, clientA describe('Actions', () => { beforeAll(() => { getAuthorizationFilter.mockResolvedValue({}); - clientMock.cases.get.mockResolvedValue({ id: '' } as unknown as ICaseResponse); + clientMock.cases.get.mockResolvedValue({ id: '' } as unknown as CaseResponse); }); beforeEach(() => { diff --git a/x-pack/plugins/cases/server/client/metrics/connectors.test.ts b/x-pack/plugins/cases/server/client/metrics/connectors.test.ts new file mode 100644 index 0000000000000..0b15b94525b02 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/connectors.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 { createCasesClientMock } from '../mocks'; +import { CasesClientArgs } from '../types'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { createUserActionServiceMock } from '../../services/mocks'; +import { Connectors } from './connectors'; + +describe('Connectors', () => { + const clientMock = createCasesClientMock(); + const logger = loggingSystemMock.createLogger(); + const userActionService = createUserActionServiceMock(); + const getAuthorizationFilter = jest.fn().mockResolvedValue({}); + + const clientArgs = { + logger, + userActionService, + authorization: { getAuthorizationFilter }, + } as unknown as CasesClientArgs; + + const constructorOptions = { caseId: 'test-id', casesClient: clientMock, clientArgs }; + + beforeAll(() => {}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns zero as total if the are no connectors', async () => { + userActionService.getUniqueConnectors.mockResolvedValue([]); + const handler = new Connectors(constructorOptions); + expect(await handler.compute()).toEqual({ connectors: { total: 0 } }); + }); + + it('returns the correct number of connectors', async () => { + userActionService.getUniqueConnectors.mockResolvedValue([ + { id: '865b6040-7533-11ec-8bcc-a9fc6f9d63b2' }, + { id: '915c2600-7533-11ec-8bcc-a9fc6f9d63b2' }, + { id: 'b2635b10-63e1-11ec-90af-6fe7d490ff66' }, + ]); + + const handler = new Connectors(constructorOptions); + expect(await handler.compute()).toEqual({ connectors: { total: 3 } }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/connectors.ts b/x-pack/plugins/cases/server/client/metrics/connectors.ts index 137bcdd61cdec..3dd29b8b6dda7 100644 --- a/x-pack/plugins/cases/server/client/metrics/connectors.ts +++ b/x-pack/plugins/cases/server/client/metrics/connectors.ts @@ -6,6 +6,8 @@ */ import { CaseMetricsResponse } from '../../../common/api'; +import { Operations } from '../../authorization'; +import { createCaseError } from '../../common/error'; import { BaseHandler } from './base_handler'; import { BaseHandlerCommonOptions } from './types'; @@ -15,8 +17,31 @@ export class Connectors extends BaseHandler { } public async compute(): Promise { - return { - connectors: { total: 0 }, - }; + const { unsecuredSavedObjectsClient, authorization, userActionService, logger } = + this.options.clientArgs; + + const { caseId } = this.options; + + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.getUserActionMetrics + ); + + const uniqueConnectors = await userActionService.getUniqueConnectors({ + unsecuredSavedObjectsClient, + caseId, + filter: authorizationFilter, + }); + + try { + return { + connectors: { total: uniqueConnectors.length }, + }; + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve total connectors metrics for case id: ${caseId}: ${error}`, + error, + logger, + }); + } } } diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 751b7ef8882f1..169e3bd5dc8e0 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -93,6 +93,7 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => { getAll: jest.fn(), bulkCreate: jest.fn(), findStatusChanges: jest.fn(), + getUniqueConnectors: jest.fn(), }; // the cast here is required because jest.Mocked tries to include private members and would throw an error diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index 8b42523f5ece2..2ccfaf54dd9fe 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -924,5 +924,165 @@ describe('CaseUserActionService', () => { ); }); }); + + describe('getUniqueConnectors', () => { + const findResponse = createUserActionFindSO(createConnectorUserAction()); + const aggregationResponse = { + aggregations: { + references: { + doc_count: 8, + connectors: { + doc_count: 4, + ids: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '865b6040-7533-11ec-8bcc-a9fc6f9d63b2', + doc_count: 2, + docs: {}, + }, + { + key: '915c2600-7533-11ec-8bcc-a9fc6f9d63b2', + doc_count: 1, + docs: {}, + }, + { + key: 'b2635b10-63e1-11ec-90af-6fe7d490ff66', + doc_count: 1, + docs: {}, + }, + ], + }, + }, + }, + }, + }; + + beforeAll(() => { + unsecuredSavedObjectsClient.find.mockResolvedValue( + findResponse as unknown as Promise + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('it returns an empty array if the response is not valid', async () => { + const res = await service.getUniqueConnectors({ + unsecuredSavedObjectsClient, + caseId: '123', + }); + + expect(res).toEqual([]); + }); + + it('it returns the connectors', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValue({ + ...findResponse, + ...aggregationResponse, + } as unknown as Promise); + + const res = await service.getUniqueConnectors({ + unsecuredSavedObjectsClient, + caseId: '123', + }); + + expect(res).toEqual([ + { id: '865b6040-7533-11ec-8bcc-a9fc6f9d63b2' }, + { id: '915c2600-7533-11ec-8bcc-a9fc6f9d63b2' }, + { id: 'b2635b10-63e1-11ec-90af-6fe7d490ff66' }, + ]); + }); + + it('it returns the unique connectors', async () => { + await service.getUniqueConnectors({ + unsecuredSavedObjectsClient, + caseId: '123', + }); + + expect(unsecuredSavedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "aggs": Object { + "references": Object { + "aggregations": Object { + "connectors": Object { + "aggregations": Object { + "ids": Object { + "terms": Object { + "field": "cases-user-actions.references.id", + "size": 100, + }, + }, + }, + "filter": Object { + "term": Object { + "cases-user-actions.references.type": "action", + }, + }, + }, + }, + "nested": Object { + "path": "cases-user-actions.references", + }, + }, + }, + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "type": "literal", + "value": "connector", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "type": "literal", + "value": "create_case", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + "hasReference": Object { + "id": "123", + "type": "cases", + }, + "page": 1, + "perPage": 1, + "sortField": "created_at", + "type": "cases-user-actions", + }, + ] + `); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 9ff3da2f230e3..130fe47645278 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -16,6 +16,7 @@ import { SavedObjectsUpdateResponse, } from 'kibana/server'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { KueryNode } from '@kbn/es-query'; import { isConnectorUserAction, @@ -434,6 +435,83 @@ export class CaseUserActionService { throw error; } } + + public async getUniqueConnectors({ + caseId, + filter, + unsecuredSavedObjectsClient, + }: { + caseId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + filter?: KueryNode; + }): Promise> { + try { + this.log.debug(`Attempting to count connectors for case id ${caseId}`); + const connectorsFilter = buildFilter({ + filters: [ActionTypes.connector, ActionTypes.create_case], + field: 'type', + operator: 'or', + type: CASE_USER_ACTION_SAVED_OBJECT, + }); + + const combinedFilter = combineFilters([connectorsFilter, filter]); + + const response = await unsecuredSavedObjectsClient.find< + CaseUserActionAttributesWithoutConnectorId, + { references: { connectors: { ids: { buckets: Array<{ key: string }> } } } } + >({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: this.buildCountConnectorsAggs(), + filter: combinedFilter, + }); + + return ( + response.aggregations?.references?.connectors?.ids?.buckets?.map(({ key }) => ({ + id: key, + })) ?? [] + ); + } catch (error) { + this.log.error(`Error while counting connectors for case id ${caseId}: ${error}`); + throw error; + } + } + + private buildCountConnectorsAggs( + /** + * It is high unlikely for a user to have more than + * 100 connectors attached to a case + */ + size: number = 100 + ): Record { + return { + references: { + nested: { + path: `${CASE_USER_ACTION_SAVED_OBJECT}.references`, + }, + aggregations: { + connectors: { + filter: { + term: { + [`${CASE_USER_ACTION_SAVED_OBJECT}.references.type`]: 'action', + }, + }, + aggregations: { + ids: { + terms: { + field: `${CASE_USER_ACTION_SAVED_OBJECT}.references.id`, + size, + }, + }, + }, + }, + }, + }, + }; + } } export function transformFindResponseToExternalModel( diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index cc661ca6ffeff..ab25ecff4045d 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -21,6 +21,13 @@ export const FILE_SIZE_DISPLAY_FORMAT = '0,0.[0] b'; // index as having been created by the File Data Visualizer. export const INDEX_META_DATA_CREATED_BY = 'file-data-visualizer'; +export const FILE_FORMATS = { + DELIMITED: 'delimited', + NDJSON: 'ndjson', + SEMI_STRUCTURED_TEXT: 'semi_structured_text', + // XML: 'xml', +}; + export const JOB_FIELD_TYPES = { BOOLEAN: 'boolean', DATE: 'date', diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts index e021de5e5beca..84f67c3222885 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/combined_fields/utils.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { cloneDeep } from 'lodash'; import uuid from 'uuid/v4'; import { CombinedField } from './types'; -import { +import type { FindFileStructureResponse, IngestPipeline, Mappings, diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts index 3dd889976eb11..5f9317be6d7c5 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/utils/utils.ts @@ -6,12 +6,8 @@ */ import { isEqual } from 'lodash'; -import { - AnalysisResult, - InputOverrides, - MB, - FILE_FORMATS, -} from '../../../../../../file_upload/common'; +import type { AnalysisResult, InputOverrides } from '../../../../../../file_upload/common'; +import { MB, FILE_FORMATS } from '../../../../../common'; export const DEFAULT_LINES_TO_SAMPLE = 1000; const UPLOAD_SIZE_MB = 5; diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx index b00266e5d3580..b754bfd4691ec 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/analysis_summary/analysis_summary.tsx @@ -9,7 +9,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import React, { FC } from 'react'; import { EuiTitle, EuiSpacer, EuiDescriptionList } from '@elastic/eui'; -import { FindFileStructureResponse, FILE_FORMATS } from '../../../../../../file_upload/common'; +import type { FindFileStructureResponse } from '../../../../../../file_upload/common'; +import { FILE_FORMATS } from '../../../../../common'; export const AnalysisSummary: FC<{ results: FindFileStructureResponse }> = ({ results }) => { const items = createDisplayItems(results); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/options/options.ts b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/options/options.ts index 066778705724d..e0c3e7f2d58cc 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/options/options.ts +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/options/options.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FILE_FORMATS } from '../../../../../../../file_upload/common'; +import { FILE_FORMATS } from '../../../../../../common/'; import { TIMESTAMP_OPTIONS, diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.js index 84e207dd5b01e..d0213813c8f16 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.js @@ -23,7 +23,7 @@ import { EuiTextArea, } from '@elastic/eui'; -import { FILE_FORMATS } from '../../../../../../file_upload/common'; +import { FILE_FORMATS } from '../../../../../common'; import { getFormatOptions, diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.test.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.test.js index aac12c386f346..8e9b0d0806fc1 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.test.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/edit_flyout/overrides.test.js @@ -7,7 +7,7 @@ import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest'; import React from 'react'; -import { FILE_FORMATS } from '../../../../../../file_upload/common/constants'; +import { FILE_FORMATS } from '../../../../../common'; import { Overrides } from './overrides'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx index 5abf3652d6622..44c5f276cd20c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/boosts/boost_item_content/boost_item_content.tsx @@ -55,7 +55,7 @@ export const BoostItemContent: React.FC = ({ boost, index, name }) => { fullWidth > new ListPlugin(initializerContext); diff --git a/x-pack/plugins/lists/server/mocks.ts b/x-pack/plugins/lists/server/mocks.ts index 9cc130fc7d0d3..642fff817949e 100644 --- a/x-pack/plugins/lists/server/mocks.ts +++ b/x-pack/plugins/lists/server/mocks.ts @@ -7,7 +7,10 @@ import { ListPluginSetup } from './types'; import { getListClientMock } from './services/lists/list_client.mock'; -import { getExceptionListClientMock } from './services/exception_lists/exception_list_client.mock'; +import { + getCreateExceptionListItemOptionsMock, + getExceptionListClientMock, +} from './services/exception_lists/exception_list_client.mock'; const createSetupMock = (): jest.Mocked => { const mock: jest.Mocked = { @@ -20,6 +23,7 @@ const createSetupMock = (): jest.Mocked => { export const listMock = { createSetup: createSetupMock, + getCreateExceptionListItemOptionsMock, getExceptionListClient: getExceptionListClientMock, getListClient: getListClientMock, }; diff --git a/x-pack/plugins/lists/server/plugin.ts b/x-pack/plugins/lists/server/plugin.ts index 5e59cbe2ba719..c824c6889c362 100644 --- a/x-pack/plugins/lists/server/plugin.ts +++ b/x-pack/plugins/lists/server/plugin.ts @@ -114,6 +114,7 @@ export class ListPlugin implements Plugin new ExceptionListClient({ + request, savedObjectsClient, serverExtensionsClient: this.extensionPoints.getClient(), user, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts index ed072b0ed7a92..c94b956c92bf7 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.test.ts @@ -11,7 +11,7 @@ import { ExtensionPointStorageContextMock, createExtensionPointStorageMock, } from '../extension_points/extension_point_storage.mock'; -import type { ExtensionPointCallbackArgument } from '../extension_points'; +import type { ExtensionPointCallbackDataArgument } from '../extension_points'; import { getCreateExceptionListItemOptionsMock, @@ -112,10 +112,12 @@ describe('exception_list_client', () => { it('should validate extension point callback returned data and throw if not valid', async () => { const extensionPointCallback = getExtensionPointCallback(); extensionPointCallback.mockImplementation(async (args) => { - const { entries, ...rest } = args as ExtensionPointCallbackArgument; + const { + data: { entries, ...rest }, + } = args as { data: ExtensionPointCallbackDataArgument }; expect(entries).toBeTruthy(); // Test entries to exist since we exclude it. - return rest as ExtensionPointCallbackArgument; + return rest as ExtensionPointCallbackDataArgument; }); const methodResponsePromise = callExceptionListClientMethod(); @@ -126,6 +128,7 @@ describe('exception_list_client', () => { reason: ['Invalid value "undefined" supplied to "entries"'], }) ); + expect(extensionPointStorageContext.logger.error).toHaveBeenCalled(); }); it('should use data returned from extension point callbacks when saving', async () => { diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 061a4d305821b..61dd47867b3ff 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import { ExceptionListItemSchema, ExceptionListSchema, @@ -18,7 +18,10 @@ import { } from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; -import type { ExtensionPointStorageClientInterface } from '../extension_points'; +import type { + ExtensionPointStorageClientInterface, + ServerExtensionCallbackContext, +} from '../extension_points'; import { ConstructorOptions, @@ -81,17 +84,26 @@ export class ExceptionListClient { private readonly savedObjectsClient: SavedObjectsClientContract; private readonly serverExtensionsClient: ExtensionPointStorageClientInterface; private readonly enableServerExtensionPoints: boolean; + private readonly request?: KibanaRequest; constructor({ user, savedObjectsClient, serverExtensionsClient, enableServerExtensionPoints = true, + request, }: ConstructorOptions) { this.user = user; this.savedObjectsClient = savedObjectsClient; this.serverExtensionsClient = serverExtensionsClient; this.enableServerExtensionPoints = enableServerExtensionPoints; + this.request = request; + } + + private getServerExtensionCallbackContext(): ServerExtensionCallbackContext { + return { + request: this.request, + }; } /** @@ -404,6 +416,7 @@ export class ExceptionListClient { itemData = await this.serverExtensionsClient.pipeRun( 'exceptionsListPreCreateItem', itemData, + this.getServerExtensionCallbackContext(), (data) => { return validateData( createExceptionListItemSchema, @@ -470,6 +483,7 @@ export class ExceptionListClient { updatedItem = await this.serverExtensionsClient.pipeRun( 'exceptionsListPreUpdateItem', updatedItem, + this.getServerExtensionCallbackContext(), (data) => { return validateData( updateExceptionListItemSchema, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 93a45358daafa..e4fdd5ef2b5ce 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -7,7 +7,7 @@ import { Readable } from 'stream'; -import { SavedObjectsClientContract } from 'kibana/server'; +import type { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; import type { CreateCommentsArray, Description, @@ -58,6 +58,8 @@ export interface ConstructorOptions { serverExtensionsClient: ExtensionPointStorageClientInterface; /** Set to `false` if wanting to disable executing registered server extension points. Default is true. */ enableServerExtensionPoints?: boolean; + /** Should be provided when creating an instance from an HTTP request handler */ + request?: KibanaRequest; } export interface GetExceptionListOptions { diff --git a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage.mock.ts b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage.mock.ts index 2610e9808dac4..fb453e00492c7 100644 --- a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage.mock.ts +++ b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage.mock.ts @@ -7,11 +7,14 @@ import { MockedLogger, loggerMock } from '@kbn/logging/mocks'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; + import { ExtensionPointStorage } from './extension_point_storage'; import { - ExceptionListPreUpdateItemServerExtension, ExceptionsListPreCreateItemServerExtension, + ExceptionsListPreUpdateItemServerExtension, ExtensionPointStorageInterface, + ServerExtensionCallbackContext, } from './types'; export interface ExtensionPointStorageContextMock { @@ -21,7 +24,8 @@ export interface ExtensionPointStorageContextMock { /** An Exception List Item pre-create extension point added to the storage. Appends `-1` to the data's `name` attribute */ exceptionPreCreate: jest.Mocked; /** An Exception List Item pre-update extension point added to the storage. Appends `-2` to the data's `name` attribute */ - exceptionPreUpdate: jest.Mocked; + exceptionPreUpdate: jest.Mocked; + callbackContext: jest.Mocked; } export const createExtensionPointStorageMock = ( @@ -30,7 +34,7 @@ export const createExtensionPointStorageMock = ( const extensionPointStorage = new ExtensionPointStorage(logger); const exceptionPreCreate: ExtensionPointStorageContextMock['exceptionPreCreate'] = { - callback: jest.fn(async (data) => { + callback: jest.fn(async ({ data }) => { return { ...data, name: `${data.name}-1`, @@ -40,7 +44,7 @@ export const createExtensionPointStorageMock = ( }; const exceptionPreUpdate: ExtensionPointStorageContextMock['exceptionPreUpdate'] = { - callback: jest.fn(async (data) => { + callback: jest.fn(async ({ data }) => { return { ...data, name: `${data.name}-1`, @@ -53,6 +57,9 @@ export const createExtensionPointStorageMock = ( extensionPointStorage.add(exceptionPreUpdate); return { + callbackContext: { + request: httpServerMock.createKibanaRequest(), + }, exceptionPreCreate, exceptionPreUpdate, extensionPointStorage, diff --git a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.test.ts b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.test.ts index b7a687064e2e2..25d4a4902a0b7 100644 --- a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.test.ts +++ b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.test.ts @@ -13,11 +13,12 @@ import { DataValidationError } from '../exception_lists/utils/errors'; import { ExtensionPointError } from './errors'; import { - ExceptionListPreUpdateItemServerExtension, ExceptionsListPreCreateItemServerExtension, + ExceptionsListPreUpdateItemServerExtension, ExtensionPoint, ExtensionPointStorageClientInterface, ExtensionPointStorageInterface, + ServerExtensionCallbackContext, } from './types'; import { createExtensionPointStorageMock } from './extension_point_storage.mock'; @@ -25,6 +26,7 @@ describe('When using the ExtensionPointStorageClient', () => { let storageClient: ExtensionPointStorageClientInterface; let logger: ReturnType; let extensionPointStorage: ExtensionPointStorageInterface; + let callbackContext: ServerExtensionCallbackContext; let preCreateExtensionPointMock1: jest.Mocked; let extensionPointsMocks: Array>; let callbackRunLog: string; @@ -34,10 +36,10 @@ describe('When using the ExtensionPointStorageClient', () => { }; beforeEach(() => { - const storageContext = createExtensionPointStorageMock(); + const extensionPointStorageMock = createExtensionPointStorageMock(); callbackRunLog = ''; - ({ logger, extensionPointStorage } = storageContext); + ({ logger, extensionPointStorage, callbackContext } = extensionPointStorageMock); extensionPointStorage.clear(); // Generic callback function that also logs to the `callbackRunLog` its id, so we know the order in which they ran. @@ -48,12 +50,12 @@ describe('When using the ExtensionPointStorageClient', () => { A extends Parameters[0] = Parameters[0] >( id: number, - arg: A - ): Promise => { + { data }: A + ): Promise => { callbackRunLog += id; return { - ...arg, - name: `${arg.name}-${id}`, + ...data, + name: `${data.name}-${id}`, }; }; preCreateExtensionPointMock1 = { @@ -72,7 +74,7 @@ describe('When using the ExtensionPointStorageClient', () => { }, { callback: jest.fn( - callbackFn.bind(window, 3) as ExceptionListPreUpdateItemServerExtension['callback'] + callbackFn.bind(window, 3) as ExceptionsListPreUpdateItemServerExtension['callback'] ), type: 'exceptionsListPreUpdateItem', }, @@ -115,38 +117,70 @@ describe('When using the ExtensionPointStorageClient', () => { it('should run extension point callbacks serially', async () => { await storageClient.pipeRun( 'exceptionsListPreCreateItem', - createExceptionListItemOptionsMock + createExceptionListItemOptionsMock, + callbackContext ); expect(callbackRunLog).toEqual('1245'); }); - it('should pass the return value of one extensionPoint to the next', async () => { + it('should provide `context` to every callback', async () => { await storageClient.pipeRun( 'exceptionsListPreCreateItem', - createExceptionListItemOptionsMock + createExceptionListItemOptionsMock, + callbackContext ); + for (const extensionPointsMock of extensionPointsMocks) { + if (extensionPointsMock.type === 'exceptionsListPreCreateItem') { + expect(extensionPointsMock.callback).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + request: expect.any(Object), + }, + }) + ); + } + } + }); - expect(extensionPointsMocks[0].callback).toHaveBeenCalledWith( - createExceptionListItemOptionsMock + it('should pass the return value of one extensionPoint to the next', async () => { + await storageClient.pipeRun( + 'exceptionsListPreCreateItem', + createExceptionListItemOptionsMock, + callbackContext ); + + expect(extensionPointsMocks[0].callback).toHaveBeenCalledWith({ + context: callbackContext, + data: createExceptionListItemOptionsMock, + }); expect(extensionPointsMocks[1].callback).toHaveBeenCalledWith({ - ...createExceptionListItemOptionsMock, - name: `${createExceptionListItemOptionsMock.name}-1`, + context: callbackContext, + data: { + ...createExceptionListItemOptionsMock, + name: `${createExceptionListItemOptionsMock.name}-1`, + }, }); expect(extensionPointsMocks[3].callback).toHaveBeenCalledWith({ - ...createExceptionListItemOptionsMock, - name: `${createExceptionListItemOptionsMock.name}-1-2`, + context: callbackContext, + data: { + ...createExceptionListItemOptionsMock, + name: `${createExceptionListItemOptionsMock.name}-1-2`, + }, }); expect(extensionPointsMocks[4].callback).toHaveBeenCalledWith({ - ...createExceptionListItemOptionsMock, - name: `${createExceptionListItemOptionsMock.name}-1-2-4`, + context: callbackContext, + data: { + ...createExceptionListItemOptionsMock, + name: `${createExceptionListItemOptionsMock.name}-1-2-4`, + }, }); }); it('should return a data structure similar to the one provided initially', async () => { const result = await storageClient.pipeRun( 'exceptionsListPreCreateItem', - createExceptionListItemOptionsMock + createExceptionListItemOptionsMock, + callbackContext ); expect(result).toEqual({ @@ -155,36 +189,20 @@ describe('When using the ExtensionPointStorageClient', () => { }); }); - it("should log an error if extension point callback Throw's", async () => { - const extensionError = new Error('foo'); - preCreateExtensionPointMock1.callback.mockImplementation(async () => { - throw extensionError; - }); - - await storageClient.pipeRun( - 'exceptionsListPreCreateItem', - createExceptionListItemOptionsMock - ); - - expect(logger.error).toHaveBeenCalledWith(expect.any(ExtensionPointError)); - expect(logger.error.mock.calls[0][0]).toMatchObject({ meta: extensionError }); - }); - - it('should continue to other extension points after encountering one that `throw`s', async () => { + it('should stop execution of other extension points after encountering one that `throw`s', async () => { const extensionError = new Error('foo'); preCreateExtensionPointMock1.callback.mockImplementation(async () => { throw extensionError; }); - const result = await storageClient.pipeRun( + const resultPromise = storageClient.pipeRun( 'exceptionsListPreCreateItem', - createExceptionListItemOptionsMock + createExceptionListItemOptionsMock, + callbackContext ); - expect(result).toEqual({ - ...createExceptionListItemOptionsMock, - name: `${createExceptionListItemOptionsMock.name}-2-4-5`, - }); + await expect(resultPromise).rejects.toBe(extensionError); + expect(extensionPointsMocks[1].callback).not.toHaveBeenCalled(); }); it('should log an error and Throw if external callback returned invalid data', async () => { @@ -194,6 +212,7 @@ describe('When using the ExtensionPointStorageClient', () => { storageClient.pipeRun( 'exceptionsListPreCreateItem', createExceptionListItemOptionsMock, + callbackContext, () => { return validationError; } diff --git a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.ts b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.ts index e430972157b75..453d5deccc8c8 100644 --- a/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.ts +++ b/x-pack/plugins/lists/server/services/extension_points/extension_point_storage_client.ts @@ -9,10 +9,11 @@ import { Logger } from 'kibana/server'; import type { ExtensionPoint, - ExtensionPointCallbackArgument, + ExtensionPointCallbackDataArgument, ExtensionPointStorageClientInterface, ExtensionPointStorageInterface, NarrowExtensionPointToType, + ServerExtensionCallbackContext, } from './types'; import { ExtensionPointError } from './errors'; @@ -38,6 +39,7 @@ export class ExtensionPointStorageClient implements ExtensionPointStorageClientI * * @param extensionType * @param initialCallbackInput The initial argument given to the first extension point callback + * @param callbackContext * @param callbackResponseValidator A function to validate the returned data from an extension point callback */ async pipeRun< @@ -46,9 +48,10 @@ export class ExtensionPointStorageClient implements ExtensionPointStorageClientI P extends Parameters = Parameters >( extensionType: T, - initialCallbackInput: P[0], - callbackResponseValidator?: (data: P[0]) => Error | undefined - ): Promise { + initialCallbackInput: P[0]['data'], + callbackContext: ServerExtensionCallbackContext, + callbackResponseValidator?: (data: P[0]['data']) => Error | undefined + ): Promise { let inputArgument = initialCallbackInput; const externalExtensions = this.get(extensionType); @@ -60,26 +63,17 @@ export class ExtensionPointStorageClient implements ExtensionPointStorageClientI const extensionRegistrationSource = this.storage.getExtensionRegistrationSource(externalExtension); - try { - inputArgument = await externalExtension.callback( - inputArgument as ExtensionPointCallbackArgument - ); - } catch (error) { - // Log the error that the external callback threw and keep going with the running of others - this.logger?.error( - new ExtensionPointError( - `Extension point execution error for ${externalExtension.type}: ${extensionRegistrationSource}`, - error - ) - ); - } + inputArgument = await externalExtension.callback({ + context: callbackContext, + data: inputArgument as ExtensionPointCallbackDataArgument, + }); if (callbackResponseValidator) { // Before calling the next one, make sure the returned payload is valid const validationError = callbackResponseValidator(inputArgument); if (validationError) { - this.logger?.error( + this.logger.error( new ExtensionPointError( `Extension point for ${externalExtension.type} returned data that failed validation: ${extensionRegistrationSource}`, { diff --git a/x-pack/plugins/lists/server/services/extension_points/types.ts b/x-pack/plugins/lists/server/services/extension_points/types.ts index 7ae2b5971f825..3dcec1fae63e8 100644 --- a/x-pack/plugins/lists/server/services/extension_points/types.ts +++ b/x-pack/plugins/lists/server/services/extension_points/types.ts @@ -5,24 +5,51 @@ * 2.0. */ -import { PromiseType } from 'utility-types'; import { UnionToIntersection } from '@kbn/utility-types'; +import { KibanaRequest } from 'kibana/server'; import { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '../exception_lists/exception_list_client_types'; -export type ServerExtensionCallback = ( - args: A -) => Promise; +/** + * The `this` context provided to extension point's callback function + * NOTE: in order to access this context, callbacks **MUST** be defined using `function()` instead of arrow functions. + */ +export interface ServerExtensionCallbackContext { + /** + * The Lists plugin HTTP Request. May be undefined if the callback is executed from a area of code that + * is not triggered via one of the HTTP handlers + */ + request?: KibanaRequest; +} + +export type ServerExtensionCallback = (args: { + context: ServerExtensionCallbackContext; + data: A; +}) => Promise; interface ServerExtensionPointDefinition< T extends string, Args extends object | void = void, - Response = void + Response = Args > { type: T; + /** + * The callback that will be executed at the given extension point. The Function will be provided a context (`this)` + * that includes supplemental data associated with its type. In order to access that data, the callback **MUST** + * be defined using `function()` and NOT an arrow function. + * + * @example + * + * { + * type: 'some type', + * callback: function() { + * // this === context is available + * } + * } + */ callback: ServerExtensionCallback; } @@ -32,7 +59,6 @@ interface ServerExtensionPointDefinition< */ export type ExceptionsListPreCreateItemServerExtension = ServerExtensionPointDefinition< 'exceptionsListPreCreateItem', - CreateExceptionListItemOptions, CreateExceptionListItemOptions >; @@ -40,15 +66,14 @@ export type ExceptionsListPreCreateItemServerExtension = ServerExtensionPointDef * Extension point is triggered prior to updating the Exception List Item. Throw'ing will cause the * update operation to fail */ -export type ExceptionListPreUpdateItemServerExtension = ServerExtensionPointDefinition< +export type ExceptionsListPreUpdateItemServerExtension = ServerExtensionPointDefinition< 'exceptionsListPreUpdateItem', - UpdateExceptionListItemOptions, UpdateExceptionListItemOptions >; export type ExtensionPoint = | ExceptionsListPreCreateItemServerExtension - | ExceptionListPreUpdateItemServerExtension; + | ExceptionsListPreUpdateItemServerExtension; /** * A Map of extension point type and associated Set of callbacks @@ -57,6 +82,7 @@ export type ExtensionPoint = * Registration function for server-side extension points */ export type ListsServerExtensionRegistrar = (extension: ExtensionPoint) => void; + export type NarrowExtensionPointToType = { type: T; } & ExtensionPoint; @@ -65,8 +91,8 @@ export type NarrowExtensionPointToType = { * An intersection of all callback arguments for use internally when * casting (ex. in `ExtensionPointStorageClient#pipeRun()` */ -export type ExtensionPointCallbackArgument = UnionToIntersection< - PromiseType> +export type ExtensionPointCallbackDataArgument = UnionToIntersection< + Parameters[0]['data'] >; export interface ExtensionPointStorageClientInterface { @@ -80,9 +106,10 @@ export interface ExtensionPointStorageClientInterface { P extends Parameters = Parameters >( extensionType: T, - initialCallbackInput: P[0], - callbackResponseValidator?: (data: P[0]) => Error | undefined - ): Promise; + initialCallbackInput: P[0]['data'], + callbackContext: ServerExtensionCallbackContext, + callbackResponseValidator?: (data: P[0]['data']) => Error | undefined + ): Promise; } export interface ExtensionPointStorageInterface { diff --git a/x-pack/plugins/lists/server/types.ts b/x-pack/plugins/lists/server/types.ts index cdaa81f697117..4eece62b9b0a3 100644 --- a/x-pack/plugins/lists/server/types.ts +++ b/x-pack/plugins/lists/server/types.ts @@ -75,7 +75,7 @@ export type ContextProviderReturn = Promise; export type { ExtensionPoint, - ExceptionListPreUpdateItemServerExtension, + ExceptionsListPreUpdateItemServerExtension, ExceptionsListPreCreateItemServerExtension, ListsServerExtensionRegistrar, } from './services/extension_points'; diff --git a/x-pack/plugins/maps/common/index.ts b/x-pack/plugins/maps/common/index.ts index f3b8595efc8d1..3e26cdc82fbbb 100644 --- a/x-pack/plugins/maps/common/index.ts +++ b/x-pack/plugins/maps/common/index.ts @@ -33,4 +33,5 @@ export type { TooltipFeature, VectorLayerDescriptor, VectorStyleDescriptor, + VectorSourceRequestMeta, } from './descriptor_types'; diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts index 5cca321482a00..4decdcb2b2d28 100644 --- a/x-pack/plugins/ml/common/constants/anomalies.ts +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -31,6 +31,25 @@ export const SEVERITY_COLORS = { BLANK: '#ffffff', }; +export const SEVERITY_COLOR_RAMP = [ + { + stop: ANOMALY_THRESHOLD.LOW, + color: SEVERITY_COLORS.WARNING, + }, + { + stop: ANOMALY_THRESHOLD.MINOR, + color: SEVERITY_COLORS.MINOR, + }, + { + stop: ANOMALY_THRESHOLD.MAJOR, + color: SEVERITY_COLORS.MAJOR, + }, + { + stop: ANOMALY_THRESHOLD.CRITICAL, + color: SEVERITY_COLORS.CRITICAL, + }, +]; + export const ANOMALY_RESULT_TYPE = { BUCKET: 'bucket', RECORD: 'record', diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index f13537892875d..0a4bc0b165e02 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -29,6 +29,11 @@ export const JOB_MAP_NODE_TYPES = { TRAINED_MODEL: 'trainedModel', } as const; +export const INDEX_CREATED_BY = { + FILE_DATA_VISUALIZER: 'file-data-visualizer', + DATA_FRAME_ANALYTICS: 'data-frame-analytics', +} as const; + export const BUILT_IN_MODEL_TAG = 'prepackaged'; export type JobMapNodeTypes = typeof JOB_MAP_NODE_TYPES[keyof typeof JOB_MAP_NODE_TYPES]; diff --git a/x-pack/plugins/ml/common/constants/messages.test.ts b/x-pack/plugins/ml/common/constants/messages.test.ts index 13ef103ae0ff1..e77de53b5cb70 100644 --- a/x-pack/plugins/ml/common/constants/messages.test.ts +++ b/x-pack/plugins/ml/common/constants/messages.test.ts @@ -32,7 +32,7 @@ describe('Constants: Messages parseMessages()', () => { id: 'detectors_function_not_empty', status: 'success', text: 'Presence of detector functions validated in all detectors.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-detectors', }, { bucketSpan: '15m', @@ -40,7 +40,7 @@ describe('Constants: Messages parseMessages()', () => { id: 'success_bucket_span', status: 'success', text: 'Format of "15m" is valid and passed validation checks.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-bucket-span', }, { heading: 'Time range', @@ -53,7 +53,7 @@ describe('Constants: Messages parseMessages()', () => { id: 'success_mml', status: 'success', text: 'Valid and within the estimated model memory limit.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-model-memory-limits', }, ]); }); @@ -71,7 +71,7 @@ describe('Constants: Messages parseMessages()', () => { id: 'detectors_function_not_empty', status: 'success', text: 'Presence of detector functions validated in all detectors.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-detectors', }, { bucketSpan: '15m', @@ -103,7 +103,7 @@ describe('Constants: Messages parseMessages()', () => { id: 'detectors_function_not_empty', status: 'success', text: 'Presence of detector functions validated in all detectors.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-detectors', }, { id: 'cardinality_model_plot_high', @@ -115,14 +115,14 @@ describe('Constants: Messages parseMessages()', () => { id: 'cardinality_partition_field', status: 'warning', text: 'Cardinality of partition_field "order_id" is above 1000 and might result in high memory usage.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-cardinality', }, { heading: 'Bucket span', id: 'bucket_span_high', status: 'info', text: 'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-bucket-span', }, { bucketSpanCompareFactor: 25, @@ -136,14 +136,14 @@ describe('Constants: Messages parseMessages()', () => { id: 'success_influencers', status: 'success', text: 'Influencer configuration passed the validation checks.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-influencers', }, { id: 'half_estimated_mml_greater_than_mml', mml: '1MB', status: 'warning', text: 'The specified model memory limit is less than half of the estimated model memory limit and will likely hit the hard limit.', - url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-finding-anomalies.html', + url: 'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-ad-run-jobs.html#ml-ad-model-memory-limits', }, { id: 'missing_summary_count_field_name', diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index c76b662df7a5a..de5989d92d208 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -7,7 +7,12 @@ export { ES_CLIENT_TOTAL_HITS_RELATION } from './types/es_client'; export type { ChartData } from './types/field_histograms'; -export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD, SEVERITY_COLORS } from './constants/anomalies'; +export { + ANOMALY_SEVERITY, + ANOMALY_THRESHOLD, + SEVERITY_COLOR_RAMP, + SEVERITY_COLORS, +} from './constants/anomalies'; export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; export { isPopulatedObject } from './util/object_utils'; export { composeValidators, patternValidator } from './util/validators'; diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index e66d8de5bd15e..f1991118d4e36 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -73,6 +73,12 @@ export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean return isMappable; } +// Returns a boolean indicating whether the specified job is suitable for maps plugin. +export function isJobWithGeoData(job: Job): boolean { + const { detectors } = job.analysis_config; + return detectors.some((detector) => detector.function === ML_JOB_AGGREGATION.LAT_LONG); +} + /** * Validates that composite definition only have sources that are only terms and date_histogram * if composite is defined. diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index e61549efe8adc..d38a37716b82c 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -28,7 +28,7 @@ import { mlApiServicesProvider } from './services/ml_api_service'; import { HttpService } from './services/http_service'; import { ML_APP_LOCATOR, ML_PAGES } from '../../common/constants/locator'; -export type MlDependencies = Omit & +export type MlDependencies = Omit & MlStartDependencies; interface AppProps { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts index 7f3378fc1c858..51b9732aebdd0 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -6,29 +6,11 @@ */ import { FIELD_ORIGIN, LAYER_TYPE, STYLE_TYPE } from '../../../../../maps/common'; -import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; +import { SEVERITY_COLOR_RAMP } from '../../../../common'; import { AnomaliesTableData } from '../explorer_utils'; const FEATURE = 'Feature'; const POINT = 'Point'; -const SEVERITY_COLOR_RAMP = [ - { - stop: ANOMALY_THRESHOLD.LOW, - color: SEVERITY_COLORS.WARNING, - }, - { - stop: ANOMALY_THRESHOLD.MINOR, - color: SEVERITY_COLORS.MINOR, - }, - { - stop: ANOMALY_THRESHOLD.MAJOR, - color: SEVERITY_COLORS.MAJOR, - }, - { - stop: ANOMALY_THRESHOLD.CRITICAL, - color: SEVERITY_COLORS.CRITICAL, - }, -]; function getAnomalyFeatures( anomalies: AnomaliesTableData['anomalies'], diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 96c5e1abce170..f1492074ed762 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -45,6 +45,13 @@ export const jobsApiProvider = (httpService: HttpService) => ({ }); }, + jobIdsWithGeo() { + return httpService.http({ + path: `${ML_BASE_PATH}/jobs/jobs_with_geo`, + method: 'GET', + }); + }, + jobsWithTimerange(dateFormatTz: string) { const body = JSON.stringify({ dateFormatTz }); return httpService.http<{ diff --git a/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx b/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx new file mode 100644 index 0000000000000..aacc8e37d935b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_job_selector.tsx @@ -0,0 +1,86 @@ +/* + * 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, { Component } from 'react'; + +import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import type { MlApiServices } from '../application/services/ml_api_service'; + +interface Props { + onJobChange: (jobId: string) => void; + mlJobsService: MlApiServices['jobs']; +} + +interface State { + jobId?: string; + jobIdList?: Array>; +} + +export class AnomalyJobSelector extends Component { + private _isMounted: boolean = false; + + state: State = {}; + + private async _loadJobs() { + const jobIdList = await this.props.mlJobsService.jobIdsWithGeo(); + const options = jobIdList.map((jobId) => { + return { label: jobId, value: jobId }; + }); + if (this._isMounted && !isEqual(options, this.state.jobIdList)) { + this.setState({ + jobIdList: options, + }); + } + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + this._loadJobs(); + } + + componentDidMount(): void { + this._isMounted = true; + this._loadJobs(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + onJobIdSelect = (selectedOptions: Array>) => { + const jobId: string = selectedOptions[0].value!; + if (this._isMounted) { + this.setState({ jobId }); + this.props.onJobChange(jobId); + } + }; + + render() { + if (!this.state.jobIdList) { + return null; + } + + return ( + + + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx new file mode 100644 index 0000000000000..b45cce35a89ba --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard.tsx @@ -0,0 +1,30 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { LAYER_WIZARD_CATEGORY } from '../../../maps/common'; +import type { LayerWizard } from '../../../maps/public'; + +export const anomalyLayerWizard: Partial = { + categories: [LAYER_WIZARD_CATEGORY.SOLUTIONS, LAYER_WIZARD_CATEGORY.ELASTICSEARCH], + description: i18n.translate('xpack.ml.maps.anomalyLayerDescription', { + defaultMessage: 'Display anomalies from a machine learning job', + }), + disabledReason: i18n.translate('xpack.ml.maps.anomalyLayerUnavailableMessage', { + defaultMessage: + 'Anomalies layers are a subscription feature. Ensure you have the right subscription and access to Machine Learning.', + }), + icon: 'outlierDetectionJob', + getIsDisabled: () => { + // return false by default + return false; + }, + title: i18n.translate('xpack.ml.maps.anomalyLayerTitle', { + defaultMessage: 'ML Anomalies', + }), + order: 100, +}; diff --git a/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx new file mode 100644 index 0000000000000..70a81c89816a2 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_layer_wizard_factory.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { htmlIdGenerator } from '@elastic/eui'; +import type { StartServicesAccessor } from 'kibana/public'; +import type { LayerWizard, RenderWizardArguments } from '../../../maps/public'; +import { FIELD_ORIGIN, LAYER_TYPE, STYLE_TYPE } from '../../../maps/common'; +import { SEVERITY_COLOR_RAMP } from '../../common'; +import { CreateAnomalySourceEditor } from './create_anomaly_source_editor'; +import { + VectorLayerDescriptor, + VectorStylePropertiesDescriptor, +} from '../../../maps/common/descriptor_types'; +import { AnomalySource, AnomalySourceDescriptor } from './anomaly_source'; + +import { HttpService } from '../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../plugin'; +import type { MlApiServices } from '../application/services/ml_api_service'; + +export const ML_ANOMALY = 'ML_ANOMALIES'; +const CUSTOM_COLOR_RAMP = { + type: STYLE_TYPE.DYNAMIC, + options: { + customColorRamp: SEVERITY_COLOR_RAMP, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: true, + }, +}; + +export class AnomalyLayerWizardFactory { + public readonly type = ML_ANOMALY; + + constructor( + private getStartServices: StartServicesAccessor, + private canGetJobs: boolean + ) { + this.canGetJobs = canGetJobs; + } + + private async getServices(): Promise<{ mlJobsService: MlApiServices['jobs'] }> { + const [coreStart] = await this.getStartServices(); + const { jobsApiProvider } = await import('../application/services/ml_api_service/jobs'); + + const httpService = new HttpService(coreStart.http); + const mlJobsService = jobsApiProvider(httpService); + + return { mlJobsService }; + } + + public async create(): Promise { + const { mlJobsService } = await this.getServices(); + const { anomalyLayerWizard } = await import('./anomaly_layer_wizard'); + + anomalyLayerWizard.getIsDisabled = () => !this.canGetJobs; + + anomalyLayerWizard.renderWizard = ({ previewLayers }: RenderWizardArguments) => { + const onSourceConfigChange = (sourceConfig: Partial | null) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + + const anomalyLayerDescriptor: VectorLayerDescriptor = { + id: htmlIdGenerator()(), + type: LAYER_TYPE.GEOJSON_VECTOR, + sourceDescriptor: AnomalySource.createDescriptor({ + jobId: sourceConfig.jobId, + typicalActual: sourceConfig.typicalActual, + }), + style: { + type: 'VECTOR', + properties: { + fillColor: CUSTOM_COLOR_RAMP, + lineColor: CUSTOM_COLOR_RAMP, + } as unknown as VectorStylePropertiesDescriptor, + isTimeAware: false, + }, + }; + + previewLayers([anomalyLayerDescriptor]); + }; + + return ( + + ); + }; + + return anomalyLayerWizard as LayerWizard; + } +} diff --git a/x-pack/plugins/ml/public/maps/anomaly_source.tsx b/x-pack/plugins/ml/public/maps/anomaly_source.tsx new file mode 100644 index 0000000000000..1159f97dcbec9 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source.tsx @@ -0,0 +1,360 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { ReactElement } from 'react'; +import { + FieldFormatter, + MAX_ZOOM, + MIN_ZOOM, + VECTOR_SHAPE_TYPE, + VectorSourceRequestMeta, +} from '../../../maps/common'; +import { AbstractSourceDescriptor, MapExtent } from '../../../maps/common/descriptor_types'; +import { ITooltipProperty } from '../../../maps/public'; +import { + AnomalySourceField, + AnomalySourceTooltipProperty, + ANOMALY_SOURCE_FIELDS, +} from './anomaly_source_field'; +import type { Adapters } from '../../../../../src/plugins/inspector/common/adapters'; +import type { GeoJsonWithMeta } from '../../../maps/public'; +import type { IField } from '../../../maps/public'; +import type { Attribution, ImmutableSourceProperty, PreIndexedShape } from '../../../maps/public'; +import type { SourceEditorArgs } from '../../../maps/public'; +import type { DataRequest } from '../../../maps/public'; +import type { IVectorSource, SourceStatus } from '../../../maps/public'; +import { ML_ANOMALY } from './anomaly_source_factory'; +import { getResultsForJobId, MlAnomalyLayers } from './util'; +import { UpdateAnomalySourceEditor } from './update_anomaly_source_editor'; +import type { MlApiServices } from '../application/services/ml_api_service'; + +export interface AnomalySourceDescriptor extends AbstractSourceDescriptor { + jobId: string; + typicalActual: MlAnomalyLayers; +} + +export class AnomalySource implements IVectorSource { + static mlResultsService: MlApiServices['results']; + static canGetJobs: boolean; + + static createDescriptor(descriptor: Partial) { + if (typeof descriptor.jobId !== 'string') { + throw new Error('Job id is required for anomaly layer creation'); + } + + return { + type: ML_ANOMALY, + jobId: descriptor.jobId, + typicalActual: descriptor.typicalActual || 'actual', + }; + } + + private readonly _descriptor: AnomalySourceDescriptor; + + constructor(sourceDescriptor: Partial, adapters?: Adapters) { + this._descriptor = AnomalySource.createDescriptor(sourceDescriptor); + } + // TODO: implement query awareness + async getGeoJsonWithMeta( + layerName: string, + searchFilters: VectorSourceRequestMeta, + registerCancelCallback: (callback: () => void) => void, + isRequestStillActive: () => boolean + ): Promise { + const results = await getResultsForJobId( + AnomalySource.mlResultsService, + this._descriptor.jobId, + this._descriptor.typicalActual, + searchFilters + ); + + return { + data: results, + meta: { + // Set this to true if data is incomplete (e.g. capping number of results to first 1k) + areResultsTrimmed: false, + }, + }; + } + + canFormatFeatureProperties(): boolean { + return false; + } + + cloneDescriptor(): AnomalySourceDescriptor { + return { + type: this._descriptor.type, + jobId: this._descriptor.jobId, + typicalActual: this._descriptor.typicalActual, + }; + } + + createField({ fieldName }: { fieldName: string }): IField { + if (fieldName !== 'record_score') { + throw new Error('Record score field name is required'); + } + return new AnomalySourceField({ source: this, field: fieldName }); + } + + async createFieldFormatter(field: IField): Promise { + return null; + } + + destroy(): void {} + + getApplyGlobalQuery(): boolean { + return false; + } + + getApplyForceRefresh(): boolean { + return false; + } + + getApplyGlobalTime(): boolean { + return true; + } + + async getAttributions(): Promise { + return []; + } + + async getBoundsForFilters( + boundsFilters: object, + registerCancelCallback: (callback: () => void) => void + ): Promise { + return null; + } + + async getDisplayName(): Promise { + return i18n.translate('xpack.ml.maps.anomalySource.displayLabel', { + defaultMessage: '{typicalActual} for {jobId}', + values: { + typicalActual: this._descriptor.typicalActual, + jobId: this._descriptor.jobId, + }, + }); + } + + getFieldByName(fieldName: string): IField | null { + if (fieldName === 'record_score') { + return new AnomalySourceField({ source: this, field: fieldName }); + } + return null; + } + + getSourceStatus() { + return { tooltipContent: null, areResultsTrimmed: true }; + } + + getType(): string { + return this._descriptor.type; + } + + isMvt() { + return true; + } + + showJoinEditor(): boolean { + // Ignore, only show if joins are enabled for current configuration + return false; + } + + getFieldNames(): string[] { + return Object.keys(ANOMALY_SOURCE_FIELDS); + } + + async getFields(): Promise { + return this.getFieldNames().map((field) => new AnomalySourceField({ source: this, field })); + } + + getGeoGridPrecision(zoom: number): number { + return 0; + } + + isBoundsAware(): boolean { + return false; + } + + async getImmutableProperties(): Promise { + return [ + { + label: i18n.translate('xpack.ml.maps.anomalySourcePropLabel', { + defaultMessage: 'Job Id', + }), + value: this._descriptor.jobId, + }, + ]; + } + + async isTimeAware(): Promise { + return true; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement | null { + return ( + + ); + } + + async supportsFitToBounds(): Promise { + // Return true if you can compute bounds of data + return true; + } + + async getLicensedFeatures(): Promise<[]> { + return []; + } + + getMaxZoom(): number { + return MAX_ZOOM; + } + + getMinZoom(): number { + return MIN_ZOOM; + } + + getSourceTooltipContent(sourceDataRequest?: DataRequest): SourceStatus { + return { + tooltipContent: i18n.translate('xpack.ml.maps.sourceTooltip', { + defaultMessage: 'Shows anomalies', + }), + // set to true if data is incomplete (we limit to first 1000 results) + areResultsTrimmed: true, + }; + } + + async getSupportedShapeTypes(): Promise { + return this._descriptor.typicalActual === 'connected' + ? [VECTOR_SHAPE_TYPE.LINE] + : [VECTOR_SHAPE_TYPE.POINT]; + } + + getSyncMeta(): object | null { + return { + jobId: this._descriptor.jobId, + typicalActual: this._descriptor.typicalActual, + }; + } + + async getTooltipProperties(properties: { [p: string]: any } | null): Promise { + const tooltipProperties: ITooltipProperty[] = []; + for (const key in properties) { + if (properties.hasOwnProperty(key)) { + const label = ANOMALY_SOURCE_FIELDS[key]?.label; + if (label) { + tooltipProperties.push(new AnomalySourceTooltipProperty(label, properties[key])); + } + } + } + return tooltipProperties; + } + + isFieldAware(): boolean { + return true; + } + + // This is for type-ahead support in the UX for by-value styling + async getValueSuggestions(field: IField, query: string): Promise { + return []; + } + + // ----------------- + // API ML probably can ignore + getAttributionProvider() { + return null; + } + + getIndexPatternIds(): string[] { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return []; + } + + getInspectorAdapters(): Adapters | undefined { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return undefined; + } + + getJoinsDisabledReason(): string | null { + // IGNORE: This is only relevant if your source can be joined to other data + return null; + } + + async getLeftJoinFields(): Promise { + // IGNORE: This is only relevant if your source can be joined to other data + return []; + } + + async getPreIndexedShape( + properties: { [p: string]: any } | null + ): Promise { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return null; + } + + getQueryableIndexPatternIds(): string[] { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return []; + } + + isESSource(): boolean { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return false; + } + + isFilterByMapBounds(): boolean { + // Only implement if you can query this data with a bounding-box + return false; + } + + isGeoGridPrecisionAware(): boolean { + // Ignore: only implement if your data is scale-dependent (probably not) + return false; + } + + isQueryAware(): boolean { + // IGNORE: This is only relevant if your source is backed by an index-pattern + return false; + } + + isRefreshTimerAware(): boolean { + // Allow force-refresh when user clicks "refresh" button in the global time-picker + return true; + } + + async getTimesliceMaskFieldName() { + return null; + } + + async supportsFeatureEditing() { + return false; + } + + hasTooltipProperties() { + return true; + } + + async addFeature() { + // should not be called + } + + async deleteFeature() { + // should not be called + } + + getUpdateDueToTimeslice() { + // TODO + return true; + } + + async getDefaultFields(): Promise>> { + return {}; + } +} diff --git a/x-pack/plugins/ml/public/maps/anomaly_source_factory.ts b/x-pack/plugins/ml/public/maps/anomaly_source_factory.ts new file mode 100644 index 0000000000000..71aefbfd0fdb9 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source_factory.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 { StartServicesAccessor } from 'kibana/public'; +import { HttpService } from '../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../plugin'; +import type { MlApiServices } from '../application/services/ml_api_service'; + +export const ML_ANOMALY = 'ML_ANOMALIES'; + +export class AnomalySourceFactory { + public readonly type = ML_ANOMALY; + + constructor( + private getStartServices: StartServicesAccessor, + private canGetJobs: boolean + ) { + this.canGetJobs = canGetJobs; + } + + private async getServices(): Promise<{ mlResultsService: MlApiServices['results'] }> { + const [coreStart] = await this.getStartServices(); + const { mlApiServicesProvider } = await import('../application/services/ml_api_service'); + + const httpService = new HttpService(coreStart.http); + const mlResultsService = mlApiServicesProvider(httpService).results; + + return { mlResultsService }; + } + + public async create(): Promise { + const { mlResultsService } = await this.getServices(); + const { AnomalySource } = await import('./anomaly_source'); + AnomalySource.mlResultsService = mlResultsService; + AnomalySource.canGetJobs = this.canGetJobs; + return AnomalySource; + } +} diff --git a/x-pack/plugins/ml/public/maps/anomaly_source_field.ts b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts new file mode 100644 index 0000000000000..8a0e1f0104ee0 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts @@ -0,0 +1,169 @@ +/* + * 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. + */ + +// eslint-disable-next-line max-classes-per-file +import { escape } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { IField, IVectorSource } from '../../../maps/public'; +import { FIELD_ORIGIN } from '../../../maps/common'; +import { TileMetaFeature } from '../../../maps/common/descriptor_types'; +import { AnomalySource } from './anomaly_source'; +import { ITooltipProperty } from '../../../maps/public'; +import { Filter } from '../../../../../src/plugins/data/public'; + +export const ANOMALY_SOURCE_FIELDS: Record> = { + record_score: { + label: i18n.translate('xpack.ml.maps.anomalyLayerRecordScoreLabel', { + defaultMessage: 'Record score', + }), + type: 'number', + }, + timestamp: { + label: i18n.translate('xpack.ml.maps.anomalyLayerTimeStampLabel', { + defaultMessage: 'Time', + }), + type: 'string', + }, + fieldName: { + label: i18n.translate('xpack.ml.maps.anomalyLayerFieldNameLabel', { + defaultMessage: 'Field name', + }), + type: 'string', + }, + functionDescription: { + label: i18n.translate('xpack.ml.maps.anomalyLayerFunctionDescriptionLabel', { + defaultMessage: 'Function', + }), + type: 'string', + }, + actualDisplay: { + label: i18n.translate('xpack.ml.maps.anomalyLayerActualLabel', { + defaultMessage: 'Actual', + }), + type: 'string', + }, + typicalDisplay: { + label: i18n.translate('xpack.ml.maps.anomalyLayerTypicalLabel', { + defaultMessage: 'Typical', + }), + type: 'string', + }, +}; + +export class AnomalySourceTooltipProperty implements ITooltipProperty { + constructor(private readonly _label: string, private readonly _value: string) {} + + async getESFilters(): Promise { + return []; + } + + getHtmlDisplayValue(): string { + return this._value.toString(); + } + + getPropertyKey(): string { + return this._label; + } + + getPropertyName(): string { + return this._label; + } + + getRawValue(): string | string[] | undefined { + return this._value.toString(); + } + + isFilterable(): boolean { + return false; + } +} + +// this needs to be generic so it works for all fields in anomaly record result +export class AnomalySourceField implements IField { + private readonly _source: AnomalySource; + private readonly _field: string; + + constructor({ source, field }: { source: AnomalySource; field: string }) { + this._source = source; + this._field = field; + } + + async createTooltipProperty(value: string | string[] | undefined): Promise { + return new AnomalySourceTooltipProperty( + await this.getLabel(), + escape(Array.isArray(value) ? value.join() : value ? value : '') + ); + } + + async getDataType(): Promise { + return ANOMALY_SOURCE_FIELDS[this._field].type; + } + + async getLabel(): Promise { + return ANOMALY_SOURCE_FIELDS[this._field].label; + } + + getName(): string { + return this._field; + } + + getMbFieldName(): string { + return this.getName(); + } + + getOrigin(): FIELD_ORIGIN { + return FIELD_ORIGIN.SOURCE; + } + + getRootName(): string { + return this.getName(); + } + + getSource(): IVectorSource { + return this._source; + } + + isEqual(field: IField): boolean { + return this.getName() === field.getName(); + } + + isValid(): boolean { + return true; + } + + supportsFieldMetaFromLocalData(): boolean { + return true; + } + + supportsFieldMetaFromEs(): boolean { + return false; + } + + canValueBeFormatted(): boolean { + return false; + } + + async getExtendedStatsFieldMetaRequest(): Promise { + return null; + } + + async getPercentilesFieldMetaRequest(percentiles: number[]): Promise { + return null; + } + + async getCategoricalFieldMetaRequest(size: number): Promise { + return null; + } + + pluckRangeFromTileMetaFeature(metaFeature: TileMetaFeature): { min: number; max: number } | null { + return null; + } + + isCount(): boolean { + return false; + } +} diff --git a/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx new file mode 100644 index 0000000000000..e04fd40f9416b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/create_anomaly_source_editor.tsx @@ -0,0 +1,89 @@ +/* + * 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, { Component } from 'react'; + +import { EuiPanel } from '@elastic/eui'; +import { AnomalySourceDescriptor } from './anomaly_source'; +import { AnomalyJobSelector } from './anomaly_job_selector'; +import { LayerSelector } from './layer_selector'; +import { MlAnomalyLayers } from './util'; +import type { MlApiServices } from '../application/services/ml_api_service'; + +interface Props { + onSourceConfigChange: (sourceConfig: Partial | null) => void; + mlJobsService: MlApiServices['jobs']; +} + +interface State { + jobId?: string; + typicalActual?: MlAnomalyLayers; +} + +export class CreateAnomalySourceEditor extends Component { + private _isMounted: boolean = false; + state: State = {}; + + private configChange() { + if (this.state.jobId) { + this.props.onSourceConfigChange({ + jobId: this.state.jobId, + typicalActual: this.state.typicalActual, + }); + } + } + + componentDidMount(): void { + this._isMounted = true; + } + + private onTypicalActualChange = (typicalActual: MlAnomalyLayers) => { + if (!this._isMounted) { + return; + } + this.setState( + { + typicalActual, + }, + () => { + this.configChange(); + } + ); + }; + + private previewLayer = (jobId: string) => { + if (!this._isMounted) { + return; + } + this.setState( + { + jobId, + }, + () => { + this.configChange(); + } + ); + }; + + render() { + const selector = this.state.jobId ? ( + + ) : null; + return ( + + + {selector} + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/layer_selector.tsx b/x-pack/plugins/ml/public/maps/layer_selector.tsx new file mode 100644 index 0000000000000..2998187ad465b --- /dev/null +++ b/x-pack/plugins/ml/public/maps/layer_selector.tsx @@ -0,0 +1,68 @@ +/* + * 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, { Component } from 'react'; + +import { EuiComboBox, EuiFormRow, EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { MlAnomalyLayers } from './util'; + +interface Props { + onChange: (typicalActual: MlAnomalyLayers) => void; + typicalActual: MlAnomalyLayers; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface State {} + +export class LayerSelector extends Component { + private _isMounted: boolean = false; + + state: State = {}; + + componentDidMount(): void { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + onSelect = (selectedOptions: Array>) => { + const typicalActual: MlAnomalyLayers = selectedOptions[0].value! as + | 'typical' + | 'actual' + | 'connected'; + if (this._isMounted) { + this.setState({ typicalActual }); + this.props.onChange(typicalActual); + } + }; + + render() { + const options = [{ value: this.props.typicalActual, label: this.props.typicalActual }]; + return ( + + + + ); + } +} diff --git a/x-pack/plugins/ml/public/maps/register_map_extension.ts b/x-pack/plugins/ml/public/maps/register_map_extension.ts new file mode 100644 index 0000000000000..6cd8bd1f2a3e8 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/register_map_extension.ts @@ -0,0 +1,31 @@ +/* + * 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 { MapsSetupApi } from '../../../maps/public'; +import type { MlCoreSetup } from '../plugin'; +import { AnomalySourceFactory } from './anomaly_source_factory'; +import { AnomalyLayerWizardFactory } from './anomaly_layer_wizard_factory'; + +export async function registerMapExtension( + mapsSetupApi: MapsSetupApi, + core: MlCoreSetup, + canGetJobs: boolean +) { + const anomalySourceFactory = new AnomalySourceFactory(core.getStartServices, canGetJobs); + const anomalyLayerWizardFactory = new AnomalyLayerWizardFactory( + core.getStartServices, + canGetJobs + ); + const anomalylayerWizard = await anomalyLayerWizardFactory.create(); + + mapsSetupApi.registerSource({ + type: anomalySourceFactory.type, + ConstructorFunction: await anomalySourceFactory.create(), + }); + + mapsSetupApi.registerLayerWizard(anomalylayerWizard); +} diff --git a/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx new file mode 100644 index 0000000000000..7e53e6ebf9f5d --- /dev/null +++ b/x-pack/plugins/ml/public/maps/update_anomaly_source_editor.tsx @@ -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 React, { Fragment, Component } from 'react'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { LayerSelector } from './layer_selector'; +import { MlAnomalyLayers } from './util'; + +interface Props { + onChange: (...args: Array<{ propName: string; value: unknown }>) => void; + typicalActual: MlAnomalyLayers; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface State {} + +export class UpdateAnomalySourceEditor extends Component { + state: State = {}; + + render() { + return ( + + + +
+ +
+
+ + { + this.props.onChange({ + propName: 'typicalActual', + value: typicalActual, + }); + }} + typicalActual={this.props.typicalActual} + /> +
+ +
+ ); + } +} diff --git a/x-pack/plugins/ml/public/maps/util.ts b/x-pack/plugins/ml/public/maps/util.ts new file mode 100644 index 0000000000000..6a9d55ad64d38 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/util.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 { FeatureCollection, Feature, Geometry } from 'geojson'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ESSearchResponse } from '../../../../../src/core/types/elasticsearch'; +import { formatHumanReadableDateTimeSeconds } from '../../common/util/date_utils'; +import type { MlApiServices } from '../application/services/ml_api_service'; +import { MLAnomalyDoc } from '../../common/types/anomalies'; +import { VectorSourceRequestMeta } from '../../../maps/common'; + +export type MlAnomalyLayers = 'typical' | 'actual' | 'connected'; + +// Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs +function getCoordinates(actualCoordinateStr: string, round: boolean = false): number[] { + const convertWithRounding = (point: string) => Math.round(Number(point) * 100) / 100; + const convert = (point: string) => Number(point); + return actualCoordinateStr + .split(',') + .map(round ? convertWithRounding : convert) + .reverse(); +} + +export async function getResultsForJobId( + mlResultsService: MlApiServices['results'], + jobId: string, + locationType: MlAnomalyLayers, + searchFilters: VectorSourceRequestMeta +): Promise { + const { timeFilters } = searchFilters; + + const must: estypes.QueryDslQueryContainer[] = [ + { term: { job_id: jobId } }, + { term: { result_type: 'record' } }, + ]; + + // Query to look for the highest scoring anomaly. + const body: estypes.SearchRequest['body'] = { + query: { + bool: { + must, + }, + }, + size: 1000, + _source: { + excludes: [], + }, + }; + + if (timeFilters) { + const timerange = { + range: { + timestamp: { + gte: `${timeFilters.from}`, + lte: timeFilters.to, + }, + }, + }; + must.push(timerange); + } + + let resp: ESSearchResponse | null = null; + let hits: Array<{ + actual: number[]; + actualDisplay: number[]; + fieldName?: string; + functionDescription: string; + typical: number[]; + typicalDisplay: number[]; + record_score: number; + timestamp: string; + }> = []; + + try { + resp = await mlResultsService.anomalySearch( + { + body, + }, + [jobId] + ); + } catch (error) { + // search may fail if the job doesn't already exist + // ignore this error as the outer function call will raise a toast + } + if (resp !== null && resp.hits.total.value > 0) { + hits = resp.hits.hits.map(({ _source }) => { + const geoResults = _source.geo_results; + const actualCoordStr = geoResults && geoResults.actual_point; + const typicalCoordStr = geoResults && geoResults.typical_point; + let typical: number[] = []; + let typicalDisplay: number[] = []; + let actual: number[] = []; + let actualDisplay: number[] = []; + + if (actualCoordStr !== undefined) { + actual = getCoordinates(actualCoordStr); + actualDisplay = getCoordinates(actualCoordStr, true); + } + if (typicalCoordStr !== undefined) { + typical = getCoordinates(typicalCoordStr); + typicalDisplay = getCoordinates(typicalCoordStr, true); + } + return { + fieldName: _source.field_name, + functionDescription: _source.function_description, + timestamp: formatHumanReadableDateTimeSeconds(_source.timestamp), + typical, + typicalDisplay, + actual, + actualDisplay, + record_score: Math.floor(_source.record_score), + }; + }); + } + + const features: Feature[] = hits.map((result) => { + let geometry: Geometry; + if (locationType === 'typical' || locationType === 'actual') { + geometry = { + type: 'Point', + coordinates: locationType === 'typical' ? result.typical : result.actual, + }; + } else { + geometry = { + type: 'LineString', + coordinates: [result.typical, result.actual], + }; + } + return { + type: 'Feature', + geometry, + properties: { + actual: result.actual, + actualDisplay: result.actualDisplay, + typical: result.typical, + typicalDisplay: result.typicalDisplay, + fieldName: result.fieldName, + functionDescription: result.functionDescription, + timestamp: result.timestamp, + record_score: result.record_score, + }, + }; + }); + + return { + type: 'FeatureCollection', + features, + }; +} diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 2a5cb2ab4ae2a..6563a7bc5b551 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -36,7 +36,7 @@ import { isFullLicense, isMlEnabled } from '../common/license'; import { setDependencyCache } from './application/util/dependency_cache'; import { registerFeature } from './register_feature'; import { MlLocatorDefinition, MlLocator } from './locator'; -import type { MapsStartApi } from '../../maps/public'; +import type { MapsStartApi, MapsSetupApi } from '../../maps/public'; import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, @@ -66,6 +66,7 @@ export interface MlStartDependencies { export interface MlSetupDependencies { security?: SecurityPluginSetup; + maps?: MapsSetupApi; licensing: LicensingPluginSetup; management?: ManagementSetup; licenseManagement?: LicenseManagementUIPluginSetup; @@ -159,11 +160,23 @@ export class MlPlugin implements Plugin { // register various ML plugin features which require a full license // note including registerFeature in register_helper would cause the page bundle size to increase significantly - const { registerEmbeddables, registerMlUiActions, registerSearchLinks, registerMlAlerts } = - await import('./register_helper'); + const { + registerEmbeddables, + registerMlUiActions, + registerSearchLinks, + registerMlAlerts, + registerMapExtension, + } = await import('./register_helper'); const mlEnabled = isMlEnabled(license); const fullLicense = isFullLicense(license); + + if (pluginsSetup.maps) { + // Pass capabilites.ml.canGetJobs as minimum permission to show anomalies card in maps layers + const canGetJobs = capabilities.ml?.canGetJobs === true || false; + await registerMapExtension(pluginsSetup.maps, core, canGetJobs); + } + if (mlEnabled) { registerSearchLinks(this.appUpdater$, fullLicense); diff --git a/x-pack/plugins/ml/public/register_helper/index.ts b/x-pack/plugins/ml/public/register_helper/index.ts index 278f32f683053..47d9bad31997a 100644 --- a/x-pack/plugins/ml/public/register_helper/index.ts +++ b/x-pack/plugins/ml/public/register_helper/index.ts @@ -10,3 +10,4 @@ export { registerManagementSection } from '../application/management'; export { registerMlUiActions } from '../ui_actions'; export { registerSearchLinks } from './register_search_links'; export { registerMlAlerts } from '../alerting'; +export { registerMapExtension } from '../maps/register_map_extension'; diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts index dc39a8005b99e..bf0ffc05dd014 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_manager.ts @@ -8,6 +8,7 @@ import Boom from '@hapi/boom'; import { IScopedClusterClient } from 'kibana/server'; import { + INDEX_CREATED_BY, JOB_MAP_NODE_TYPES, JobMapNodeTypes, } from '../../../common/constants/data_frame_analytics'; @@ -19,7 +20,6 @@ import { DataFrameAnalyticsStats, MapElements, } from '../../../common/types/data_frame_analytics'; -import { INDEX_META_DATA_CREATED_BY } from '../../../../file_upload/common'; import { getAnalysisType } from '../../../common/util/analytics_utils'; import { ExtendAnalyticsMapArgs, @@ -458,14 +458,14 @@ export class AnalyticsManager { if ( link.isWildcardIndexPattern === false && (link.meta === undefined || - link.meta?.created_by.includes(INDEX_META_DATA_CREATED_BY)) + link.meta?.created_by.includes(INDEX_CREATED_BY.FILE_DATA_VISUALIZER)) ) { rootIndexPattern = nextLinkId; complete = true; break; } - if (link.meta?.created_by === 'data-frame-analytics') { + if (link.meta?.created_by === INDEX_CREATED_BY.DATA_FRAME_ANALYTICS) { nextLinkId = link.meta.analytics; nextType = JOB_MAP_NODE_TYPES.ANALYTICS; } diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index a79093caabbef..c4e215fc3c38e 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -11,6 +11,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { getSingleMetricViewerJobErrorMessage, parseTimeIntervalForJob, + isJobWithGeoData, } from '../../../common/util/job_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { @@ -272,6 +273,11 @@ export function jobsProvider( return jobs; } + async function getJobIdsWithGeo(): Promise { + const { body } = await mlClient.getJobs(); + return body.jobs.filter(isJobWithGeoData).map((job) => job.job_id); + } + async function jobsWithTimerange() { const fullJobsList = await createFullJobsList(); const jobsMap: { [id: string]: string[] } = {}; @@ -669,5 +675,6 @@ export function jobsProvider( getAllJobAndGroupIds, getLookBackProgress, bulkCreate, + getJobIdsWithGeo, }; } diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 96ca56baa38da..123b38e9cd7de 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -285,6 +285,42 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { }) ); + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs_with_geo Jobs summary + * @apiName JobsSummary + * @apiDescription Returns a list of anomaly detection jobs with analysis config with fields supported by maps. + * + * @apiSuccess {Array} jobIds list of job ids. + */ + router.get( + { + path: '/api/ml/jobs/jobs_with_geo', + validate: false, + options: { + tags: ['access:ml:canGetJobs'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, response, context }) => { + try { + const { getJobIdsWithGeo } = jobServiceProvider( + client, + mlClient, + context.alerting?.getRulesClient() + ); + + const resp = await getJobIdsWithGeo(); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup JobService * diff --git a/x-pack/plugins/reporting/common/types/export_types/png.ts b/x-pack/plugins/reporting/common/types/export_types/png.ts index 5afde424127a1..635de9823c8b3 100644 --- a/x-pack/plugins/reporting/common/types/export_types/png.ts +++ b/x-pack/plugins/reporting/common/types/export_types/png.ts @@ -15,7 +15,10 @@ interface BaseParamsPNG { } // Job params: structure of incoming user request data -export type JobParamsPNG = BaseParamsPNG & BaseParams; +/** + * @deprecated + */ +export type JobParamsPNGDeprecated = BaseParamsPNG & BaseParams; // Job payload: structure of stored job data provided by create_job export type TaskPayloadPNG = BaseParamsPNG & BasePayload; diff --git a/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts b/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts index 55a686cc829be..d5b8ec21cc13b 100644 --- a/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts +++ b/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts @@ -15,9 +15,12 @@ interface BaseParamsPDF { } // Job params: structure of incoming user request data, after being parsed from RISON -export type JobParamsPDF = BaseParamsPDF & BaseParams; +/** + * @deprecated + */ +export type JobParamsPDFDeprecated = BaseParamsPDF & BaseParams; -export type JobAppParamsPDF = Omit; +export type JobAppParamsPDF = Omit; // Job payload: structure of stored job data provided by create_job export interface TaskPayloadPDF extends BasePayload { diff --git a/x-pack/plugins/reporting/common/types/index.ts b/x-pack/plugins/reporting/common/types/index.ts index 4056b40a7f454..4ccd11f8522e6 100644 --- a/x-pack/plugins/reporting/common/types/index.ts +++ b/x-pack/plugins/reporting/common/types/index.ts @@ -7,9 +7,9 @@ import type { BaseParams, BaseParamsV2, BasePayload, BasePayloadV2, JobId } from './base'; -export type { JobParamsPNG } from './export_types/png'; +export type { JobParamsPNGDeprecated } from './export_types/png'; export type { JobParamsPNGV2 } from './export_types/png_v2'; -export type { JobAppParamsPDF, JobParamsPDF } from './export_types/printable_pdf'; +export type { JobAppParamsPDF, JobParamsPDFDeprecated } from './export_types/printable_pdf'; export type { JobAppParamsPDFV2, JobParamsPDFV2 } from './export_types/printable_pdf_v2'; export type { DownloadReportFn, diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index f18c0fe2778bd..70822c29119d5 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -7,16 +7,18 @@ import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPNG, TaskPayloadPNG } from '../types'; +import { JobParamsPNGDeprecated, TaskPayloadPNG } from '../types'; -export const createJobFnFactory: CreateJobFnFactory> = - function createJobFactoryFn() { - return async function createJob(jobParams) { - validateUrls([jobParams.relativeUrl]); +export const createJobFnFactory: CreateJobFnFactory< + CreateJobFn +> = function createJobFactoryFn() { + return async function createJob(jobParams) { + validateUrls([jobParams.relativeUrl]); - return { - ...jobParams, - forceNow: new Date().toISOString(), - }; + return { + ...jobParams, + isDeprecated: true, + forceNow: new Date().toISOString(), }; }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/png/index.ts b/x-pack/plugins/reporting/server/export_types/png/index.ts index 7d3e65540fe3a..f4c3155c7b8a5 100644 --- a/x-pack/plugins/reporting/server/export_types/png/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/index.ts @@ -17,10 +17,10 @@ import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; import { createJobFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsPNG, TaskPayloadPNG } from './types'; +import { JobParamsPNGDeprecated, TaskPayloadPNG } from './types'; export const getExportType = (): ExportTypeDefinition< - CreateJobFn, + CreateJobFn, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/png/types.ts b/x-pack/plugins/reporting/server/export_types/png/types.ts index 8b8768467155e..3940f24b762a8 100644 --- a/x-pack/plugins/reporting/server/export_types/png/types.ts +++ b/x-pack/plugins/reporting/server/export_types/png/types.ts @@ -5,4 +5,7 @@ * 2.0. */ -export type { JobParamsPNG, TaskPayloadPNG } from '../../../common/types/export_types/png'; +export type { + JobParamsPNGDeprecated, + TaskPayloadPNG, +} from '../../../common/types/export_types/png'; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts index 00702341f0dc9..eeaf0f82f1698 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts @@ -7,20 +7,22 @@ import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPDF, TaskPayloadPDF } from '../types'; +import { JobParamsPDFDeprecated, TaskPayloadPDF } from '../types'; -export const createJobFnFactory: CreateJobFnFactory> = - function createJobFactoryFn() { - return async function createJobFn( - { relativeUrls, ...jobParams }: JobParamsPDF // relativeUrls does not belong in the payload of PDFV1 - ) { - validateUrls(relativeUrls); +export const createJobFnFactory: CreateJobFnFactory< + CreateJobFn +> = function createJobFactoryFn() { + return async function createJobFn( + { relativeUrls, ...jobParams }: JobParamsPDFDeprecated // relativeUrls does not belong in the payload of PDFV1 + ) { + validateUrls(relativeUrls); - // return the payload - return { - ...jobParams, - forceNow: new Date().toISOString(), - objects: relativeUrls.map((u) => ({ relativeUrl: u })), - }; + // return the payload + return { + ...jobParams, + isDeprecated: true, + forceNow: new Date().toISOString(), + objects: relativeUrls.map((u) => ({ relativeUrl: u })), }; }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts index d429c0fd6d001..4a4b63bae930b 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts @@ -17,10 +17,10 @@ import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; import { createJobFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsPDF, TaskPayloadPDF } from './types'; +import { JobParamsPDFDeprecated, TaskPayloadPDF } from './types'; export const getExportType = (): ExportTypeDefinition< - CreateJobFn, + CreateJobFn, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.ts index f57d6a709fedb..ebf258f73a41f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.ts @@ -6,6 +6,6 @@ */ export type { - JobParamsPDF, + JobParamsPDFDeprecated, TaskPayloadPDF, } from '../../../common/types/export_types/printable_pdf'; diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts index 13408db881317..0028073290f20 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts @@ -8,7 +8,7 @@ import { KibanaRequest, KibanaResponseFactory } from 'kibana/server'; import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { ReportingCore } from '../..'; -import { JobParamsPDF, TaskPayloadPDF } from '../../export_types/printable_pdf/types'; +import { JobParamsPDFDeprecated, TaskPayloadPDF } from '../../export_types/printable_pdf/types'; import { Report, ReportingStore } from '../../lib/store'; import { ReportApiJSON } from '../../lib/store/report'; import { @@ -52,7 +52,7 @@ describe('Handle request to generate', () => { let mockResponseFactory: ReturnType; let requestHandler: RequestHandler; - const mockJobParams: JobParamsPDF = { + const mockJobParams: JobParamsPDFDeprecated = { browserTimezone: 'UTC', objectType: 'cool_object_type', title: 'cool_title', @@ -110,7 +110,7 @@ describe('Handle request to generate', () => { "kibana_name": undefined, "max_attempts": undefined, "meta": Object { - "isDeprecated": undefined, + "isDeprecated": true, "layout": "preserve_layout", "objectType": "cool_object_type", }, @@ -127,6 +127,7 @@ describe('Handle request to generate', () => { Object { "browserTimezone": "UTC", "headers": "hello mock cypher text", + "isDeprecated": true, "layout": Object { "id": "preserve_layout", }, diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts index 7c4095cca039f..dc81e200032f7 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/ecs_field_map.ts @@ -415,21 +415,6 @@ export const ecsFieldMap = { array: false, required: false, }, - 'data_stream.dataset': { - type: 'constant_keyword', - array: false, - required: false, - }, - 'data_stream.namespace': { - type: 'constant_keyword', - array: false, - required: false, - }, - 'data_stream.type': { - type: 'constant_keyword', - array: false, - required: false, - }, 'destination.address': { type: 'keyword', array: false, diff --git a/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js index 5e90a3c16aa7c..9f28f87b492c5 100644 --- a/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js +++ b/x-pack/plugins/rule_registry/scripts/generate_ecs_fieldmap/index.js @@ -9,7 +9,7 @@ const fs = require('fs'); const util = require('util'); const yaml = require('js-yaml'); const { exec: execCb } = require('child_process'); -const { mapValues } = require('lodash'); +const { reduce } = require('lodash'); const exists = util.promisify(fs.exists); const readFile = util.promisify(fs.readFile); @@ -32,19 +32,27 @@ async function generate() { const flatYaml = await yaml.safeLoad(await readFile(ecsYamlFilename)); - const fields = mapValues(flatYaml, (description) => { - const field = { - type: description.type, - array: description.normalize.includes('array'), - required: !!description.required, - }; + const fields = reduce( + flatYaml, + (fieldsObj, value, key) => { + const field = { + type: value.type, + array: value.normalize.includes('array'), + required: !!value.required, + }; - if (description.scaling_factor) { - field.scaling_factor = description.scaling_factor; - } + if (value.scaling_factor) { + field.scaling_factor = value.scaling_factor; + } - return field; - }); + if (field.type !== 'constant_keyword') { + fieldsObj[key] = field; + } + + return fieldsObj; + }, + {} + ); await Promise.all([ writeFile( diff --git a/x-pack/plugins/security_solution/common/endpoint/actions.ts b/x-pack/plugins/security_solution/common/endpoint/actions.ts deleted file mode 100644 index 287ebddacad9a..0000000000000 --- a/x-pack/plugins/security_solution/common/endpoint/actions.ts +++ /dev/null @@ -1,14 +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. - */ - -export const userCanIsolate = (roles: readonly string[] | undefined): boolean => { - // only superusers can write to the fleet index (or look up endpoint data to convert endp ID to agent ID) - if (!roles || roles.length === 0) { - return false; - } - return roles.includes('superuser'); -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index d66f1e950c34f..3ff333232a677 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -5,10 +5,34 @@ * 2.0. */ -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { BaseDataGenerator } from './base_data_generator'; -import { POLICY_REFERENCE_PREFIX } from '../service/trusted_apps/mapping'; import { ConditionEntryField } from '../types'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../service/artifacts/constants'; + +/** Utility that removes null and undefined from a Type's property value */ +type NonNullableTypeProperties = { + [P in keyof T]-?: NonNullable; +}; + +/** + * Normalizes the create type to remove `undefined`/`null` from the returned type since the generator or sure to + * create a value for (almost) all properties + */ +type CreateExceptionListItemSchemaWithNonNullProps = NonNullableTypeProperties< + Omit +> & + Pick; + +type UpdateExceptionListItemSchemaWithNonNullProps = NonNullableTypeProperties< + Omit +> & + Pick; export class ExceptionsListItemGenerator extends BaseDataGenerator { generate(overrides: Partial = {}): ExceptionListItemSchema { @@ -38,11 +62,11 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator = {} + ): CreateExceptionListItemSchemaWithNonNullProps { + const { + /* eslint-disable @typescript-eslint/naming-convention */ + description, + entries, + list_id, + name, + type, + comments, + item_id, + meta, + namespace_type, + os_types, + tags, + /* eslint-enable @typescript-eslint/naming-convention */ + } = this.generate(); + + return { + description, + entries, + list_id, + name, + type, + comments, + item_id, + meta, + namespace_type, + os_types, + tags, + ...overrides, + }; + } + + generateTrustedApp(overrides: Partial = {}): ExceptionListItemSchema { + const trustedApp = this.generate(overrides); + + return { + ...trustedApp, + name: `Trusted app (${this.randomString(5)})`, + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + // Remove the hash field which the generator above currently still sets to a field that is not + // actually valid when used with the Exception List + entries: trustedApp.entries.filter((entry) => entry.field !== ConditionEntryField.HASH), + }; + } + + generateTrustedAppForCreate( + overrides: Partial = {} + ): CreateExceptionListItemSchemaWithNonNullProps { + const { + /* eslint-disable @typescript-eslint/naming-convention */ + description, + entries, + list_id, + name, + type, + comments, + item_id, + meta, + namespace_type, + os_types, + tags, + /* eslint-enable @typescript-eslint/naming-convention */ + } = this.generateTrustedApp(); + + return { + description, + entries, + list_id, + name, + type, + comments, + item_id, + meta, + namespace_type, + os_types, + tags, + ...overrides, + }; + } + + generateTrustedAppForUpdate( + overrides: Partial = {} + ): UpdateExceptionListItemSchemaWithNonNullProps { + const { + /* eslint-disable @typescript-eslint/naming-convention */ + description, + entries, + name, + type, + comments, + id, + item_id, + meta, + namespace_type, + os_types, + tags, + _version, + /* eslint-enable @typescript-eslint/naming-convention */ + } = this.generateTrustedApp(); + + return { + description, + entries, + name, + type, + comments, + id, + item_id, + meta, + namespace_type, + os_types, + tags, + _version: _version ?? 'some value', + ...overrides, + }; + } } diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts new file mode 100644 index 0000000000000..eadc52941da05 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; + +export const GLOBAL_ARTIFACT_TAG = `${BY_POLICY_ARTIFACT_TAG_PREFIX}all`; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/index.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/index.ts new file mode 100644 index 0000000000000..6bdf0fb59a3a8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { isArtifactGlobal, isArtifactByPolicy, getPolicyIdsFromArtifact } from './utils'; + +export { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG } from './constants'; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts new file mode 100644 index 0000000000000..4cc39e9fb8980 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/utils.ts @@ -0,0 +1,32 @@ +/* + * 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG } from './constants'; + +const POLICY_ID_START_POSITION = BY_POLICY_ARTIFACT_TAG_PREFIX.length; + +export const isArtifactGlobal = (item: Pick): boolean => { + return (item.tags ?? []).find((tag) => tag === GLOBAL_ARTIFACT_TAG) !== undefined; +}; + +export const isArtifactByPolicy = (item: Pick): boolean => { + return !isArtifactGlobal(item); +}; + +export const getPolicyIdsFromArtifact = (item: Pick): string[] => { + const policyIds = []; + const tags = item.tags ?? []; + + for (const tag of tags) { + if (tag !== GLOBAL_ARTIFACT_TAG && tag.startsWith(BY_POLICY_ARTIFACT_TAG_PREFIX)) { + policyIds.push(tag.substring(POLICY_ID_START_POSITION)); + } + } + + return policyIds; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index 947615bdf7e88..3d64bd98b758e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -13,10 +13,12 @@ import { EndpointAuthzKeyList } from '../../types/authz'; describe('Endpoint Authz service', () => { let licenseService: ReturnType; let fleetAuthz: FleetAuthz; + let userRoles: string[]; beforeEach(() => { licenseService = createLicenseServiceMock(); fleetAuthz = createFleetAuthzMock(); + userRoles = ['superuser']; }); describe('calculateEndpointAuthz()', () => { @@ -27,24 +29,33 @@ describe('Endpoint Authz service', () => { ['canIsolateHost'], ['canUnIsolateHost'], ])('should set `%s` to `true`', (authProperty) => { - expect(calculateEndpointAuthz(licenseService, fleetAuthz)[authProperty]).toBe(true); + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe( + true + ); }); it('should set `canIsolateHost` to false if not proper license', () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz).canIsolateHost).toBe(false); + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canIsolateHost).toBe( + false + ); }); it('should set `canUnIsolateHost` to true even if not proper license', () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz).canUnIsolateHost).toBe(true); + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canUnIsolateHost).toBe( + true + ); }); }); describe('and `fleet.all` access is false', () => { - beforeEach(() => (fleetAuthz.fleet.all = false)); + beforeEach(() => { + fleetAuthz.fleet.all = false; + userRoles = []; + }); it.each([ ['canAccessFleet'], @@ -52,13 +63,17 @@ describe('Endpoint Authz service', () => { ['canIsolateHost'], ['canUnIsolateHost'], ])('should set `%s` to `false`', (authProperty) => { - expect(calculateEndpointAuthz(licenseService, fleetAuthz)[authProperty]).toBe(false); + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe( + false + ); }); it('should set `canUnIsolateHost` to false when policy is also not platinum', () => { licenseService.isPlatinumPlus.mockReturnValue(false); - expect(calculateEndpointAuthz(licenseService, fleetAuthz).canUnIsolateHost).toBe(false); + expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canUnIsolateHost).toBe( + false + ); }); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts index 83bd267e56c53..6b20a376bbf61 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -8,6 +8,7 @@ import { LicenseService } from '../../../license'; import { FleetAuthz } from '../../../../../fleet/common'; import { EndpointAuthz } from '../../types/authz'; +import { MaybeImmutable } from '../../types'; /** * Used by both the server and the UI to generate the Authorization for access to Endpoint related @@ -15,13 +16,15 @@ import { EndpointAuthz } from '../../types/authz'; * * @param licenseService * @param fleetAuthz + * @param userRoles */ export const calculateEndpointAuthz = ( licenseService: LicenseService, - fleetAuthz: FleetAuthz + fleetAuthz: FleetAuthz, + userRoles: MaybeImmutable ): EndpointAuthz => { const isPlatinumPlusLicense = licenseService.isPlatinumPlus(); - const hasAllAccessToFleet = fleetAuthz.fleet.all; + const hasAllAccessToFleet = fleetAuthz.fleet.all || userRoles.includes('superuser'); return { canAccessFleet: hasAllAccessToFleet, diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts index ddc366f49ed95..cf36c6911ca91 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/mapping.ts @@ -6,8 +6,7 @@ */ import { EffectScope } from '../../types'; - -export const POLICY_REFERENCE_PREFIX = 'policy:'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../artifacts/constants'; /** * Looks at an array of `tags` (attributed defined on the `ExceptionListItemSchema`) and returns back @@ -15,16 +14,16 @@ export const POLICY_REFERENCE_PREFIX = 'policy:'; * @param tags */ export const tagsToEffectScope = (tags: string[]): EffectScope => { - const policyReferenceTags = tags.filter((tag) => tag.startsWith(POLICY_REFERENCE_PREFIX)); + const policyReferenceTags = tags.filter((tag) => tag.startsWith(BY_POLICY_ARTIFACT_TAG_PREFIX)); - if (policyReferenceTags.some((tag) => tag === `${POLICY_REFERENCE_PREFIX}all`)) { + if (policyReferenceTags.some((tag) => tag === `${BY_POLICY_ARTIFACT_TAG_PREFIX}all`)) { return { type: 'global', }; } else { return { type: 'policy', - policies: policyReferenceTags.map((tag) => tag.substr(POLICY_REFERENCE_PREFIX.length)), + policies: policyReferenceTags.map((tag) => tag.substr(BY_POLICY_ARTIFACT_TAG_PREFIX.length)), }; } }; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index 3fee05e2f0061..0e6f2a5a7df41 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -26,7 +26,11 @@ export const getDuplicateFields = (entries: ConditionEntry[]) => { const groupedFields = new Map(); entries.forEach((entry) => { - groupedFields.set(entry.field, [...(groupedFields.get(entry.field) || []), entry]); + // With the move to the Exception Lists api, the server side now validates individual + // `process.hash.[type]`'s, so we need to account for that here + const field = entry.field.startsWith('process.hash') ? ConditionEntryField.HASH : entry.field; + + groupedFields.set(field, [...(groupedFields.get(field) || []), entry]); }); return [...groupedFields.entries()] diff --git a/x-pack/plugins/security_solution/public/common/components/news_feed/translations.ts b/x-pack/plugins/security_solution/public/common/components/news_feed/translations.ts index 6b4c342b57b29..e21f6165f3763 100644 --- a/x-pack/plugins/security_solution/public/common/components/news_feed/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/news_feed/translations.ts @@ -22,6 +22,6 @@ export const NO_NEWS_MESSAGE_ADMIN = i18n.translate( export const ADVANCED_SETTINGS_LINK_TITLE = i18n.translate( 'xpack.securitySolution.newsFeed.advancedSettingsLinkTitle', { - defaultMessage: 'SIEM advanced settings', + defaultMessage: 'Security Solution advanced settings', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts index 6fa0c51f500da..e6c7b9a5d0e95 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts @@ -8,7 +8,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useCurrentUser, useKibana } from '../../../lib/kibana'; import { useLicense } from '../../../hooks/use_license'; -import { EndpointPrivileges, Immutable } from '../../../../../common/endpoint/types'; +import { + EndpointPrivileges, + Immutable, + MaybeImmutable, +} from '../../../../../common/endpoint/types'; import { calculateEndpointAuthz, getEndpointAuthzInitialState, @@ -28,17 +32,19 @@ export const useEndpointPrivileges = (): Immutable => { const licenseService = useLicense(); const [fleetCheckDone, setFleetCheckDone] = useState(false); const [fleetAuthz, setFleetAuthz] = useState(null); + const [userRolesCheckDone, setUserRolesCheckDone] = useState(false); + const [userRoles, setUserRoles] = useState>([]); const privileges = useMemo(() => { const privilegeList: EndpointPrivileges = Object.freeze({ - loading: !fleetCheckDone || !user, + loading: !fleetCheckDone || !userRolesCheckDone || !user, ...(fleetAuthz - ? calculateEndpointAuthz(licenseService, fleetAuthz) + ? calculateEndpointAuthz(licenseService, fleetAuthz, userRoles) : getEndpointAuthzInitialState()), }); return privilegeList; - }, [fleetCheckDone, user, fleetAuthz, licenseService]); + }, [fleetCheckDone, userRolesCheckDone, user, fleetAuthz, licenseService, userRoles]); // Check if user can access fleet useEffect(() => { @@ -64,6 +70,16 @@ export const useEndpointPrivileges = (): Immutable => { })(); }, [fleetServices]); + // get user roles + useEffect(() => { + (async () => { + if (user && isMounted.current) { + setUserRoles(user?.roles); + setUserRolesCheckDone(true); + } + })(); + }, [user]); + // Capture if component is unmounted useEffect( () => () => { diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts deleted file mode 100644 index 23ef6d586adc5..0000000000000 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_isolate_privileges.ts +++ /dev/null @@ -1,40 +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 { useEffect, useState } from 'react'; -import { userCanIsolate } from '../../../../common/endpoint/actions'; -import { useKibana } from '../../lib/kibana'; -import { useLicense } from '../use_license'; - -interface IsolationPriviledgesStatus { - isLoading: boolean; - isAllowed: boolean; -} - -/* - * Host isolation requires superuser privileges and at least a platinum license - */ -export const useIsolationPrivileges = (): IsolationPriviledgesStatus => { - const [isLoading, setIsLoading] = useState(false); - const [canIsolate, setCanIsolate] = useState(false); - - const isPlatinumPlus = useLicense().isPlatinumPlus(); - const services = useKibana().services; - - useEffect(() => { - setIsLoading(true); - const user = services.security.authc.getCurrentUser(); - if (user) { - user.then((authenticatedUser) => { - setCanIsolate(userCanIsolate(authenticatedUser.roles)); - setIsLoading(false); - }); - } - }, [services.security.authc]); - - return { isLoading, isAllowed: canIsolate && isPlatinumPlus ? true : false }; -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx index a7c3cb900d7c9..57d81d48fca05 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx @@ -9,11 +9,11 @@ import { EuiContextMenuItem } from '@elastic/eui'; import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils'; import { HostStatus } from '../../../../common/endpoint/types'; -import { useIsolationPrivileges } from '../../../common/hooks/endpoint/use_isolate_privileges'; import { isAlertFromEndpointEvent } from '../../../common/utils/endpoint_alert_check'; import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status'; import { ISOLATE_HOST, UNISOLATE_HOST } from './translations'; import { getFieldValue } from './helpers'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; interface UseHostIsolationActionProps { closePopover: () => void; @@ -62,7 +62,7 @@ export const useHostIsolationAction = ({ capabilities, }); - const { isAllowed: isIsolationAllowed } = useIsolationPrivileges(); + const isIsolationAllowed = useUserPrivileges().endpointPrivileges.canIsolateHost; const isolateHostHandler = useCallback(() => { closePopover(); diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index e12f01458da4c..0c525a2d77706 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -21,9 +21,6 @@ import { useKibana } from '../../../common/lib/kibana'; jest.mock('../user_info', () => ({ useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]), })); -jest.mock('../../../common/hooks/endpoint/use_isolate_privileges', () => ({ - useIsolationPrivileges: jest.fn().mockReturnValue({ isAllowed: true }), -})); jest.mock('../../../common/lib/kibana', () => ({ useKibana: jest.fn(), useGetUserCasesPermissions: jest.fn().mockReturnValue({ crud: true }), @@ -60,6 +57,8 @@ jest.mock('../../containers/detection_engine/alerts/use_host_isolation_status', }; }); +jest.mock('../../../common/components/user_privileges'); + describe('take action dropdown', () => { const defaultProps: TakeActionDropdownProps = { detailsData: mockAlertDetailsData as TimelineEventsDetailsItem[], diff --git a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts index b3c1dd6648f83..3f90df40391bc 100644 --- a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts +++ b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts @@ -7,8 +7,7 @@ import { PolicyData } from '../../../../common/endpoint/types'; import { EffectedPolicySelection } from './effected_policy_select'; - -export const GLOBAL_POLICY_TAG = 'policy:all'; +import { GLOBAL_ARTIFACT_TAG } from '../../../../common/endpoint/service/artifacts/constants'; /** * Given a list of artifact tags, returns the tags that are not policy tags @@ -27,7 +26,7 @@ export function getArtifactTagsByEffectedPolicySelection( otherTags: string[] = [] ): string[] { if (selection.isGlobal) { - return [GLOBAL_POLICY_TAG, ...otherTags]; + return [GLOBAL_ARTIFACT_TAG, ...otherTags]; } const newTags = selection.selected.map((policy) => { return `policy:${policy.id}`; @@ -47,7 +46,7 @@ export function getEffectedPolicySelectionByTags( tags: string[], policies: PolicyData[] ): EffectedPolicySelection { - if (tags.find((tag) => tag === GLOBAL_POLICY_TAG)) { + if (tags.find((tag) => tag === GLOBAL_ARTIFACT_TAG)) { return { isGlobal: true, selected: [], @@ -71,7 +70,7 @@ export function getEffectedPolicySelectionByTags( } export function isGlobalPolicyEffected(tags?: string[]): boolean { - return tags !== undefined && tags.find((tag) => tag === GLOBAL_POLICY_TAG) !== undefined; + return tags !== undefined && tags.find((tag) => tag === GLOBAL_ARTIFACT_TAG) !== undefined; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts index e5c7a35d89c00..6145f869a660a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/index.ts @@ -13,36 +13,25 @@ import type { UpdateExceptionListItemSchema, ExceptionListSummarySchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { Immutable } from '../../../../../common/endpoint/types'; -import { EVENT_FILTER_LIST, EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '../constants'; import { EventFiltersService } from '../types'; - +import { + addEventFilters, + getList, + getOne, + updateOne, + deleteOne, + getSummary, +} from './service_actions'; + +/** + * @deprecated Don't use this class for future implementations, use the service_actions module instead! + */ export class EventFiltersHttpService implements EventFiltersService { - private listHasBeenCreated: boolean; - constructor(private http: HttpStart) { - this.listHasBeenCreated = false; - } - - private async createEndpointEventList() { - try { - await this.http.post(EXCEPTION_LIST_URL, { - body: JSON.stringify(EVENT_FILTER_LIST), - }); - this.listHasBeenCreated = true; - } catch (err) { - // Ignore 409 errors. List already created - if (err.response.status === 409) this.listHasBeenCreated = true; - else throw err; - } - } - - private async httpWrapper() { - if (!this.listHasBeenCreated) await this.createEndpointEventList(); - return this.http; + this.http = http; } async getList({ @@ -58,87 +47,28 @@ export class EventFiltersHttpService implements EventFiltersService { sortOrder: string; filter: string; }> = {}): Promise { - const http = await this.httpWrapper(); - return http.get(`${EXCEPTION_LIST_ITEM_URL}/_find`, { - query: { - page, - per_page: perPage, - sort_field: sortField, - sort_order: sortOrder, - list_id: [ENDPOINT_EVENT_FILTERS_LIST_ID], - namespace_type: ['agnostic'], - filter, - }, - }); + return getList({ http: this.http, perPage, page, sortField, sortOrder, filter }); } async addEventFilters(exception: ExceptionListItemSchema | CreateExceptionListItemSchema) { - return (await this.httpWrapper()).post(EXCEPTION_LIST_ITEM_URL, { - body: JSON.stringify(exception), - }); + return addEventFilters(this.http, exception); } async getOne(id: string) { - return (await this.httpWrapper()).get(EXCEPTION_LIST_ITEM_URL, { - query: { - id, - namespace_type: 'agnostic', - }, - }); + return getOne(this.http, id); } async updateOne( exception: Immutable ): Promise { - return (await this.httpWrapper()).put(EXCEPTION_LIST_ITEM_URL, { - body: JSON.stringify(EventFiltersHttpService.cleanEventFilterToUpdate(exception)), - }); + return updateOne(this.http, exception); } async deleteOne(id: string): Promise { - return (await this.httpWrapper()).delete(EXCEPTION_LIST_ITEM_URL, { - query: { - id, - namespace_type: 'agnostic', - }, - }); + return deleteOne(this.http, id); } async getSummary(): Promise { - return (await this.httpWrapper()).get( - `${EXCEPTION_LIST_URL}/summary`, - { - query: { - list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, - namespace_type: 'agnostic', - }, - } - ); - } - - static cleanEventFilterToUpdate( - exception: Immutable - ): UpdateExceptionListItemSchema { - const exceptionToUpdateCleaned = { ...exception }; - // Clean unnecessary fields for update action - [ - 'created_at', - 'created_by', - 'created_at', - 'created_by', - 'list_id', - 'tie_breaker_id', - 'updated_at', - 'updated_by', - ].forEach((field) => { - delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; - }); - - exceptionToUpdateCleaned.comments = exceptionToUpdateCleaned.comments?.map((comment) => ({ - comment: comment.comment, - id: comment.id, - })); - - return exceptionToUpdateCleaned as UpdateExceptionListItemSchema; + return getSummary(this.http); } } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts new file mode 100644 index 0000000000000..19c56cb5fed90 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts @@ -0,0 +1,148 @@ +/* + * 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 { + CreateExceptionListItemSchema, + ExceptionListItemSchema, + ExceptionListSummarySchema, + FoundExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { HttpStart } from 'kibana/public'; +import { + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, + EVENT_FILTER_LIST, + ENDPOINT_EVENT_FILTERS_LIST_ID, +} from '../constants'; +import { Immutable } from '../../../../../common/endpoint/types'; + +async function createEventFilterList(http: HttpStart): Promise { + try { + await http.post(EXCEPTION_LIST_URL, { + body: JSON.stringify(EVENT_FILTER_LIST), + }); + } catch (err) { + // Ignore 409 errors. List already created + if (err.response?.status !== 409) { + throw err; + } + } +} + +let listExistsPromise: Promise; +export async function ensureEventFiltersListExists(http: HttpStart): Promise { + if (!listExistsPromise) { + listExistsPromise = createEventFilterList(http); + } + await listExistsPromise; +} + +export async function getList({ + http, + perPage, + page, + sortField, + sortOrder, + filter, +}: { + http: HttpStart; + page?: number; + perPage?: number; + sortField?: string; + sortOrder?: string; + filter?: string; +}): Promise { + await ensureEventFiltersListExists(http); + return http.get(`${EXCEPTION_LIST_ITEM_URL}/_find`, { + query: { + page, + per_page: perPage, + sort_field: sortField, + sort_order: sortOrder, + list_id: [ENDPOINT_EVENT_FILTERS_LIST_ID], + namespace_type: ['agnostic'], + filter, + }, + }); +} + +export async function addEventFilters( + http: HttpStart, + exception: ExceptionListItemSchema | CreateExceptionListItemSchema +) { + await ensureEventFiltersListExists(http); + return http.post(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(exception), + }); +} + +export async function getOne(http: HttpStart, id: string) { + await ensureEventFiltersListExists(http); + return http.get(EXCEPTION_LIST_ITEM_URL, { + query: { + id, + namespace_type: 'agnostic', + }, + }); +} + +export async function updateOne( + http: HttpStart, + exception: Immutable +): Promise { + await ensureEventFiltersListExists(http); + return http.put(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(cleanEventFilterToUpdate(exception)), + }); +} + +export async function deleteOne(http: HttpStart, id: string): Promise { + await ensureEventFiltersListExists(http); + return http.delete(EXCEPTION_LIST_ITEM_URL, { + query: { + id, + namespace_type: 'agnostic', + }, + }); +} + +export async function getSummary(http: HttpStart): Promise { + await ensureEventFiltersListExists(http); + return http.get(`${EXCEPTION_LIST_URL}/summary`, { + query: { + list_id: ENDPOINT_EVENT_FILTERS_LIST_ID, + namespace_type: 'agnostic', + }, + }); +} + +export function cleanEventFilterToUpdate( + exception: Immutable +): UpdateExceptionListItemSchema { + const exceptionToUpdateCleaned = { ...exception }; + // Clean unnecessary fields for update action + [ + 'created_at', + 'created_by', + 'created_at', + 'created_by', + 'list_id', + 'tie_breaker_id', + 'updated_at', + 'updated_by', + ].forEach((field) => { + delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; + }); + + exceptionToUpdateCleaned.comments = exceptionToUpdateCleaned.comments?.map((comment) => ({ + comment: comment.comment, + id: comment.id, + })); + + return exceptionToUpdateCleaned as UpdateExceptionListItemSchema; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts index 45bb3841c0098..347f1dc088c10 100644 --- a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts @@ -26,7 +26,6 @@ import { ResponseProvidersInterface, } from '../../../common/mock/endpoint/http_handler_mock_factory'; import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator'; -import { POLICY_REFERENCE_PREFIX } from '../../../../common/endpoint/service/trusted_apps/mapping'; import { getTrustedAppsListSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_schema.mock'; import { fleetGetAgentPolicyListHttpMock, @@ -34,6 +33,7 @@ import { fleetGetEndpointPackagePolicyListHttpMock, FleetGetEndpointPackagePolicyListHttpMockInterface, } from './fleet_mocks'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../common/endpoint/service/artifacts/constants'; interface FindExceptionListItemSchemaQueryParams extends Omit { @@ -67,8 +67,8 @@ export const trustedAppsGetListHttpMocks = data[2].tags = [ // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock, // so if using in combination with that API mock, these should just "work" - `${POLICY_REFERENCE_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, - `${POLICY_REFERENCE_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, + `${BY_POLICY_ARTIFACT_TAG_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, + `${BY_POLICY_ARTIFACT_TAG_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, ]; return { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx index 6711b48326bbf..20522e35e8983 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/delete_modal/policy_event_filters_delete_modal.test.tsx @@ -17,7 +17,7 @@ import { } from '../../../../../../common/mock/endpoint'; import { PolicyEventFiltersDeleteModal } from './policy_event_filters_delete_modal'; import { eventFiltersListQueryHttpMock } from '../../../../event_filters/test_utils'; -import { EventFiltersHttpService } from '../../../../event_filters/service'; +import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; describe('Policy details event filter delete modal', () => { let policyId: string; @@ -73,7 +73,7 @@ describe('Policy details event filter delete modal', () => { await waitFor(() => { expect(mockedApi.responseProvider.eventFiltersUpdateOne).toHaveBeenLastCalledWith({ body: JSON.stringify( - EventFiltersHttpService.cleanEventFilterToUpdate({ + cleanEventFilterToUpdate({ ...exception, tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], }) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx index ef1cbc8163705..7d984cdb2a382 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/flyout/policy_event_filters_flyout.test.tsx @@ -25,7 +25,7 @@ import { FoundExceptionListItemSchema, UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { EventFiltersHttpService } from '../../../../event_filters/service'; +import { cleanEventFilterToUpdate } from '../../../../event_filters/service/service_actions'; const getDefaultQueryParameters = (customFilter: string | undefined = '') => ({ path: '/api/exception_lists/items/_find', @@ -56,7 +56,7 @@ const getCleanedExceptionWithNewTags = ( tags: [...testTags, `policy:${policy.id}`], }; - return EventFiltersHttpService.cleanEventFilterToUpdate(exceptionToUpdateWithNewTags); + return cleanEventFilterToUpdate(exceptionToUpdateWithNewTags); }; describe('Policy details event filters flyout', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts index fd5e00668c164..24e3cb464d4d3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/hooks.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useMemo } from 'react'; import pMap from 'p-map'; import { ExceptionListItemSchema, @@ -13,24 +12,20 @@ import { import { QueryObserverResult, useMutation, useQuery, useQueryClient } from 'react-query'; import { ServerApiError } from '../../../../../common/types'; import { useHttp } from '../../../../../common/lib/kibana'; -import { EventFiltersHttpService } from '../../../event_filters/service'; +import { getList, updateOne } from '../../../event_filters/service/service_actions'; import { parseQueryFilterToKQL, parsePoliciesAndFilterToKql } from '../../../../common/utils'; import { SEARCHABLE_FIELDS } from '../../../event_filters/constants'; -export function useGetEventFiltersService() { - const http = useHttp(); - return useMemo(() => new EventFiltersHttpService(http), [http]); -} - export function useGetAllAssignedEventFilters( policyId: string, enabled: boolean = true ): QueryObserverResult { - const service = useGetEventFiltersService(); + const http = useHttp(); return useQuery( ['eventFilters', 'assigned', policyId], () => { - return service.getList({ + return getList({ + http, filter: parsePoliciesAndFilterToKql({ policies: [...(policyId ? [policyId] : []), 'all'] }), }); }, @@ -48,13 +43,14 @@ export function useSearchAssignedEventFilters( policyId: string, options: { filter?: string; page?: number; perPage?: number } ): QueryObserverResult { - const service = useGetEventFiltersService(); + const http = useHttp(); const { filter, page, perPage } = options; return useQuery( ['eventFilters', 'assigned', 'search', policyId, options], () => { - return service.getList({ + return getList({ + http, filter: parsePoliciesAndFilterToKql({ policies: [policyId, 'all'], kuery: parseQueryFilterToKQL(filter || '', SEARCHABLE_FIELDS), @@ -75,13 +71,14 @@ export function useSearchNotAssignedEventFilters( policyId: string, options: { filter?: string; perPage?: number; enabled?: boolean } ): QueryObserverResult { - const service = useGetEventFiltersService(); + const http = useHttp(); return useQuery( ['eventFilters', 'notAssigned', policyId, options], () => { const { filter, perPage } = options; - return service.getList({ + return getList({ + http, filter: parsePoliciesAndFilterToKql({ excludedPolicies: [policyId, 'all'], kuery: parseQueryFilterToKQL(filter || '', SEARCHABLE_FIELDS), @@ -106,7 +103,7 @@ export function useBulkUpdateEventFilters( onSettledCallback?: () => void; } = {} ) { - const service = useGetEventFiltersService(); + const http = useHttp(); const queryClient = useQueryClient(); const { @@ -125,7 +122,7 @@ export function useBulkUpdateEventFilters( return pMap( eventFilters, (eventFilter) => { - return service.updateOne(eventFilter); + return updateOne(http, eventFilter); }, { concurrency: 5, @@ -148,11 +145,11 @@ export function useGetAllEventFilters(): QueryObserverResult< FoundExceptionListItemSchema, ServerApiError > { - const service = useGetEventFiltersService(); + const http = useHttp(); return useQuery( ['eventFilters', 'all'], () => { - return service.getList(); + return getList({ http }); }, { refetchIntervalInBackground: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts index cab162fe30250..8069d18169dd1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts @@ -27,10 +27,8 @@ import { TrustedAppEntryTypes, UpdateTrustedApp, } from '../../../../../common/endpoint/types'; -import { - POLICY_REFERENCE_PREFIX, - tagsToEffectScope, -} from '../../../../../common/endpoint/service/trusted_apps/mapping'; +import { tagsToEffectScope } from '../../../../../common/endpoint/service/trusted_apps/mapping'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../../common/endpoint/service/artifacts/constants'; type ConditionEntriesMap = { [K in ConditionEntryField]?: ConditionEntry }; type Mapping = { [K in T]: U }; @@ -177,9 +175,9 @@ const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNes const effectScopeToTags = (effectScope: EffectScope) => { if (effectScope.type === 'policy') { - return effectScope.policies.map((policy) => `${POLICY_REFERENCE_PREFIX}${policy}`); + return effectScope.policies.map((policy) => `${BY_POLICY_ARTIFACT_TAG_PREFIX}${policy}`); } else { - return [`${POLICY_REFERENCE_PREFIX}all`]; + return [`${BY_POLICY_ARTIFACT_TAG_PREFIX}all`]; } }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 7443e4b0d12a9..4808857849cb3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -301,7 +301,6 @@ describe('When on the Trusted Apps Page', () => { id: '05b5e350-0cad-4dc3-a61d-6e6796b0af39', comments: [], item_id: '2d95bec3-b48f-4db7-9622-a2b061cc031d', - meta: {}, namespace_type: 'agnostic', type: 'simple', }); diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index b61e850df2ef5..3a8616f5d3627 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -6,7 +6,7 @@ */ import { KibanaRequest, Logger } from 'src/core/server'; -import { CreateExceptionListItemOptions, ExceptionListClient } from '../../../lists/server'; +import { ExceptionListClient } from '../../../lists/server'; import { CasesClient, PluginStartContract as CasesPluginStartContract, @@ -35,11 +35,14 @@ import { EndpointAppContentServicesNotStartedError, } from './errors'; import { - EndpointFleetServicesFactory, + EndpointFleetServicesFactoryInterface, EndpointInternalFleetServicesInterface, EndpointScopedFleetServicesInterface, -} from './services/endpoint_fleet_services'; +} from './services/fleet/endpoint_fleet_services_factory'; import type { ListsServerExtensionRegistrar } from '../../../lists/server'; +import { registerListsPluginEndpointExtensionPoints } from '../lists_integration'; +import { EndpointAuthz } from '../../common/endpoint/types/authz'; +import { calculateEndpointAuthz } from '../../common/endpoint/service/authz'; export interface EndpointAppContextServiceSetupContract { securitySolutionRequestContextFactory: IRequestContextFactory; @@ -51,8 +54,10 @@ export type EndpointAppContextServiceStartContract = Partial< 'agentService' | 'packageService' | 'packagePolicyService' | 'agentPolicyService' > > & { + fleetAuthzService?: FleetStartContract['authz']; logger: Logger; endpointMetadataService: EndpointMetadataService; + endpointFleetServicesFactory: EndpointFleetServicesFactoryInterface; manifestManager?: ManifestManager; security: SecurityPluginStart; alerting: AlertsPluginStartContract; @@ -71,7 +76,7 @@ export type EndpointAppContextServiceStartContract = Partial< export class EndpointAppContextService { private setupDependencies: EndpointAppContextServiceSetupContract | null = null; private startDependencies: EndpointAppContextServiceStartContract | null = null; - private fleetServicesFactory: EndpointFleetServicesFactory | null = null; + private fleetServicesFactory: EndpointFleetServicesFactoryInterface | null = null; public security: SecurityPluginStart | undefined; public setup(dependencies: EndpointAppContextServiceSetupContract) { @@ -85,17 +90,7 @@ export class EndpointAppContextService { this.startDependencies = dependencies; this.security = dependencies.security; - - // let's try to avoid turning off eslint's Forbidden non-null assertion rule - const { agentService, agentPolicyService, packagePolicyService, packageService } = - dependencies as Required; - - this.fleetServicesFactory = new EndpointFleetServicesFactory({ - agentService, - agentPolicyService, - packagePolicyService, - packageService, - }); + this.fleetServicesFactory = dependencies.endpointFleetServicesFactory; if (dependencies.registerIngestCallback && dependencies.manifestManager) { dependencies.registerIngestCallback( @@ -124,13 +119,7 @@ export class EndpointAppContextService { if (this.startDependencies.registerListsServerExtension) { const { registerListsServerExtension } = this.startDependencies; - registerListsServerExtension({ - type: 'exceptionsListPreCreateItem', - callback: async (arg: CreateExceptionListItemOptions) => { - // this.startDependencies?.logger.info('exceptionsListPreCreateItem called!'); - return arg; - }, - }); + registerListsPluginEndpointExtensionPoints(registerListsServerExtension, this); } } @@ -140,6 +129,19 @@ export class EndpointAppContextService { return this.startDependencies?.config.experimentalFeatures; } + private getFleetAuthzService(): FleetStartContract['authz'] { + if (!this.startDependencies?.fleetAuthzService) { + throw new EndpointAppContentServicesNotStartedError(); + } + + return this.startDependencies.fleetAuthzService; + } + + public async getEndpointAuthz(request: KibanaRequest): Promise { + const fleetAuthz = await this.getFleetAuthzService().fromRequest(request); + return calculateEndpointAuthz(this.getLicenseService(), fleetAuthz); + } + public getEndpointMetadataService(): EndpointMetadataService { if (this.startDependencies == null) { throw new EndpointAppContentServicesNotStartedError(); @@ -198,4 +200,11 @@ export class EndpointAppContextService { } return this.startDependencies.cases.getCasesClientWithRequest(req); } + + public getExceptionListsClient(): ExceptionListClient { + if (!this.startDependencies?.exceptionListsClient) { + throw new EndpointAppContentServicesNotStartedError(); + } + return this.startDependencies.exceptionListsClient; + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 4da108859691f..fa22869b391ab 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -29,7 +29,6 @@ import { ManifestManager } from './services/artifacts/manifest_manager/manifest_ import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { EndpointAppContext } from './types'; import { MetadataRequestContext } from './routes/metadata/handlers'; -import { LicenseService } from '../../common/license'; import { SecuritySolutionRequestHandlerContext } from '../types'; import { parseExperimentalConfigValue } from '../../common/experimental_features'; // A TS error (TS2403) is thrown when attempting to export the mock function below from Cases @@ -45,6 +44,8 @@ import { createMockClients } from '../lib/detection_engine/routes/__mocks__/requ import { createEndpointMetadataServiceTestContextMock } from './services/metadata/mocks'; import type { EndpointAuthz } from '../../common/endpoint/types/authz'; +import { EndpointFleetServicesFactory } from './services/fleet'; +import { createLicenseServiceMock } from '../../common/license/mocks'; /** * Creates a mocked EndpointAppContext. @@ -102,12 +103,22 @@ export const createMockEndpointAppContextServiceStartContract = const agentService = createMockAgentService(); const agentPolicyService = createMockAgentPolicyService(); const packagePolicyService = createPackagePolicyServiceMock(); + const packageService = createMockPackageService(); const endpointMetadataService = new EndpointMetadataService( savedObjectsStart, agentPolicyService, packagePolicyService, logger ); + const endpointFleetServicesFactory = new EndpointFleetServicesFactory( + { + packageService, + packagePolicyService, + agentPolicyService, + agentService, + }, + savedObjectsStart + ); packagePolicyService.list.mockImplementation(async (_, options) => { return { @@ -122,14 +133,16 @@ export const createMockEndpointAppContextServiceStartContract = agentService, agentPolicyService, endpointMetadataService, + endpointFleetServicesFactory, packagePolicyService, logger, - packageService: createMockPackageService(), + packageService, + fleetAuthzService: createFleetAuthzServiceMock(), manifestManager: getManifestManagerMock(), security: securityMock.createStart(), alerting: alertsMock.createStart(), config, - licenseService: new LicenseService(), + licenseService: createLicenseServiceMock(), registerIngestCallback: jest.fn< ReturnType, Parameters @@ -141,18 +154,21 @@ export const createMockEndpointAppContextServiceStartContract = }; }; +export const createFleetAuthzServiceMock = (): jest.Mocked => { + return { + fromRequest: jest.fn(async (_) => createFleetAuthzMock()), + }; +}; + /** - * Creates a mock IndexPatternService for use in tests that need to interact with the Fleet's - * ESIndexPatternService. + * Creates the Fleet Start contract mock return by the Fleet Plugin * * @param indexPattern a string index pattern to return when called by a test * @returns the same value as `indexPattern` parameter */ export const createMockFleetStartContract = (indexPattern: string): FleetStartContract => { return { - authz: { - fromRequest: jest.fn().mockResolvedValue(createFleetAuthzMock()), - }, + authz: createFleetAuthzServiceMock(), fleetSetupCompleted: jest.fn().mockResolvedValue(undefined), esIndexPatternService: { getESIndexPattern: jest.fn().mockResolvedValue(indexPattern), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 4b1ea1fe9efaa..02d13c7c057d1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -42,7 +42,7 @@ import { ENDPOINT_DEFAULT_PAGE_SIZE, METADATA_TRANSFORMS_PATTERN, } from '../../../../common/endpoint/constants'; -import { EndpointFleetServicesInterface } from '../../services/endpoint_fleet_services'; +import { EndpointFleetServicesInterface } from '../../services/fleet/endpoint_fleet_services_factory'; export interface MetadataRequestContext { esClient?: IScopedClusterClient; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 527afe23b694d..9ad26443cf0d5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -11,11 +11,7 @@ import { createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; -import { - createMockAgentClient, - createMockAgentService, - createPackagePolicyServiceMock, -} from '../../../../../fleet/server/mocks'; +import { createMockAgentClient, createMockAgentService } from '../../../../../fleet/server/mocks'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../fleet/common'; import { getHostPolicyResponseHandler, @@ -251,10 +247,20 @@ describe('test policy response handler', () => { let policyHandler: ReturnType; beforeEach(() => { + const endpointAppContextServiceStartContract = + createMockEndpointAppContextServiceStartContract(); + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockResponse = httpServerMock.createResponseFactory(); - mockPackagePolicyService = createPackagePolicyServiceMock(); + + if (endpointAppContextServiceStartContract.packagePolicyService) { + mockPackagePolicyService = + endpointAppContextServiceStartContract.packagePolicyService as jest.Mocked; + } else { + expect(endpointAppContextServiceStartContract.packagePolicyService).toBeTruthy(); + } + mockPackagePolicyService.list.mockImplementation(() => { return Promise.resolve({ items: [], @@ -265,10 +271,7 @@ describe('test policy response handler', () => { }); endpointAppContextService = new EndpointAppContextService(); endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); - endpointAppContextService.start({ - ...createMockEndpointAppContextServiceStartContract(), - ...{ packagePolicyService: mockPackagePolicyService }, - }); + endpointAppContextService.start(endpointAppContextServiceStartContract); policyHandler = getPolicyListHandler({ logFactory: loggingSystemMock.create(), service: endpointAppContextService, @@ -289,6 +292,7 @@ describe('test policy response handler', () => { mockRequest, mockResponse ); + expect(mockPackagePolicyService.list).toHaveBeenCalled(); expect(mockPackagePolicyService.list.mock.calls[0][1]).toEqual({ kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: endpoint`, perPage: undefined, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts similarity index 81% rename from x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts rename to x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts index 915070a9b064f..7ab263705eaaf 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/endpoint_fleet_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts @@ -5,14 +5,15 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, SavedObjectsClientContract, SavedObjectsServiceStart } from 'kibana/server'; import type { AgentClient, AgentPolicyServiceInterface, FleetStartContract, PackagePolicyServiceInterface, PackageClient, -} from '../../../../fleet/server'; +} from '../../../../../fleet/server'; +import { createInternalReadonlySoClient } from '../../utils/create_internal_readonly_so_client'; export interface EndpointFleetServicesFactoryInterface { asScoped(req: KibanaRequest): EndpointScopedFleetServicesInterface; @@ -25,7 +26,8 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor private readonly fleetDependencies: Pick< FleetStartContract, 'agentService' | 'packageService' | 'packagePolicyService' | 'agentPolicyService' - > + >, + private savedObjectsStart: SavedObjectsServiceStart ) {} asScoped(req: KibanaRequest): EndpointScopedFleetServicesInterface { @@ -61,6 +63,7 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor packagePolicy, asScoped: this.asScoped.bind(this), + internalReadonlySoClient: createInternalReadonlySoClient(this.savedObjectsStart), }; } } @@ -87,4 +90,9 @@ export interface EndpointInternalFleetServicesInterface extends EndpointFleetSer * get scoped endpoint fleet services instance */ asScoped: EndpointFleetServicesFactoryInterface['asScoped']; + + /** + * An internal SO client (readonly) that can be used with the Fleet services that require it + */ + internalReadonlySoClient: SavedObjectsClientContract; } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/fleet/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/fleet/index.ts new file mode 100644 index 0000000000000..6e1088a21ee02 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/fleet/index.ts @@ -0,0 +1,8 @@ +/* + * 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 './endpoint_fleet_services_factory'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts index 5251992a5d3d4..857b9ca18163c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts @@ -56,7 +56,7 @@ import { getAllEndpointPackagePolicies } from '../../routes/metadata/support/end import { getAgentStatus } from '../../../../../fleet/common/services/agent_status'; import { GetMetadataListRequestQuery } from '../../../../common/endpoint/schema/metadata'; import { EndpointError } from '../../../../common/endpoint/errors'; -import { EndpointFleetServicesInterface } from '../endpoint_fleet_services'; +import { EndpointFleetServicesInterface } from '../fleet/endpoint_fleet_services_factory'; type AgentPolicyWithPackagePolicies = Omit & { package_policies: PackagePolicy[]; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts index 3cd368d35b657..599f153e19e05 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts @@ -21,7 +21,7 @@ import { AgentPolicyServiceInterface, AgentService } from '../../../../../fleet/ import { EndpointFleetServicesFactory, EndpointInternalFleetServicesInterface, -} from '../endpoint_fleet_services'; +} from '../fleet/endpoint_fleet_services_factory'; const createCustomizedPackagePolicyService = () => { const service = createPackagePolicyServiceMock(); @@ -62,12 +62,15 @@ export const createEndpointMetadataServiceTestContextMock = ( .create() .get() ): EndpointMetadataServiceTestContextMock => { - const fleetServices = new EndpointFleetServicesFactory({ - agentService, - packageService, - packagePolicyService, - agentPolicyService, - }).asInternalUser(); + const fleetServices = new EndpointFleetServicesFactory( + { + agentService, + packageService, + packagePolicyService, + agentPolicyService, + }, + savedObjectsStart + ).asInternalUser(); const endpointMetadataService = new EndpointMetadataService( savedObjectsStart, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md index e0ada4aad0817..635839577fb2d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md @@ -40,12 +40,14 @@ - templateTimelineId: Specify an unique uuid e.g.: `2c7e0663-5a91-0004-aa15-26bf756d2c40` - - templateTimelineVersion: just start from `1` + - templateTimelineVersion: start from `1`, bump it on update - timelineType: `template` - status: `immutable` + - indexNames: [] + 3. ```cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts``` diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson index 549f6733b0208..84972a837a3e8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson @@ -11,4 +11,4 @@ {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"","queryMatch":{"displayValue":"endpoint","field":"agent.type","displayField":"agent.type","value":"endpoint","operator":":"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"default","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Endpoint Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"db366523-f1c6-4c1f-8731-6ce5ed9e5717","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735857110,"createdBy":"Elastic","updated":1611609999115,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","searchable":null,"example":"user-password-change"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"destination.port","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"host.name","searchable":null}],"dataProviders":[{"and":[{"enabled":true,"excluded":false,"id":"timeline-1-e37e37c5-a6e7-4338-af30-47bfbc3c0e1e","kqlQuery":"","name":"{destination.ip}","queryMatch":{"displayField":"destination.ip","displayValue":"{destination.ip}","field":"destination.ip","operator":":","value":"{destination.ip}"},"type":"template"}],"enabled":true,"excluded":false,"id":"timeline-1-ec778f01-1802-40f0-9dfb-ed8de1f656cb","kqlQuery":"","name":"{source.ip}","queryMatch":{"displayField":"source.ip","displayValue":"{source.ip}","field":"source.ip","operator":":","value":"{source.ip}"},"type":"template"}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Network Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"91832785-286d-4ebe-b884-1a208d111a70","dateRange":{"start":1588255858373,"end":1588256218373},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735573866,"createdBy":"Elastic","updated":1611609960850,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} {"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":null,"field":"process.name","displayField":null,"value":"{process.name}","operator":":"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Process Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"76e52245-7519-4251-91ab-262fb1a1728c","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735629389,"createdBy":"Elastic","updated":1611609848602,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} -{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description"},{"aggregatable":true,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"columnHeaderType":"not-filtered","id":"process.pid"},{"aggregatable":true,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"aggregatable":true,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number"},{"aggregatable":true,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","example":"albert"},{"columnHeaderType":"not-filtered","id":"host.name"}],"dataProviders":[{"excluded":false,"and":[{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.type}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.type","displayField":null,"value":"{threat.enrichments.matched.type}","operator":":"},"id":"timeline-1-ae18ef4b-f690-4122-a24d-e13b6818fba8","type":"template","enabled":true},{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.field}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.field","displayField":null,"value":"{threat.enrichments.matched.field}","operator":":"},"id":"timeline-1-7b4cf27e-6788-4d8e-9188-7687f0eba0f2","type":"template","enabled":true}],"kqlQuery":"","name":"{threat.enrichments.matched.atomic}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.atomic","displayField":null,"value":"{threat.enrichments.matched.atomic}","operator":":"},"id":"timeline-1-7db7d278-a80a-4853-971a-904319c50777","type":"template","enabled":true}],"description":"This Timeline template is for alerts generated by Indicator Match detection rules.","eqlOptions":{"eventCategoryField":"event.category","tiebreakerField":"","timestampField":"@timestamp","query":"","size":100},"eventType":"alert","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"dataViewId": "security-solution","indexNames":[".siem-signals-default"],"title":"Generic Threat Match Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"495ad7a7-316e-4544-8a0f-9c098daee76e","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":[{"sortDirection":"desc","columnId":"@timestamp"}],"created":1616696609311,"createdBy":"elastic","updated":1616788372794,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description"},{"aggregatable":true,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"columnHeaderType":"not-filtered","id":"process.pid"},{"aggregatable":true,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"aggregatable":true,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number"},{"aggregatable":true,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","example":"albert"},{"columnHeaderType":"not-filtered","id":"host.name"}],"dataProviders":[{"excluded":false,"and":[{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.type}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.type","displayField":null,"value":"{threat.enrichments.matched.type}","operator":":"},"id":"timeline-1-ae18ef4b-f690-4122-a24d-e13b6818fba8","type":"template","enabled":true},{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.field}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.field","displayField":null,"value":"{threat.enrichments.matched.field}","operator":":"},"id":"timeline-1-7b4cf27e-6788-4d8e-9188-7687f0eba0f2","type":"template","enabled":true}],"kqlQuery":"","name":"{threat.enrichments.matched.atomic}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.atomic","displayField":null,"value":"{threat.enrichments.matched.atomic}","operator":":"},"id":"timeline-1-7db7d278-a80a-4853-971a-904319c50777","type":"template","enabled":true}],"description":"This Timeline template is for alerts generated by Indicator Match detection rules.","eqlOptions":{"eventCategoryField":"event.category","tiebreakerField":"","timestampField":"@timestamp","query":"","size":100},"eventType":"alert","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"dataViewId": "security-solution","indexNames":[],"title":"Generic Threat Match Timeline","timelineType":"template","templateTimelineVersion":3,"templateTimelineId":"495ad7a7-316e-4544-8a0f-9c098daee76e","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":[{"sortDirection":"desc","columnId":"@timestamp"}],"created":1616696609311,"createdBy":"elastic","updated":1616788372794,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/threat.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/threat.json index 588ead3db2c44..64ebd134b6805 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/threat.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/threat.json @@ -1 +1 @@ -{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description"},{"aggregatable":true,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"columnHeaderType":"not-filtered","id":"process.pid"},{"aggregatable":true,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"aggregatable":true,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number"},{"aggregatable":true,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","example":"albert"},{"columnHeaderType":"not-filtered","id":"host.name"}],"dataProviders":[{"excluded":false,"and":[{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.type}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.type","displayField":null,"value":"{threat.enrichments.matched.type}","operator":":"},"id":"timeline-1-ae18ef4b-f690-4122-a24d-e13b6818fba8","type":"template","enabled":true},{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.field}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.field","displayField":null,"value":"{threat.enrichments.matched.field}","operator":":"},"id":"timeline-1-7b4cf27e-6788-4d8e-9188-7687f0eba0f2","type":"template","enabled":true}],"kqlQuery":"","name":"{threat.enrichments.matched.atomic}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.atomic","displayField":null,"value":"{threat.enrichments.matched.atomic}","operator":":"},"id":"timeline-1-7db7d278-a80a-4853-971a-904319c50777","type":"template","enabled":true}],"description":"This Timeline template is for alerts generated by Indicator Match detection rules.","eqlOptions":{"eventCategoryField":"event.category","tiebreakerField":"","timestampField":"@timestamp","query":"","size":100},"eventType":"alert","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"dataViewId": "security-solution","indexNames":[".siem-signals-default"],"title":"Generic Threat Match Timeline","timelineType":"template","templateTimelineVersion":2,"templateTimelineId":"495ad7a7-316e-4544-8a0f-9c098daee76e","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":[{"sortDirection":"desc","columnId":"@timestamp"}],"created":1616696609311,"createdBy":"elastic","updated":1616788372794,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"kibana.alert.rule.description"},{"aggregatable":true,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","example":"user-password-change"},{"aggregatable":true,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"columnHeaderType":"not-filtered","id":"process.pid"},{"aggregatable":true,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip"},{"aggregatable":true,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number"},{"aggregatable":true,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip"},{"columnHeaderType":"not-filtered","id":"destination.port"},{"aggregatable":true,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","example":"albert"},{"columnHeaderType":"not-filtered","id":"host.name"}],"dataProviders":[{"excluded":false,"and":[{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.type}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.type","displayField":null,"value":"{threat.enrichments.matched.type}","operator":":"},"id":"timeline-1-ae18ef4b-f690-4122-a24d-e13b6818fba8","type":"template","enabled":true},{"excluded":false,"kqlQuery":"","name":"{threat.enrichments.matched.field}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.field","displayField":null,"value":"{threat.enrichments.matched.field}","operator":":"},"id":"timeline-1-7b4cf27e-6788-4d8e-9188-7687f0eba0f2","type":"template","enabled":true}],"kqlQuery":"","name":"{threat.enrichments.matched.atomic}","queryMatch":{"displayValue":null,"field":"threat.enrichments.matched.atomic","displayField":null,"value":"{threat.enrichments.matched.atomic}","operator":":"},"id":"timeline-1-7db7d278-a80a-4853-971a-904319c50777","type":"template","enabled":true}],"description":"This Timeline template is for alerts generated by Indicator Match detection rules.","eqlOptions":{"eventCategoryField":"event.category","tiebreakerField":"","timestampField":"@timestamp","query":"","size":100},"eventType":"alert","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"dataViewId": "security-solution","indexNames":[],"title":"Generic Threat Match Timeline","timelineType":"template","templateTimelineVersion":3,"templateTimelineId":"495ad7a7-316e-4544-8a0f-9c098daee76e","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":[{"sortDirection":"desc","columnId":"@timestamp"}],"created":1616696609311,"createdBy":"elastic","updated":1616788372794,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts new file mode 100644 index 0000000000000..08a6f80162d03 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -0,0 +1,26 @@ +/* + * 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 { + CreateExceptionListItemOptions, + ExceptionsListPreCreateItemServerExtension, +} from '../../../../../lists/server'; +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { TrustedAppValidator } from '../validators'; + +export const getExceptionsPreCreateItemHandler = ( + endpointAppContext: EndpointAppContextService +): ExceptionsListPreCreateItemServerExtension['callback'] => { + return async function ({ data, context: { request } }): Promise { + // Validate trusted apps + if (TrustedAppValidator.isTrustedApp(data)) { + return new TrustedAppValidator(endpointAppContext, request).validatePreCreateItem(data); + } + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts new file mode 100644 index 0000000000000..4c162bb03a5e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -0,0 +1,43 @@ +/* + * 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 { + ExceptionsListPreUpdateItemServerExtension, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { TrustedAppValidator } from '../validators'; + +export const getExceptionsPreUpdateItemHandler = ( + endpointAppContextService: EndpointAppContextService +): ExceptionsListPreUpdateItemServerExtension['callback'] => { + return async function ({ data, context: { request } }): Promise { + const currentSavedItem = await endpointAppContextService + .getExceptionListsClient() + .getExceptionListItem({ + id: data.id, + itemId: data.itemId, + namespaceType: data.namespaceType, + }); + + // We don't want to `throw` here becuase we don't know for sure that the item is one we care about. + // So we just return the data and the Lists plugin will likely error out because it can't find the item + if (!currentSavedItem) { + return data; + } + + // Validate trusted apps + if (TrustedAppValidator.isTrustedApp({ listId: currentSavedItem.list_id })) { + return new TrustedAppValidator(endpointAppContextService, request).validatePreUpdateItem( + data, + currentSavedItem + ); + } + + return data; + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts new file mode 100644 index 0000000000000..bc0c59f44be13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/register_endpoint_extension_points.ts @@ -0,0 +1,28 @@ +/* + * 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 { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services'; +import { getExceptionsPreCreateItemHandler } from './handlers/exceptions_pre_create_handler'; +import { getExceptionsPreUpdateItemHandler } from './handlers/exceptions_pre_update_handler'; +import type { ListsServerExtensionRegistrar } from '../../../../lists/server'; + +export const registerListsPluginEndpointExtensionPoints = ( + registerListsExtensionPoint: ListsServerExtensionRegistrar, + endpointAppContextService: EndpointAppContextService +): void => { + // PRE-CREATE handler + registerListsExtensionPoint({ + type: 'exceptionsListPreCreateItem', + callback: getExceptionsPreCreateItemHandler(endpointAppContextService), + }); + + // PRE-UPDATE handler + registerListsExtensionPoint({ + type: 'exceptionsListPreUpdateItem', + callback: getExceptionsPreUpdateItemHandler(endpointAppContextService), + }); +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/types.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/types.ts new file mode 100644 index 0000000000000..4b00bafafc967 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/types.ts @@ -0,0 +1,19 @@ +/* + * 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 { CreateExceptionListItemOptions } from '../../../../lists/server'; + +/** + * An Exception Like item is a structure used internally by several of the Exceptions api/service in that + * the keys are camelCased. Because different methods of the ExceptionListClient have slightly different + * structures, this one attempt to normalize the properties we care about here that can be found across + * those service methods. + */ +export type ExceptionItemLikeOptions = Pick< + CreateExceptionListItemOptions, + 'osTypes' | 'tags' | 'description' | 'name' | 'entries' | 'namespaceType' +> & { listId?: string }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts new file mode 100644 index 0000000000000..728e3b8559ed3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.test.ts @@ -0,0 +1,187 @@ +/* + * 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 { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceSetupContract, + createMockEndpointAppContextServiceStartContract, +} from '../../../endpoint/mocks'; +import { BaseValidatorMock, createExceptionItemLikeOptionsMock } from './mocks'; +import { EndpointArtifactExceptionValidationError } from './errors'; +import { httpServerMock } from '../../../../../../../src/core/server/mocks'; +import { createFleetAuthzMock, PackagePolicy } from '../../../../../fleet/common'; +import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; +import { ExceptionItemLikeOptions } from '../types'; +import { + BY_POLICY_ARTIFACT_TAG_PREFIX, + GLOBAL_ARTIFACT_TAG, +} from '../../../../common/endpoint/service/artifacts'; + +describe('When using Artifacts Exceptions BaseValidator', () => { + let endpointAppContextServices: EndpointAppContextService; + let kibanaRequest: ReturnType; + let exceptionLikeItem: ExceptionItemLikeOptions; + let validator: BaseValidatorMock; + let packagePolicyService: jest.Mocked; + let initValidator: (withNoAuth?: boolean, withBasicLicense?: boolean) => BaseValidatorMock; + + beforeEach(() => { + kibanaRequest = httpServerMock.createKibanaRequest(); + exceptionLikeItem = createExceptionItemLikeOptionsMock(); + + const servicesStart = createMockEndpointAppContextServiceStartContract(); + + packagePolicyService = + servicesStart.packagePolicyService as jest.Mocked; + + endpointAppContextServices = new EndpointAppContextService(); + endpointAppContextServices.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextServices.start(servicesStart); + + initValidator = (withNoAuth: boolean = false, withBasicLicense = false) => { + if (withNoAuth) { + const fleetAuthz = createFleetAuthzMock(); + fleetAuthz.fleet.all = false; + (servicesStart.fleetAuthzService?.fromRequest as jest.Mock).mockResolvedValue(fleetAuthz); + } + + if (withBasicLicense) { + (servicesStart.licenseService.isPlatinumPlus as jest.Mock).mockResolvedValue(false); + } + + validator = new BaseValidatorMock(endpointAppContextServices, kibanaRequest); + + return validator; + }; + }); + + it('should use default endpoint authz (no access) when `request` is not provided', async () => { + const baseValidator = new BaseValidatorMock(endpointAppContextServices); + + await expect(baseValidator._isAllowedToCreateArtifactsByPolicy()).resolves.toBe(false); + await expect(baseValidator._validateCanManageEndpointArtifacts()).rejects.toBeInstanceOf( + EndpointArtifactExceptionValidationError + ); + }); + + it('should validate is allowed to manage endpoint artifacts', async () => { + await expect(initValidator()._validateCanManageEndpointArtifacts()).resolves.toBeUndefined(); + }); + + it('should throw if not allowed to manage endpoint artifacts', async () => { + await expect(initValidator(true)._validateCanManageEndpointArtifacts()).rejects.toBeInstanceOf( + EndpointArtifactExceptionValidationError + ); + }); + + it('should validate basic artifact data', async () => { + await expect(initValidator()._validateBasicData(exceptionLikeItem)).resolves.toBeUndefined(); + }); + + it.each([ + [ + 'name is empty', + () => { + exceptionLikeItem.name = ''; + }, + ], + [ + 'namespace is not agnostic', + () => { + exceptionLikeItem.namespaceType = 'single'; + }, + ], + [ + 'osTypes has more than 1 value', + () => { + exceptionLikeItem.osTypes = ['macos', 'linux']; + }, + ], + [ + 'osType has invalid value', + () => { + exceptionLikeItem.osTypes = ['xunil' as 'linux']; + }, + ], + ])('should throw if %s', async (_, setupData) => { + setupData(); + + await expect(initValidator()._validateBasicData(exceptionLikeItem)).rejects.toBeInstanceOf( + EndpointArtifactExceptionValidationError + ); + }); + + it('should validate is allowed to create artifacts by policy', async () => { + await expect( + initValidator()._validateCanCreateByPolicyArtifacts(exceptionLikeItem) + ).resolves.toBeUndefined(); + }); + + it('should throw if not allowed to create artifacts by policy', async () => { + await expect( + initValidator(false, true)._validateCanCreateByPolicyArtifacts(exceptionLikeItem) + ).rejects.toBeInstanceOf(EndpointArtifactExceptionValidationError); + }); + + it('should validate policy ids for by policy artifacts', async () => { + packagePolicyService.getByIDs.mockResolvedValue([ + { + id: '123', + version: '123', + } as PackagePolicy, + ]); + + await expect(initValidator()._validateByPolicyItem(exceptionLikeItem)).resolves.toBeUndefined(); + }); + + it('should throw if policy ids for by policy artifacts are not valid', async () => { + packagePolicyService.getByIDs.mockResolvedValue([ + { + id: '123', + version: undefined, + } as PackagePolicy, + ]); + + await expect(initValidator()._validateByPolicyItem(exceptionLikeItem)).rejects.toBeInstanceOf( + EndpointArtifactExceptionValidationError + ); + }); + + it.each([ + ['no policies (unassigned)', () => createExceptionItemLikeOptionsMock({ tags: [] })], + [ + 'different policy', + () => createExceptionItemLikeOptionsMock({ tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}:456`] }), + ], + [ + 'additional policies', + () => + createExceptionItemLikeOptionsMock({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}:123`, `${BY_POLICY_ARTIFACT_TAG_PREFIX}:456`], + }), + ], + ])( + 'should return `true` when `wasByPolicyEffectScopeChanged()` is called with: %s', + (_, getUpdated) => { + expect(initValidator()._wasByPolicyEffectScopeChanged(getUpdated(), exceptionLikeItem)).toBe( + true + ); + } + ); + + it.each([ + ['identical data', () => createExceptionItemLikeOptionsMock()], + [ + 'scope changed to all', + () => createExceptionItemLikeOptionsMock({ tags: [GLOBAL_ARTIFACT_TAG] }), + ], + ])('should return `false` when `wasByPolicyEffectScopeChanged()` with: %s', (_, getUpdated) => { + expect(initValidator()._wasByPolicyEffectScopeChanged(getUpdated(), exceptionLikeItem)).toBe( + false + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts new file mode 100644 index 0000000000000..d320a2ecc8aef --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts @@ -0,0 +1,164 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { isEqual } from 'lodash/fp'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; +import { ExceptionItemLikeOptions } from '../types'; +import { getEndpointAuthzInitialState } from '../../../../common/endpoint/service/authz'; +import { + getPolicyIdsFromArtifact, + isArtifactByPolicy, +} from '../../../../common/endpoint/service/artifacts'; +import { OperatingSystem } from '../../../../common/endpoint/types'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const BasicEndpointExceptionDataSchema = schema.object( + { + // must have a name + name: schema.string({ minLength: 1, maxLength: 256 }), + + description: schema.maybe(schema.string({ minLength: 0, maxLength: 256, defaultValue: '' })), + + // We only support agnostic entries + namespaceType: schema.literal('agnostic'), + + // only one OS per entry + osTypes: schema.arrayOf( + schema.oneOf([ + schema.literal(OperatingSystem.WINDOWS), + schema.literal(OperatingSystem.LINUX), + schema.literal(OperatingSystem.MAC), + ]), + { minSize: 1, maxSize: 1 } + ), + }, + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +/** + * Provides base methods for doing validation that apply across endpoint exception entries + */ +export class BaseValidator { + private readonly endpointAuthzPromise: ReturnType; + + constructor( + protected readonly endpointAppContext: EndpointAppContextService, + /** + * Request is optional only because it needs to be optional in the Lists ExceptionListClient + */ + private readonly request?: KibanaRequest + ) { + if (this.request) { + this.endpointAuthzPromise = this.endpointAppContext.getEndpointAuthz(this.request); + } else { + this.endpointAuthzPromise = Promise.resolve(getEndpointAuthzInitialState()); + } + } + + protected isItemByPolicy(item: ExceptionItemLikeOptions): boolean { + return isArtifactByPolicy(item); + } + + protected async isAllowedToCreateArtifactsByPolicy(): Promise { + return (await this.endpointAuthzPromise).canCreateArtifactsByPolicy; + } + + protected async validateCanManageEndpointArtifacts(): Promise { + if (!(await this.endpointAuthzPromise).canAccessEndpointManagement) { + throw new EndpointArtifactExceptionValidationError('Endpoint authorization failure', 403); + } + } + + /** + * validates some basic common data that can be found across all endpoint exceptions + * @param item + * @protected + */ + protected async validateBasicData(item: ExceptionItemLikeOptions) { + try { + BasicEndpointExceptionDataSchema.validate(item); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } + + protected async validateCanCreateByPolicyArtifacts( + item: ExceptionItemLikeOptions + ): Promise { + if (this.isItemByPolicy(item) && !(await this.isAllowedToCreateArtifactsByPolicy())) { + throw new EndpointArtifactExceptionValidationError( + 'Your license level does not allow create/update of by policy artifacts', + 403 + ); + } + } + + /** + * Validates that by-policy artifacts is permitted and that each policy referenced in the item is valid + * @protected + */ + protected async validateByPolicyItem(item: ExceptionItemLikeOptions): Promise { + if (this.isItemByPolicy(item)) { + const { packagePolicy, internalReadonlySoClient } = + this.endpointAppContext.getInternalFleetServices(); + const policyIds = getPolicyIdsFromArtifact(item); + + if (policyIds.length === 0) { + return; + } + + const policiesFromFleet = await packagePolicy.getByIDs(internalReadonlySoClient, policyIds); + + if (!policiesFromFleet) { + throw new EndpointArtifactExceptionValidationError( + `invalid policy ids: ${policyIds.join(', ')}` + ); + } + + const invalidPolicyIds = policiesFromFleet + .filter((policy) => policy.version === undefined) + .map((policy) => policy.id); + + if (invalidPolicyIds.length) { + throw new EndpointArtifactExceptionValidationError( + `invalid policy ids: ${invalidPolicyIds.join(', ')}` + ); + } + } + } + + /** + * If the item being updated is `by policy`, method validates if anyting was changes in regard to + * the effected scope of the by policy settings. + * + * @param updatedItem + * @param currentItem + * @protected + */ + protected wasByPolicyEffectScopeChanged( + updatedItem: ExceptionItemLikeOptions, + currentItem: Pick + ): boolean { + // if global, then return. Nothing to validate and setting the trusted app to global is allowed + if (!this.isItemByPolicy(updatedItem)) { + return false; + } + + if (updatedItem.tags) { + return !isEqual( + getPolicyIdsFromArtifact({ tags: updatedItem.tags }), + getPolicyIdsFromArtifact(currentItem) + ); + } + + return false; + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/errors.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/errors.ts new file mode 100644 index 0000000000000..69a8d9c5914de --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/errors.ts @@ -0,0 +1,14 @@ +/* + * 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 { ListsErrorWithStatusCode } from '../../../../../lists/server'; + +export class EndpointArtifactExceptionValidationError extends ListsErrorWithStatusCode { + constructor(message: string, statusCode: number = 400) { + super(`EndpointArtifactError: ${message}`, statusCode); + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts new file mode 100644 index 0000000000000..be26c31e2e155 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { TrustedAppValidator } from './trusted_app_validator'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts new file mode 100644 index 0000000000000..97a29aee962e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/mocks.ts @@ -0,0 +1,60 @@ +/* + * 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { listMock } from '../../../../../lists/server/mocks'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../common/endpoint/service/artifacts'; + +/** + * Exposes all `protected` methods of `BaseValidator` by prefixing them with an underscore. + */ +export class BaseValidatorMock extends BaseValidator { + _isItemByPolicy(item: ExceptionItemLikeOptions): boolean { + return this.isItemByPolicy(item); + } + + async _isAllowedToCreateArtifactsByPolicy(): Promise { + return this.isAllowedToCreateArtifactsByPolicy(); + } + + async _validateCanManageEndpointArtifacts(): Promise { + return this.validateCanManageEndpointArtifacts(); + } + + async _validateBasicData(item: ExceptionItemLikeOptions) { + return this.validateBasicData(item); + } + + async _validateCanCreateByPolicyArtifacts(item: ExceptionItemLikeOptions): Promise { + return this.validateCanCreateByPolicyArtifacts(item); + } + + async _validateByPolicyItem(item: ExceptionItemLikeOptions): Promise { + return this.validateByPolicyItem(item); + } + + _wasByPolicyEffectScopeChanged( + updatedItem: ExceptionItemLikeOptions, + currentItem: Pick + ): boolean { + return this.wasByPolicyEffectScopeChanged(updatedItem, currentItem); + } +} + +export const createExceptionItemLikeOptionsMock = ( + overrides: Partial = {} +): ExceptionItemLikeOptions => { + return { + ...listMock.getCreateExceptionListItemOptionsMock(), + namespaceType: 'agnostic', + osTypes: ['windows'], + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`], + ...overrides, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts new file mode 100644 index 0000000000000..26208661b4b6b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -0,0 +1,224 @@ +/* + * 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 { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { + ConditionEntry, + OperatingSystem, + TrustedAppEntryTypes, +} from '../../../../common/endpoint/types'; +import { + getDuplicateFields, + isValidHash, +} from '../../../../common/endpoint/service/trusted_apps/validations'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const ProcessHashField = schema.oneOf([ + schema.literal('process.hash.md5'), + schema.literal('process.hash.sha1'), + schema.literal('process.hash.sha256'), +]); +const ProcessExecutablePath = schema.literal('process.executable.caseless'); +const ProcessCodeSigner = schema.literal('process.Ext.code_signature'); + +const ConditionEntryTypeSchema = schema.conditional( + schema.siblingRef('field'), + ProcessExecutablePath, + schema.oneOf([schema.literal('match'), schema.literal('wildcard')]), + schema.literal('match') +); +const ConditionEntryOperatorSchema = schema.literal('included'); + +type ConditionEntryFieldAllowedType = + | TypeOf + | TypeOf + | TypeOf; + +type TrustedAppConditionEntry< + T extends ConditionEntryFieldAllowedType = ConditionEntryFieldAllowedType +> = + | { + field: T; + type: TrustedAppEntryTypes; + operator: 'included'; + value: string; + } + | TypeOf; + +/* + * A generic Entry schema to be used for a specific entry schema depending on the OS + */ +const CommonEntrySchema = { + field: schema.oneOf([ProcessHashField, ProcessExecutablePath]), + type: ConditionEntryTypeSchema, + operator: ConditionEntryOperatorSchema, + // If field === HASH then validate hash with custom method, else validate string with minLength = 1 + value: schema.conditional( + schema.siblingRef('field'), + ProcessHashField, + schema.string({ + validate: (hash: string) => (isValidHash(hash) ? undefined : `invalid hash value [${hash}]`), + }), + schema.conditional( + schema.siblingRef('field'), + ProcessExecutablePath, + schema.string({ + validate: (pathValue: string) => + pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`, + }), + schema.string({ + validate: (signerValue: string) => + signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`, + }) + ) + ), +}; + +// Windows Signer entries use a Nested field that checks to ensure +// that the certificate is trusted +const WindowsSignerEntrySchema = schema.object({ + type: schema.literal('nested'), + field: ProcessCodeSigner, + entries: schema.arrayOf( + schema.oneOf([ + schema.object({ + field: schema.literal('trusted'), + value: schema.literal('true'), + type: schema.literal('match'), + operator: schema.literal('included'), + }), + schema.object({ + field: schema.literal('subject_name'), + value: schema.string({ minLength: 1 }), + type: schema.literal('match'), + operator: schema.literal('included'), + }), + ]), + { minSize: 2, maxSize: 2 } + ), +}); + +const WindowsEntrySchema = schema.oneOf([ + WindowsSignerEntrySchema, + schema.object({ + ...CommonEntrySchema, + field: schema.oneOf([ProcessHashField, ProcessExecutablePath]), + }), +]); + +const LinuxEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const MacEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const entriesSchemaOptions = { + minSize: 1, + validate(entries: TrustedAppConditionEntry[]) { + const dups = getDuplicateFields(entries as ConditionEntry[]); + return dups.map((field) => `Duplicated entry: ${field}`).join(', ') || undefined; + }, +}; + +/* + * Entities array schema depending on Os type using schema.conditional. + * If OS === WINDOWS then use Windows schema, + * else if OS === LINUX then use Linux schema, + * else use Mac schema + * + * The validate function checks there is no duplicated entry inside the array + */ +const EntriesSchema = schema.conditional( + schema.contextRef('os'), + OperatingSystem.WINDOWS, + schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions), + schema.conditional( + schema.contextRef('os'), + OperatingSystem.LINUX, + schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions), + schema.arrayOf(MacEntrySchema, entriesSchemaOptions) + ) +); + +/** + * Schema to validate Trusted Apps data for create and update. + * When called, it must be given an `context` with a `os` property set + * + * @example + * + * TrustedAppDataSchema.validate(item, { os: 'windows' }); + */ +const TrustedAppDataSchema = schema.object( + { + entries: EntriesSchema, + }, + + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +export class TrustedAppValidator extends BaseValidator { + static isTrustedApp(item: { listId: string }): boolean { + return item.listId === ENDPOINT_TRUSTED_APPS_LIST_ID; + } + + async validatePreCreateItem( + item: CreateExceptionListItemOptions + ): Promise { + await this.validateCanManageEndpointArtifacts(); + await this.validateTrustedAppData(item); + await this.validateCanCreateByPolicyArtifacts(item); + await this.validateByPolicyItem(item); + + return item; + } + + async validatePreUpdateItem( + _updatedItem: UpdateExceptionListItemOptions, + currentItem: ExceptionListItemSchema + ): Promise { + const updatedItem = _updatedItem as ExceptionItemLikeOptions; + + await this.validateCanManageEndpointArtifacts(); + await this.validateTrustedAppData(updatedItem); + + try { + await this.validateCanCreateByPolicyArtifacts(updatedItem); + } catch (noByPolicyAuthzError) { + // Not allowed to create/update by policy data. Validate that the effective scope of the item + // remained unchanged with this update or was set to `global` (only allowed update). If not, + // then throw the validation error that was catch'ed + if (this.wasByPolicyEffectScopeChanged(updatedItem, currentItem)) { + throw noByPolicyAuthzError; + } + } + + await this.validateByPolicyItem(updatedItem); + + return updatedItem as UpdateExceptionListItemOptions; + } + + private async validateTrustedAppData(item: ExceptionItemLikeOptions): Promise { + await this.validateBasicData(item); + + try { + TrustedAppDataSchema.validate(item, { os: item.osTypes[0] }); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/index.ts b/x-pack/plugins/security_solution/server/lists_integration/index.ts new file mode 100644 index 0000000000000..19f30492d0cb5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { registerListsPluginEndpointExtensionPoints } from './endpoint/register_endpoint_extension_points'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/jest.config.js b/x-pack/plugins/security_solution/server/lists_integration/jest.config.js new file mode 100644 index 0000000000000..0831c87ee59d4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/jest.config.js @@ -0,0 +1,26 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/plugins/security_solution/server/lists_integration'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/plugins/security_solution/server/lists_integration', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/plugins/security_solution/server/lists_integration/**/*.{ts,tsx}', + ], + // See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core. + moduleNameMapper: { + 'core/server$': '/x-pack/plugins/security_solution/server/__mocks__/core.mock.ts', + 'task_manager/server$': + '/x-pack/plugins/security_solution/server/__mocks__/task_manager.mock.ts', + 'alerting/server$': '/x-pack/plugins/security_solution/server/__mocks__/alert.mock.ts', + 'actions/server$': '/x-pack/plugins/security_solution/server/__mocks__/action.mock.ts', + }, +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a8e6d49a994c0..91118d0ef4e89 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -90,6 +90,7 @@ import type { PluginInitializerContext, } from './plugin_contract'; import { alertsFieldMap, rulesFieldMap } from '../common/field_maps'; +import { EndpointFleetServicesFactory } from './endpoint/services/fleet'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -394,19 +395,31 @@ export class Plugin implements ISecuritySolutionPlugin { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const exceptionListClient = this.lists!.getExceptionListClient(savedObjectsClient, 'kibana'); + const { authz, agentService, packageService, packagePolicyService, agentPolicyService } = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + plugins.fleet!; + this.endpointAppContextService.start({ - agentService: plugins.fleet?.agentService, - packageService: plugins.fleet?.packageService, - packagePolicyService: plugins.fleet?.packagePolicyService, - agentPolicyService: plugins.fleet?.agentPolicyService, + fleetAuthzService: authz, + agentService, + packageService, + packagePolicyService, + agentPolicyService, endpointMetadataService: new EndpointMetadataService( core.savedObjects, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - plugins.fleet?.agentPolicyService!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - plugins.fleet?.packagePolicyService!, + agentPolicyService, + packagePolicyService, logger ), + endpointFleetServicesFactory: new EndpointFleetServicesFactory( + { + agentService, + packageService, + packagePolicyService, + agentPolicyService, + }, + core.savedObjects + ), security: plugins.security, alerting: plugins.alerting, config: this.config, diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index c84f2e9eab9ed..26bb56113e635 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -83,7 +83,8 @@ export class RequestContextFactory implements IRequestContextFactory { if (!startPlugins.fleet) { endpointAuthz = getEndpointAuthzInitialState(); } else { - endpointAuthz = calculateEndpointAuthz(licenseService, fleetAuthz); + const userRoles = security?.authc.getCurrentUser(request)?.roles ?? []; + endpointAuthz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bbc5309e476dd..e17ed6f278acd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7694,7 +7694,6 @@ "xpack.cases.containers.markInProgressCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}を進行中に設定しました", "xpack.cases.containers.pushToExternalService": "{ serviceName }への送信が正常に完了しました", "xpack.cases.containers.reopenedCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}をオープンしました", - "xpack.cases.containers.statusChangeToasterText": "このケースのアラートはステータスが更新されました", "xpack.cases.containers.syncCase": "\"{caseTitle}\"のアラートが同期されました", "xpack.cases.containers.updatedCase": "\"{caseTitle}\"を更新しました", "xpack.cases.create.stepOneTitle": "ケースフィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ebfc41d0821b0..7850bfaba35c2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7753,7 +7753,6 @@ "xpack.cases.containers.markInProgressCases": "已将{totalCases, plural, =1 {“{caseTitle}”} other { {totalCases} 个案例}}标记为进行中", "xpack.cases.containers.pushToExternalService": "已成功发送到 { serviceName }", "xpack.cases.containers.reopenedCases": "已打开{totalCases, plural, =1 {“{caseTitle}”} other { {totalCases} 个案例}}", - "xpack.cases.containers.statusChangeToasterText": "此案例中的告警也更新了状态", "xpack.cases.containers.syncCase": "“{caseTitle}”中的告警已同步", "xpack.cases.containers.updatedCase": "已更新“{caseTitle}”", "xpack.cases.create.stepOneTitle": "案例字段", diff --git a/x-pack/plugins/uptime/common/constants/client_defaults.ts b/x-pack/plugins/uptime/common/constants/client_defaults.ts index 42521a1166bde..a8860dcca4a1a 100644 --- a/x-pack/plugins/uptime/common/constants/client_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/client_defaults.ts @@ -41,3 +41,5 @@ export const CLIENT_DEFAULTS = { SEARCH: '', STATUS_FILTER: '', }; + +export const EXCLUDE_RUN_ONCE_FILTER = { bool: { must_not: { exists: { field: 'run_once' } } } }; diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index f2359e4f1fb1e..2c369579b0150 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -40,4 +40,5 @@ export enum API_URLS { INDEX_TEMPLATES = '/internal/uptime/service/index_templates', SERVICE_LOCATIONS = '/internal/uptime/service/locations', SYNTHETICS_MONITORS = '/internal/uptime/service/monitors', + RUN_ONCE_MONITOR = '/internal/uptime/service/monitors/run_once', } diff --git a/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx b/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx index adb76d4cbc8ac..bc744be163223 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/duration_line_series_list.tsx @@ -31,6 +31,7 @@ export const DurationLineSeriesList = ({ monitorType, lines }: Props) => ( yAccessors={[1]} yScaleType={ScaleType.Linear} fit={Fit.Linear} + timeZone="local" tickFormat={(d) => monitorType === 'browser' ? `${microToSec(d)} ${SEC_LABEL}` diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 06c7ab7bff843..27141aa436d67 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -5,31 +5,15 @@ * 2.0. */ -import { EuiBasicTable, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useState, useEffect, MouseEvent } from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; -import { useHistory } from 'react-router-dom'; -import moment from 'moment'; -import { useDispatch } from 'react-redux'; -import { Ping } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; -import { LocationName } from './location_name'; import { Pagination } from '../../overview/monitor_list'; -import { pruneJourneyState } from '../../../state/actions/journey'; -import { PingStatusColumn } from './columns/ping_status'; -import * as I18LABELS from './translations'; -import { MONITOR_TYPES } from '../../../../common/constants'; -import { ResponseCodeColumn } from './columns/response_code'; -import { ERROR_LABEL, LOCATION_LABEL, RES_CODE_LABEL, TIMESTAMP_LABEL } from './translations'; -import { ExpandRowColumn } from './columns/expand_row'; -import { PingErrorCol } from './columns/ping_error'; -import { PingTimestamp } from './columns/ping_timestamp'; -import { FailedStep } from './columns/failed_step'; import { usePingsList } from './use_pings'; import { PingListHeader } from './ping_list_header'; -import { clearPings } from '../../../state/actions'; -import { getShortTimeStamp } from '../../overview/monitor_list/columns/monitor_status_column'; +import { PingListTable } from './ping_list_table'; export const SpanWithMargin = styled.span` margin-right: 16px; @@ -52,7 +36,7 @@ export const formatDuration = (durationMicros: number) => { } const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); - // we format seconds with correct pulralization here and not for `ms` because it is much more likely users + // we format seconds with correct pluralization here and not for `ms` because it is much more likely users // will encounter times of exactly '1' second. if (seconds === '1') { return i18n.translate('xpack.uptime.pingist.durationSecondsColumnFormatting.singular', { @@ -70,178 +54,11 @@ export const PingList = () => { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageIndex, setPageIndex] = useState(0); - const dispatch = useDispatch(); - - const history = useHistory(); - - const pruneJourneysCallback = useCallback( - (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), - [dispatch] - ); - const { error, loading, pings, total, failedSteps } = usePingsList({ pageSize, pageIndex, }); - const [expandedRows, setExpandedRows] = useState>({}); - - const expandedIdsToRemove = JSON.stringify( - Object.keys(expandedRows).filter((e) => !pings.some(({ docId }) => docId === e)) - ); - - useEffect(() => { - return () => { - dispatch(clearPings()); - }; - }, [dispatch]); - - useEffect(() => { - const parsed = JSON.parse(expandedIdsToRemove); - if (parsed.length) { - parsed.forEach((docId: string) => { - delete expandedRows[docId]; - }); - setExpandedRows(expandedRows); - } - }, [expandedIdsToRemove, expandedRows]); - - const expandedCheckGroups = pings - .filter((p: Ping) => Object.keys(expandedRows).some((f) => p.docId === f)) - .map(({ monitor: { check_group: cg } }) => cg); - - const expandedCheckGroupsStr = JSON.stringify(expandedCheckGroups); - - useEffect(() => { - pruneJourneysCallback(JSON.parse(expandedCheckGroupsStr)); - }, [pruneJourneysCallback, expandedCheckGroupsStr]); - - const hasStatus = pings.reduce( - (hasHttpStatus: boolean, currentPing) => - hasHttpStatus || !!currentPing.http?.response?.status_code, - false - ); - - const monitorType = pings?.[0]?.monitor.type; - - const columns: any[] = [ - { - field: 'monitor.status', - name: I18LABELS.STATUS_LABEL, - render: (pingStatus: string, item: Ping) => ( - - ), - }, - { - align: 'left', - field: 'observer.geo.name', - name: LOCATION_LABEL, - render: (location: string) => , - }, - ...(monitorType === MONITOR_TYPES.BROWSER - ? [ - { - align: 'left', - field: 'timestamp', - name: TIMESTAMP_LABEL, - render: (timestamp: string, item: Ping) => ( - - ), - }, - ] - : []), - // ip column not needed for browser type - ...(monitorType !== MONITOR_TYPES.BROWSER - ? [ - { - align: 'right', - dataType: 'number', - field: 'monitor.ip', - name: i18n.translate('xpack.uptime.pingList.ipAddressColumnLabel', { - defaultMessage: 'IP', - }), - }, - ] - : []), - { - align: 'center', - field: 'monitor.duration.us', - name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { - defaultMessage: 'Duration', - }), - render: (duration: number) => formatDuration(duration), - }, - { - field: 'error.type', - name: ERROR_LABEL, - width: '30%', - render: (errorType: string, item: Ping) => , - }, - ...(monitorType === MONITOR_TYPES.BROWSER - ? [ - { - field: 'monitor.status', - align: 'left', - name: i18n.translate('xpack.uptime.pingList.columns.failedStep', { - defaultMessage: 'Failed step', - }), - render: (_timestamp: string, item: Ping) => ( - - ), - }, - ] - : []), - // Only add this column is there is any status present in list - ...(hasStatus - ? [ - { - field: 'http.response.status_code', - align: 'right', - name: {RES_CODE_LABEL}, - render: (statusCode: string) => , - }, - ] - : []), - ...(monitorType !== MONITOR_TYPES.BROWSER - ? [ - { - align: 'right', - width: '24px', - isExpander: true, - render: (item: Ping) => ( - - ), - }, - ] - : []), - ]; - - const getRowProps = (item: Ping) => { - if (monitorType !== MONITOR_TYPES.BROWSER) { - return {}; - } - const { monitor } = item; - return { - height: '85px', - 'data-test-subj': `row-${monitor.check_group}`, - onClick: (evt: MouseEvent) => { - const targetElem = evt.target as HTMLElement; - - // we dont want to capture image click event - if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'path') { - history.push(`/journey/${monitor.check_group}/steps`); - } - }, - }; - }; - const pagination: Pagination = { initialPageSize: DEFAULT_PAGE_SIZE, pageIndex, @@ -254,31 +71,16 @@ export const PingList = () => { - { setPageSize(criteria.page!.size); setPageIndex(criteria.page!.index); }} - tableLayout={'auto'} - rowProps={getRowProps} + error={error} + pings={pings} + loading={loading} + pagination={pagination} + failedSteps={failedSteps} /> ); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx new file mode 100644 index 0000000000000..84a2d6a5d6a31 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx @@ -0,0 +1,244 @@ +/* + * 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, { MouseEvent, useCallback, useEffect, useState } from 'react'; +import { EuiBasicTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { useHistory } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import * as I18LABELS from './translations'; +import { FailedStepsApiResponse, Ping } from '../../../../common/runtime_types'; +import { PingStatusColumn } from './columns/ping_status'; +import { ERROR_LABEL, LOCATION_LABEL, RES_CODE_LABEL, TIMESTAMP_LABEL } from './translations'; +import { LocationName } from './location_name'; +import { MONITOR_TYPES } from '../../../../common/constants'; +import { PingTimestamp } from './columns/ping_timestamp'; +import { getShortTimeStamp } from '../../overview/monitor_list/columns/monitor_status_column'; +import { PingErrorCol } from './columns/ping_error'; +import { FailedStep } from './columns/failed_step'; +import { ResponseCodeColumn } from './columns/response_code'; +import { ExpandRowColumn } from './columns/expand_row'; +import { formatDuration, SpanWithMargin } from './ping_list'; +import { clearPings } from '../../../state/actions'; +import { pruneJourneyState } from '../../../state/actions/journey'; +import { Pagination } from '../../overview'; + +interface Props { + loading?: boolean; + pings: Ping[]; + error?: Error; + onChange?: (criteria: any) => void; + pagination?: Pagination; + failedSteps?: FailedStepsApiResponse; +} + +export function PingListTable({ loading, error, pings, pagination, onChange, failedSteps }: Props) { + const history = useHistory(); + + const [expandedRows, setExpandedRows] = useState>({}); + + const expandedIdsToRemove = JSON.stringify( + Object.keys(expandedRows).filter((e) => !pings.some(({ docId }) => docId === e)) + ); + + const dispatch = useDispatch(); + + const pruneJourneysCallback = useCallback( + (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), + [dispatch] + ); + + useEffect(() => { + return () => { + dispatch(clearPings()); + }; + }, [dispatch]); + + useEffect(() => { + const parsed = JSON.parse(expandedIdsToRemove); + if (parsed.length) { + parsed.forEach((docId: string) => { + delete expandedRows[docId]; + }); + setExpandedRows(expandedRows); + } + }, [expandedIdsToRemove, expandedRows]); + + const expandedCheckGroups = pings + .filter((p: Ping) => Object.keys(expandedRows).some((f) => p.docId === f)) + .map(({ monitor: { check_group: cg } }) => cg); + + const expandedCheckGroupsStr = JSON.stringify(expandedCheckGroups); + + useEffect(() => { + pruneJourneysCallback(JSON.parse(expandedCheckGroupsStr)); + }, [pruneJourneysCallback, expandedCheckGroupsStr]); + + const hasStatus = pings.reduce( + (hasHttpStatus: boolean, currentPing) => + hasHttpStatus || !!currentPing.http?.response?.status_code, + false + ); + + const hasError = pings.reduce( + (errorType: boolean, currentPing) => errorType || !!currentPing.error?.type, + false + ); + + const monitorType = pings?.[0]?.monitor.type; + + const columns: any[] = [ + { + field: 'monitor.status', + name: I18LABELS.STATUS_LABEL, + render: (pingStatus: string, item: Ping) => ( + + ), + }, + { + align: 'left', + field: 'observer.geo.name', + name: LOCATION_LABEL, + render: (location: string) => , + }, + ...(monitorType === MONITOR_TYPES.BROWSER + ? [ + { + align: 'left', + field: 'timestamp', + name: TIMESTAMP_LABEL, + render: (timestamp: string, item: Ping) => ( + + ), + }, + ] + : []), + // ip column not needed for browser type + ...(monitorType !== MONITOR_TYPES.BROWSER + ? [ + { + align: 'right', + dataType: 'number', + field: 'monitor.ip', + name: i18n.translate('xpack.uptime.pingList.ipAddressColumnLabel', { + defaultMessage: 'IP', + }), + }, + ] + : []), + { + align: 'center', + field: 'monitor.duration.us', + name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { + defaultMessage: 'Duration', + }), + render: (duration: number) => formatDuration(duration), + }, + ...(hasError + ? [ + { + field: 'error.type', + name: ERROR_LABEL, + width: '30%', + render: (errorType: string, item: Ping) => ( + + ), + }, + ] + : []), + ...(monitorType === MONITOR_TYPES.BROWSER + ? [ + { + field: 'monitor.status', + align: 'left', + name: i18n.translate('xpack.uptime.pingList.columns.failedStep', { + defaultMessage: 'Failed step', + }), + render: (_timestamp: string, item: Ping) => ( + + ), + }, + ] + : []), + // Only add this column is there is any status present in list + ...(hasStatus + ? [ + { + field: 'http.response.status_code', + align: 'right', + name: {RES_CODE_LABEL}, + render: (statusCode: string) => , + }, + ] + : []), + ...(monitorType !== MONITOR_TYPES.BROWSER + ? [ + { + align: 'right', + width: '24px', + isExpander: true, + render: (item: Ping) => ( + + ), + }, + ] + : []), + ]; + + const getRowProps = (item: Ping) => { + if (monitorType !== MONITOR_TYPES.BROWSER) { + return {}; + } + const { monitor } = item; + return { + height: '85px', + 'data-test-subj': `row-${monitor.check_group}`, + onClick: (evt: MouseEvent) => { + const targetElem = evt.target as HTMLElement; + + // we dont want to capture image click event + if (targetElem.tagName !== 'IMG' && targetElem.tagName !== 'path') { + history.push(`/journey/${monitor.check_group}/steps`); + } + }, + }; + }; + + return ( + + ); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx index 06cc2b6db41d8..6321688d92c4c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx @@ -7,7 +7,14 @@ import React, { useCallback, useContext, useState, useEffect } from 'react'; import { useParams, Redirect } from 'react-router-dom'; -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiText, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FETCH_STATUS, useFetcher } from '../../../../../observability/public'; @@ -18,14 +25,18 @@ import { UptimeSettingsContext } from '../../../contexts'; import { setMonitor } from '../../../state/api'; import { SyntheticsMonitor } from '../../../../common/runtime_types'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; +import { TestRun } from '../test_now_mode/test_now_mode'; -interface Props { +export interface ActionBarProps { monitor: SyntheticsMonitor; isValid: boolean; + testRun?: TestRun; onSave?: () => void; + onTestNow?: () => void; } -export const ActionBar = ({ monitor, isValid, onSave }: Props) => { +export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: ActionBarProps) => { const { monitorId } = useParams<{ monitorId: string }>(); const { basePath } = useContext(UptimeSettingsContext); @@ -84,9 +95,28 @@ export const ActionBar = ({ monitor, isValid, onSave }: Props) => { ) : ( - {!isValid && hasBeenSubmitted && VALIDATION_ERROR_LABEL} + + {!isValid && hasBeenSubmitted && VALIDATION_ERROR_LABEL} + + {onTestNow && ( + + + onTestNow()} + disabled={!isValid} + > + {testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL} + + + + )} + { {DISCARD_LABEL} + { ); }; +const WarningText = euiStyled(EuiText)` + box-shadow: -4px 0 ${(props) => props.theme.eui.euiColorWarning}; + padding-left: 8px; +`; + const DISCARD_LABEL = i18n.translate('xpack.uptime.monitorManagement.discardLabel', { defaultMessage: 'Discard', }); @@ -128,6 +164,14 @@ const UPDATE_MONITOR_LABEL = i18n.translate('xpack.uptime.monitorManagement.upda defaultMessage: 'Update monitor', }); +const RUN_TEST_LABEL = i18n.translate('xpack.uptime.monitorManagement.runTest', { + defaultMessage: 'Run test', +}); + +const RE_RUN_TEST_LABEL = i18n.translate('xpack.uptime.monitorManagement.reRunTest', { + defaultMessage: 'Re-run test', +}); + const VALIDATION_ERROR_LABEL = i18n.translate('xpack.uptime.monitorManagement.validationError', { defaultMessage: 'Your monitor has errors. Please fix them before saving.', }); @@ -153,3 +197,7 @@ const MONITOR_FAILURE_LABEL = i18n.translate( defaultMessage: 'Monitor was unable to be saved. Please try again later.', } ); + +const TEST_NOW_DESCRIPTION = i18n.translate('xpack.uptime.testRun.description', { + defaultMessage: 'Test your monitor and verify the results before saving', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar_portal.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar_portal.tsx index 097bd48e966b1..b6a80ec90893c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar_portal.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar_portal.tsx @@ -9,17 +9,9 @@ import React from 'react'; import { InPortal } from 'react-reverse-portal'; import { ActionBarPortalNode } from '../../../pages/monitor_management/action_bar_portal_node'; -import { SyntheticsMonitor } from '../../../../common/runtime_types'; +import { ActionBar, ActionBarProps } from './action_bar'; -import { ActionBar } from './action_bar'; - -interface Props { - monitor: SyntheticsMonitor; - isValid: boolean; - onSave?: () => void; -} - -export const ActionBarPortal = (props: Props) => { +export const ActionBarPortal = (props: ActionBarProps) => { return ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx index 7536a76b315a8..bcade36929805 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx @@ -5,8 +5,10 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; +import { EuiResizableContainer } from '@elastic/eui'; +import { v4 as uuidv4 } from 'uuid'; import { defaultConfig, usePolicyConfigContext } from '../../fleet_package/contexts'; import { usePolicy } from '../../fleet_package/hooks/use_policy'; @@ -14,11 +16,11 @@ import { validate } from '../validation'; import { ActionBarPortal } from '../action_bar/action_bar_portal'; import { useFormatMonitor } from '../hooks/use_format_monitor'; import { MonitorFields } from './monitor_fields'; +import { TestNowMode, TestRun } from '../test_now_mode/test_now_mode'; +import { MonitorFields as MonitorFieldsType } from '../../../../common/runtime_types'; export const MonitorConfig = () => { const { monitorType } = usePolicyConfigContext(); - /* TODO - Use Effect to make sure the package/index templates are loaded. Wait for it to load before showing view - * then show error message if it fails */ /* raw policy config compatible with the UI. Save this to saved objects */ const policyConfig = usePolicy(); @@ -27,18 +29,54 @@ export const MonitorConfig = () => { This type of helper should ideally be moved to task manager where we are syncing the config. We can process validation (isValid) and formatting for heartbeat (formattedMonitor) separately We don't need to save the heartbeat compatible version in saved objects */ - const { isValid } = useFormatMonitor({ + const { isValid, config } = useFormatMonitor({ monitorType, validate, config: policyConfig[monitorType], defaultConfig: defaultConfig[monitorType], }); + const [testRun, setTestRun] = useState(); + + const onTestNow = () => { + if (config) { + setTestRun({ id: uuidv4(), monitor: config as MonitorFieldsType }); + } + }; + return ( <> - + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + + + + + + + {config && } + + + )} + - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx new file mode 100644 index 0000000000000..727dfa4b9ec31 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../../lib/helper/rtl_helpers'; +import { kibanaService } from '../../../../state/kibana_service'; +import * as runOnceHooks from './use_browser_run_once_monitors'; +import { BrowserTestRunResult } from './browser_test_results'; +import { fireEvent } from '@testing-library/dom'; + +describe('BrowserTestRunResult', function () { + it('should render properly', async function () { + render(); + expect(await screen.findByText('Test result')).toBeInTheDocument(); + expect(await screen.findByText('0 steps completed')).toBeInTheDocument(); + const dataApi = (kibanaService.core as any).data.search; + + expect(dataApi.search).toHaveBeenCalledTimes(1); + expect(dataApi.search).toHaveBeenLastCalledWith( + { + params: { + body: { + query: { + bool: { + filter: [ + { term: { config_id: 'test-id' } }, + { + terms: { + 'synthetics.type': ['heartbeat/summary', 'journey/start'], + }, + }, + ], + }, + }, + sort: [{ '@timestamp': 'desc' }], + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + size: 10, + }, + }, + {} + ); + }); + + it('should displays results', async function () { + jest.spyOn(runOnceHooks, 'useBrowserRunOnceMonitors').mockReturnValue({ + data, + stepListData: { steps: [stepEndDoc._source] } as any, + loading: false, + journeyStarted: true, + summaryDoc: summaryDoc._source, + stepEnds: [stepEndDoc._source], + }); + + render(); + + expect(await screen.findByText('Test result')).toBeInTheDocument(); + + expect(await screen.findByText('COMPLETED')).toBeInTheDocument(); + expect(await screen.findByText('Took 22 seconds')).toBeInTheDocument(); + expect(await screen.findByText('1 step completed')).toBeInTheDocument(); + + fireEvent.click(await screen.findByTestId('expandResults')); + + expect(await screen.findByText('Go to https://www.elastic.co/')).toBeInTheDocument(); + expect(await screen.findByText('21.8 seconds')).toBeInTheDocument(); + }); +}); + +const journeyStartDoc = { + _index: '.ds-synthetics-browser-default-2022.01.11-000002', + _id: 'J1pLU34B6BrWThBwS4Fb', + _score: null, + _source: { + agent: { + name: 'job-78df368e085a796b-x9cbm', + id: 'df497635-644b-43ba-97a6-2f4dce1ea93b', + type: 'heartbeat', + ephemeral_id: 'e24d9e65-ae5f-4088-9a79-01dd504a1403', + version: '8.0.0', + }, + package: { name: '@elastic/synthetics', version: '1.0.0-beta.17' }, + os: { platform: 'linux' }, + synthetics: { + package_version: '1.0.0-beta.17', + journey: { name: 'inline', id: 'inline' }, + payload: { + source: + 'async ({ page, context, browser, params }) => {\n scriptFn.apply(null, [core_1.step, page, context, browser, params, expect_1.expect]);\n }', + params: {}, + }, + index: 0, + type: 'journey/start', + }, + monitor: { + name: 'Test Browser monitor - inline', + id: '3e11e70a-41b9-472c-a465-7c9b76b1a085-inline', + timespan: { lt: '2022-01-13T11:58:49.463Z', gte: '2022-01-13T11:55:49.463Z' }, + check_group: 'c01406bf-7467-11ec-9858-aa31996e0afe', + type: 'browser', + }, + '@timestamp': '2022-01-13T11:55:49.462Z', + ecs: { version: '8.0.0' }, + config_id: '3e11e70a-41b9-472c-a465-7c9b76b1a085', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + run_once: true, + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-01-13T11:55:50Z', + dataset: 'browser', + }, + }, + sort: [1642074949462], +}; + +const summaryDoc: any = { + _index: '.ds-synthetics-browser-default-2022.01.11-000002', + _id: 'Ix5LU34BPllLwAMpqlfi', + _score: null, + _source: { + summary: { up: 1, down: 0 }, + agent: { + name: 'job-78df368e085a796b-x9cbm', + id: 'df497635-644b-43ba-97a6-2f4dce1ea93b', + type: 'heartbeat', + ephemeral_id: 'e24d9e65-ae5f-4088-9a79-01dd504a1403', + version: '8.0.0', + }, + synthetics: { + journey: { name: 'inline', id: 'inline', tags: null }, + type: 'heartbeat/summary', + }, + monitor: { + duration: { us: 21754383 }, + name: 'Test Browser monitor - inline', + check_group: 'c01406bf-7467-11ec-9858-aa31996e0afe', + id: '3e11e70a-41b9-472c-a465-7c9b76b1a085-inline', + timespan: { lt: '2022-01-13T11:59:13.567Z', gte: '2022-01-13T11:56:13.567Z' }, + type: 'browser', + status: 'up', + }, + url: { + path: '/', + scheme: 'https', + port: 443, + domain: 'www.elastic.co', + full: 'https://www.elastic.co/', + }, + '@timestamp': '2022-01-13T11:56:11.217Z', + ecs: { version: '8.0.0' }, + config_id: '3e11e70a-41b9-472c-a465-7c9b76b1a085', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + run_once: true, + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-01-13T11:56:14Z', + dataset: 'browser', + }, + }, + sort: [1642074971217], +}; + +const stepEndDoc: any = { + _index: '.ds-synthetics-browser-default-2022.01.11-000002', + _id: 'M1pLU34B6BrWThBwoIGk', + _score: null, + _source: { + agent: { + name: 'job-78df368e085a796b-x9cbm', + id: 'df497635-644b-43ba-97a6-2f4dce1ea93b', + ephemeral_id: 'e24d9e65-ae5f-4088-9a79-01dd504a1403', + type: 'heartbeat', + version: '8.0.0', + }, + package: { name: '@elastic/synthetics', version: '1.0.0-beta.17' }, + os: { platform: 'linux' }, + synthetics: { + package_version: '1.0.0-beta.17', + journey: { name: 'inline', id: 'inline' }, + payload: { + source: "async () => {\n await page.goto('https://www.elastic.co/');\n}", + url: 'https://www.elastic.co/', + status: 'succeeded', + }, + index: 12, + step: { + duration: { us: 21751370 }, + name: 'Go to https://www.elastic.co/', + index: 1, + status: 'succeeded', + }, + type: 'step/end', + }, + monitor: { + name: 'Test Browser monitor - inline', + id: '3e11e70a-41b9-472c-a465-7c9b76b1a085-inline', + timespan: { lt: '2022-01-13T11:59:11.250Z', gte: '2022-01-13T11:56:11.250Z' }, + check_group: 'c01406bf-7467-11ec-9858-aa31996e0afe', + type: 'browser', + }, + url: { + path: '/', + scheme: 'https', + port: 443, + domain: 'www.elastic.co', + full: 'https://www.elastic.co/', + }, + '@timestamp': '2022-01-13T11:56:11.216Z', + ecs: { version: '8.0.0' }, + config_id: '3e11e70a-41b9-472c-a465-7c9b76b1a085', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + run_once: true, + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-01-13T11:56:12Z', + dataset: 'browser', + }, + }, + sort: [1642074971216], +}; + +const data: any = { + took: 4, + timed_out: false, + _shards: { total: 8, successful: 8, skipped: 2, failed: 0 }, + hits: { + total: 3, + max_score: null, + hits: [journeyStartDoc, stepEndDoc, summaryDoc], + }, +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx new file mode 100644 index 0000000000000..d5dd333f7f6c7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx @@ -0,0 +1,89 @@ +/* + * 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 React from 'react'; +import { EuiAccordion, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { StepsList } from '../../../synthetics/check_steps/steps_list'; +import { JourneyStep } from '../../../../../common/runtime_types'; +import { useBrowserRunOnceMonitors } from './use_browser_run_once_monitors'; +import { TestResultHeader } from '../test_result_header'; + +interface Props { + monitorId: string; +} +export const BrowserTestRunResult = ({ monitorId }: Props) => { + const { data, loading, stepEnds, journeyStarted, summaryDoc, stepListData } = + useBrowserRunOnceMonitors({ + monitorId, + }); + + const hits = data?.hits.hits; + const doc = hits?.[0]?._source as JourneyStep; + + const buttonContent = ( +
+ + +

+ + {i18n.translate('xpack.uptime.monitorManagement.stepCompleted', { + defaultMessage: + '{stepCount, number} {stepCount, plural, one {step} other {steps}} completed', + values: { + stepCount: stepEnds.length, + }, + })} + +

+
+
+ ); + + return ( + + {summaryDoc && stepEnds.length === 0 && {FAILED_TO_RUN}} + {!summaryDoc && journeyStarted && stepEnds.length === 0 && {LOADING_STEPS}} + {stepEnds.length > 0 && stepListData?.steps && ( + + )} + + ); +}; + +const AccordionWrapper = styled(EuiAccordion)` + .euiAccordion__buttonContent { + width: 100%; + } +`; + +const FAILED_TO_RUN = i18n.translate('xpack.uptime.monitorManagement.failedRun', { + defaultMessage: 'Failed to run steps', +}); + +const LOADING_STEPS = i18n.translate('xpack.uptime.monitorManagement.loadingSteps', { + defaultMessage: 'Loading steps...', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx new file mode 100644 index 0000000000000..3a126e6f69e99 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { useBrowserRunOnceMonitors } from './use_browser_run_once_monitors'; +import * as resultHook from './use_browser_run_once_monitors'; +import { WrappedHelper } from '../../../../lib/helper/rtl_helpers'; + +describe('useBrowserRunOnceMonitors', function () { + it('should return results as expected', function () { + jest.spyOn(resultHook, 'useBrowserEsResults').mockReturnValue({ + loading: false, + data: { + took: 4, + timed_out: false, + _shards: { total: 8, successful: 8, skipped: 2, failed: 0 }, + hits: { + total: { value: 3, relation: 'eq' }, + max_score: null, + hits: [], + }, + }, + }); + + const { result } = renderHook(() => useBrowserRunOnceMonitors({ monitorId: 'test-id' }), { + wrapper: WrappedHelper, + }); + + expect(result.current).toEqual({ + data: undefined, + journeyStarted: false, + loading: true, + stepEnds: [], + stepListData: undefined, + summaryDoc: undefined, + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts new file mode 100644 index 0000000000000..41f2b1cbe11f8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.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 { useSelector } from 'react-redux'; +import { useEffect, useState } from 'react'; +import { selectDynamicSettings } from '../../../../state/selectors'; +import { JourneyStep } from '../../../../../common/runtime_types'; +import { createEsParams, useEsSearch, useFetcher } from '../../../../../../observability/public'; +import { useTickTick } from '../use_tick_tick'; +import { fetchJourneySteps } from '../../../../state/api/journey'; +import { isStepEnd } from '../../../synthetics/check_steps/steps_list'; + +export const useBrowserEsResults = ({ + monitorId, + lastRefresh, +}: { + monitorId: string; + lastRefresh: number; +}) => { + const { settings } = useSelector(selectDynamicSettings); + + return useEsSearch( + createEsParams({ + index: settings?.heartbeatIndices, + body: { + sort: [ + { + '@timestamp': 'desc', + }, + ], + query: { + bool: { + filter: [ + { + term: { + config_id: monitorId, + }, + }, + { + terms: { + 'synthetics.type': ['heartbeat/summary', 'journey/start'], + }, + }, + ], + }, + }, + }, + size: 10, + }), + [monitorId, settings?.heartbeatIndices, lastRefresh], + { name: 'TestRunData' } + ); +}; + +export const useBrowserRunOnceMonitors = ({ monitorId }: { monitorId: string }) => { + const { refreshTimer, lastRefresh } = useTickTick(); + + const [checkGroupId, setCheckGroupId] = useState(''); + const [stepEnds, setStepEnds] = useState([]); + const [summary, setSummary] = useState(); + + const { data, loading } = useBrowserEsResults({ monitorId, lastRefresh }); + + const { data: stepListData } = useFetcher(() => { + if (checkGroupId) { + return fetchJourneySteps({ + checkGroup: checkGroupId, + }); + } + return Promise.resolve(null); + }, [lastRefresh]); + + useEffect(() => { + const hits = data?.hits.hits; + + if (hits && hits.length > 0) { + hits?.forEach((hit) => { + const doc = hit._source as JourneyStep; + if (doc.synthetics?.type === 'journey/start') { + setCheckGroupId(doc.monitor.check_group); + } + if (doc.synthetics?.type === 'heartbeat/summary') { + setSummary(doc); + clearInterval(refreshTimer); + } + }); + } + }, [data, refreshTimer]); + + useEffect(() => { + if (stepListData?.steps && stepListData?.steps.length > 0) { + setStepEnds(stepListData.steps.filter(isStepEnd)); + } + }, [stepListData]); + + return { + data, + stepEnds, + loading, + stepListData, + summaryDoc: summary, + journeyStarted: Boolean(checkGroupId), + }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx new file mode 100644 index 0000000000000..99ed9ac43db1b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../../lib/helper/rtl_helpers'; +import { SimpleTestResults } from './simple_test_results'; +import { kibanaService } from '../../../../state/kibana_service'; +import * as runOnceHooks from './use_simple_run_once_monitors'; +import { Ping } from '../../../../../common/runtime_types'; + +describe('SimpleTestResults', function () { + it('should render properly', async function () { + render(); + expect(await screen.findByText('Test result')).toBeInTheDocument(); + const dataApi = (kibanaService.core as any).data.search; + + expect(dataApi.search).toHaveBeenCalledTimes(1); + expect(dataApi.search).toHaveBeenLastCalledWith( + { + params: { + body: { + query: { + bool: { + filter: [{ term: { config_id: 'test-id' } }, { exists: { field: 'summary' } }], + }, + }, + sort: [{ '@timestamp': 'desc' }], + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + size: 10, + }, + }, + {} + ); + }); + + it('should displays results', async function () { + const doc = data.hits.hits[0]; + jest.spyOn(runOnceHooks, 'useSimpleRunOnceMonitors').mockReturnValue({ + data: data as any, + summaryDoc: { + ...(doc._source as unknown as Ping), + timestamp: (doc._source as unknown as Record)?.['@timestamp'], + docId: doc._id, + }, + loading: false, + }); + + render(); + + expect(await screen.findByText('Test result')).toBeInTheDocument(); + + expect(await screen.findByText('COMPLETED')).toBeInTheDocument(); + expect(await screen.findByText('191 ms')).toBeInTheDocument(); + expect(await screen.findByText('151.101.2.217')).toBeInTheDocument(); + expect(await screen.findByText('Checked Jan 12, 2022 11:54:27 AM')).toBeInTheDocument(); + expect(await screen.findByText('Took 191 ms')).toBeInTheDocument(); + + screen.debug(); + }); +}); + +const data = { + took: 201, + timed_out: false, + _shards: { total: 8, successful: 8, skipped: 0, failed: 0 }, + hits: { + total: 1, + max_score: null, + hits: [ + { + _index: '.ds-synthetics-http-default-2022.01.11-000002', + _id: '6h42T34BPllLwAMpWCjo', + _score: null, + _source: { + tcp: { rtt: { connect: { us: 11480 } } }, + summary: { up: 1, down: 0 }, + agent: { + name: 'job-2b730ffa8811ff-knvmz', + id: 'a3ed3007-4261-40a9-ad08-7a8384cce7f5', + type: 'heartbeat', + ephemeral_id: '7ffe97e3-15a3-4d76-960e-8f0488998e3c', + version: '8.0.0', + }, + resolve: { rtt: { us: 46753 }, ip: '151.101.2.217' }, + monitor: { + duration: { us: 191528 }, + ip: '151.101.2.217', + name: 'Elastic HTTP', + check_group: '4d7fd600-73c8-11ec-b035-2621090844ff', + id: 'e5a3a871-b5c3-49cf-a798-3860028e7a6b', + timespan: { lt: '2022-01-12T16:57:27.443Z', gte: '2022-01-12T16:54:27.443Z' }, + type: 'http', + status: 'up', + }, + url: { + scheme: 'https', + port: 443, + domain: 'www.elastic.co', + full: 'https://www.elastic.co', + }, + '@timestamp': '2022-01-12T16:54:27.252Z', + ecs: { version: '8.0.0' }, + config_id: 'e5a3a871-b5c3-49cf-a798-3860028e7a6b', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'http' }, + run_once: true, + http: { + rtt: { + response_header: { us: 61231 }, + total: { us: 144630 }, + write_request: { us: 93 }, + content: { us: 25234 }, + validate: { us: 86466 }, + }, + response: { + headers: { + 'X-Dns-Prefetch-Control': 'off', + Server: 'my-server', + 'Access-Control-Allow-Origin': '*', + 'X-Timer': 'S1642006467.362040,VS0,VE51', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Frame-Options': 'SAMEORIGIN', + 'Strict-Transport-Security': 'max-age=0', + Etag: '"29d46-xv8YFxCD32Ncbzip9bXU5q9QSvg"', + 'X-Served-By': 'cache-sea4462-SEA, cache-pwk4941-PWK', + 'Content-Security-Policy': + "frame-ancestors 'self' https://*.elastic.co https://elasticsandbox.docebosaas.com https://elastic.docebosaas.com https://www.gather.town;", + 'Set-Cookie': + 'euid=2b70f3d5-56bc-49f1-a64f-50d352914207; Expires=Tuesday, 19 January 2038 01:00:00 GMT; Path=/; Domain=.elastic.co;', + 'X-Change-Language': 'true', + 'Content-Length': '171334', + Age: '1591', + 'Content-Type': 'text/html; charset=utf-8', + 'X-Powered-By': 'Next.js', + 'X-Cache': 'HIT, MISS', + 'X-Content-Type-Options': 'nosniff', + 'X-Download-Options': 'noopen', + Date: 'Wed, 12 Jan 2022 16:54:27 GMT', + Via: '1.1 varnish, 1.1 varnish', + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'max-age=86400', + 'X-Xss-Protection': '1; mode=block', + Vary: 'Accept-Language, X-Change-Language, Accept-Encoding', + 'Elastic-Vi': '2b70f3d5-56bc-49f1-a64f-50d352914207', + 'X-Cache-Hits': '425, 0', + }, + status_code: 200, + mime_type: 'text/html; charset=utf-8', + body: { + bytes: 171334, + hash: '29e5b1a1949dc4d253399874b161049030639d70c5164a5235e039bb4b95f9fd', + }, + }, + }, + tls: { + established: true, + cipher: 'ECDHE-RSA-AES-128-GCM-SHA256', + certificate_not_valid_before: '2021-11-26T19:42:12.000Z', + server: { + x509: { + not_after: '2022-12-28T19:42:11.000Z', + public_key_exponent: 65537, + not_before: '2021-11-26T19:42:12.000Z', + subject: { + distinguished_name: 'CN=www.elastic.co', + common_name: 'www.elastic.co', + }, + public_key_algorithm: 'RSA', + signature_algorithm: 'SHA256-RSA', + public_key_size: 2048, + serial_number: '2487880865947729006738430997169012636', + issuer: { + distinguished_name: + 'CN=GlobalSign Atlas R3 DV TLS CA H2 2021,O=GlobalSign nv-sa,C=BE', + common_name: 'GlobalSign Atlas R3 DV TLS CA H2 2021', + }, + }, + hash: { + sha1: '21099729d121d9707ca6c1b642032a97ea2dcb74', + sha256: '55715c58c7e0939aa9b8989df59082ce33c1b274678e7913fd0c269f33103b02', + }, + }, + rtt: { handshake: { us: 46409 } }, + version: '1.2', + certificate_not_valid_after: '2022-12-28T19:42:11.000Z', + version_protocol: 'tls', + }, + event: { + agent_id_status: 'auth_metadata_missing', + ingested: '2022-01-12T16:54:28Z', + dataset: 'http', + }, + }, + sort: [1642006467252], + }, + ], + }, +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx new file mode 100644 index 0000000000000..97097285d0bbc --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx @@ -0,0 +1,32 @@ +/* + * 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, useState } from 'react'; +import { useSimpleRunOnceMonitors } from './use_simple_run_once_monitors'; +import { Ping } from '../../../../../common/runtime_types'; +import { PingListTable } from '../../../monitor/ping_list/ping_list_table'; +import { TestResultHeader } from '../test_result_header'; + +interface Props { + monitorId: string; +} +export function SimpleTestResults({ monitorId }: Props) { + const [summaryDocs, setSummaryDocs] = useState([]); + const { summaryDoc, loading } = useSimpleRunOnceMonitors({ monitorId }); + + useEffect(() => { + if (summaryDoc) { + setSummaryDocs((prevState) => [summaryDoc, ...prevState]); + } + }, [summaryDoc]); + + return ( + <> + + {summaryDoc && } + + ); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/use_simple_run_once_monitors.ts b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/use_simple_run_once_monitors.ts new file mode 100644 index 0000000000000..816f9b019c45a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/use_simple_run_once_monitors.ts @@ -0,0 +1,74 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { selectDynamicSettings } from '../../../../state/selectors'; +import { Ping } from '../../../../../common/runtime_types'; +import { createEsParams, useEsSearch } from '../../../../../../observability/public'; +import { useTickTick } from '../use_tick_tick'; + +export const useSimpleRunOnceMonitors = ({ monitorId }: { monitorId: string }) => { + const { refreshTimer, lastRefresh } = useTickTick(); + + const { settings } = useSelector(selectDynamicSettings); + + const { data, loading } = useEsSearch( + createEsParams({ + index: settings?.heartbeatIndices, + body: { + sort: [ + { + '@timestamp': 'desc', + }, + ], + query: { + bool: { + filter: [ + { + term: { + config_id: monitorId, + }, + }, + { + exists: { + field: 'summary', + }, + }, + ], + }, + }, + }, + size: 10, + }), + [monitorId, settings?.heartbeatIndices, lastRefresh], + { name: 'TestRunData' } + ); + + return useMemo(() => { + const doc = data?.hits.hits?.[0]; + + if (doc) { + clearInterval(refreshTimer); + return { + data, + loading, + summaryDoc: { + ...(doc._source as Ping), + timestamp: (doc._source as Record)?.['@timestamp'], + docId: doc._id, + }, + }; + } + + return { + data, + loading, + summaryDoc: null, + }; + }, [data, loading, refreshTimer]); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx new file mode 100644 index 0000000000000..849f1215614d0 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { TestNowMode } from './test_now_mode'; +import { kibanaService } from '../../../state/kibana_service'; +import { MonitorFields } from '../../../../common/runtime_types'; + +describe('TestNowMode', function () { + it('should render properly', async function () { + render( + + ); + expect(await screen.findByText('Test result')).toBeInTheDocument(); + expect(await screen.findByText('PENDING')).toBeInTheDocument(); + + expect(await screen.findByText('0 steps completed')).toBeInTheDocument(); + + expect(kibanaService.core.http.post).toHaveBeenCalledTimes(1); + + expect(kibanaService.core.http.post).toHaveBeenLastCalledWith( + expect.stringContaining('/internal/uptime/service/monitors/run_once/'), + { body: '{"type":"browser"}', method: 'POST' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx new file mode 100644 index 0000000000000..43d4e0e6e9d2a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx @@ -0,0 +1,90 @@ +/* + * 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 } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { TestRunResult } from './test_run_results'; +import { MonitorFields } from '../../../../common/runtime_types'; +import { useFetcher } from '../../../../../observability/public'; +import { runOnceMonitor } from '../../../state/api'; +import { kibanaService } from '../../../state/kibana_service'; + +export interface TestRun { + id: string; + monitor: MonitorFields; +} + +export function TestNowMode({ testRun }: { testRun?: TestRun }) { + const { data, loading: isPushing } = useFetcher(() => { + if (testRun) { + return runOnceMonitor({ + monitor: testRun.monitor, + id: testRun.id, + }); + } + return new Promise((resolve) => resolve(null)); + }, [testRun]); + + useEffect(() => { + const errors = (data as { errors: Array<{ error: Error }> })?.errors; + + if (errors?.length > 0) { + errors.forEach(({ error }) => { + kibanaService.toasts.addError(error, { title: PushErrorLabel }); + }); + } + }, [data]); + + const errors = (data as { errors?: Array<{ error: Error }> })?.errors; + + const hasErrors = errors && errors?.length > 0; + + if (!testRun) { + return null; + } + + return ( + + {isPushing && ( + + {PushingLabel} + + )} + + {hasErrors && !isPushing && } + + {testRun && !hasErrors && !isPushing && ( + + + + + + )} + + + ); +} + +const PushingLabel = i18n.translate('xpack.uptime.testRun.pushing.description', { + defaultMessage: 'Pushing the monitor to service...', +}); + +const PushError = i18n.translate('xpack.uptime.testRun.pushError', { + defaultMessage: 'Failed to push the monitor to service.', +}); + +const PushErrorLabel = i18n.translate('xpack.uptime.testRun.pushErrorLabel', { + defaultMessage: 'Push error', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.test.tsx new file mode 100644 index 0000000000000..07b2d59e0751f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { TestResultHeader } from './test_result_header'; + +describe('TestResultHeader', function () { + it('should render properly', async function () { + render(); + expect(await screen.findByText('Test result')).toBeInTheDocument(); + expect(await screen.findByText('PENDING')).toBeInTheDocument(); + }); + + it('should render in progress state', async function () { + render(); + + expect(await screen.findByText('Test result')).toBeInTheDocument(); + expect(await screen.findByText('IN PROGRESS')).toBeInTheDocument(); + }); + + it('should render completed state', async function () { + render( + + ); + expect(await screen.findByText('Test result')).toBeInTheDocument(); + expect(await screen.findByText('COMPLETED')).toBeInTheDocument(); + expect(await screen.findByText('Took 1 ms')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.tsx new file mode 100644 index 0000000000000..51b120c3c7e5e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_result_header.tsx @@ -0,0 +1,107 @@ +/* + * 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 { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiLoadingSpinner, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import * as React from 'react'; +import { formatDuration } from '../../monitor/ping_list/ping_list'; +import { JourneyStep, Ping } from '../../../../common/runtime_types'; +import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; + +interface Props { + doc?: JourneyStep; + summaryDocs?: Ping[] | JourneyStep[] | null; + journeyStarted?: boolean; + title?: string; + isCompleted: boolean; +} + +export function TestResultHeader({ doc, title, summaryDocs, journeyStarted, isCompleted }: Props) { + const { basePath } = useUptimeSettingsContext(); + let duration = 0; + if (summaryDocs && summaryDocs.length > 0) { + summaryDocs.forEach((sDoc) => { + duration += sDoc.monitor.duration!.us; + }); + } + + return ( + + + +

{title ?? TEST_RESULT}

+
+
+ + {isCompleted ? ( + + + {COMPLETED_LABEL} + + + + {i18n.translate('xpack.uptime.monitorManagement.timeTaken', { + defaultMessage: 'Took {timeTaken}', + values: { timeTaken: formatDuration(duration) }, + })} + + + + ) : ( + + + + {journeyStarted ? IN_PROGRESS : PENDING_LABEL} + + + + + + + )} + + {doc && ( + + + {VIEW_DETAILS} + + + )} +
+ ); +} + +const PENDING_LABEL = i18n.translate('xpack.uptime.monitorManagement.pending', { + defaultMessage: 'PENDING', +}); + +const TEST_RESULT = i18n.translate('xpack.uptime.monitorManagement.testResult', { + defaultMessage: 'Test result', +}); + +const COMPLETED_LABEL = i18n.translate('xpack.uptime.monitorManagement.completed', { + defaultMessage: 'COMPLETED', +}); + +const IN_PROGRESS = i18n.translate('xpack.uptime.monitorManagement.inProgress', { + defaultMessage: 'IN PROGRESS', +}); + +const VIEW_DETAILS = i18n.translate('xpack.uptime.monitorManagement.viewTestRunDetails', { + defaultMessage: 'View test result details', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx new file mode 100644 index 0000000000000..4b261815e9949 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx @@ -0,0 +1,23 @@ +/* + * 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 React from 'react'; +import { SyntheticsMonitor } from '../../../../common/runtime_types'; +import { BrowserTestRunResult } from './browser/browser_test_results'; +import { SimpleTestResults } from './simple/simple_test_results'; + +interface Props { + monitorId: string; + monitor: SyntheticsMonitor; +} +export const TestRunResult = ({ monitorId, monitor }: Props) => { + return monitor.type === 'browser' ? ( + + ) : ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/use_tick_tick.ts b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/use_tick_tick.ts new file mode 100644 index 0000000000000..ff9b9f3f6154d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/use_tick_tick.ts @@ -0,0 +1,27 @@ +/* + * 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 { useEffect, useState, useContext } from 'react'; +import { UptimeRefreshContext } from '../../../contexts'; + +export function useTickTick() { + const { refreshApp, lastRefresh } = useContext(UptimeRefreshContext); + + const [tickTick] = useState(() => + setInterval(() => { + refreshApp(); + }, 5 * 1000) + ); + + useEffect(() => { + return () => { + clearInterval(tickTick); + }; + }, [tickTick]); + + return { refreshTimer: tickTick, lastRefresh }; +} diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx index 835cbb8060142..06c080cc659fc 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx @@ -15,6 +15,7 @@ import { FieldValueSuggestions, useInspectorContext } from '../../../../../obser import { SelectedFilters } from './selected_filters'; import { useIndexPattern } from '../../../contexts/uptime_index_pattern_context'; import { useGetUrlParams } from '../../../hooks'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; const Container = styled(EuiFilterGroup)` margin-bottom: 10px; @@ -67,7 +68,14 @@ export const FilterGroup = () => { asCombobox={false} asFilterButton={true} forceOpen={false} - filters={[]} + filters={[ + { + exists: { + field: 'summary', + }, + }, + EXCLUDE_RUN_ONCE_FILTER, + ]} cardinalityField="monitor.id" time={{ from: dateRangeStart, to: dateRangeEnd }} inspector={{ diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx index 6f995665d7ce5..a9697a8969f65 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx @@ -16,6 +16,7 @@ import { StepFieldTrend } from './step_field_trend'; import { microToSec } from '../../../lib/formatting'; interface Props { + compactView?: boolean; step: JourneyStep; durationPopoverOpenIndex: number | null; setDurationPopoverOpenIndex: (val: number | null) => void; @@ -25,6 +26,7 @@ export const StepDuration = ({ step, durationPopoverOpenIndex, setDurationPopoverOpenIndex, + compactView = false, }: Props) => { const component = useMemo( () => ( @@ -44,7 +46,7 @@ export const StepDuration = ({ const button = ( setDurationPopoverOpenIndex(step.synthetics.step?.index ?? null)} - iconType="visArea" + iconType={compactView ? undefined : 'visArea'} > {i18n.translate('xpack.uptime.synthetics.step.duration', { defaultMessage: '{value} seconds', diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx index e39a83599380b..d08614fb0b358 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_image.tsx @@ -12,9 +12,10 @@ import { PingTimestamp } from '../../monitor/ping_list/columns/ping_timestamp'; interface Props { step: JourneyStep; + compactView?: boolean; } -export const StepImage = ({ step }: Props) => { +export const StepImage = ({ step, compactView }: Props) => { return ( @@ -24,7 +25,7 @@ export const StepImage = ({ step }: Props) => { /> - {step.synthetics?.step?.name} + {step.synthetics?.step?.name} ); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index da06121515581..d635d76fc3f89 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -26,6 +26,7 @@ import { VIEW_PERFORMANCE } from '../../monitor/synthetics/translations'; import { StepImage } from './step_image'; import { useExpandedRow } from './use_expanded_row'; import { StepDuration } from './step_duration'; +import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; export const SpanWithMargin = styled.span` margin-right: 16px; @@ -35,6 +36,7 @@ interface Props { data: JourneyStep[]; error?: Error; loading: boolean; + compactView?: boolean; } interface StepStatusCount { @@ -43,7 +45,7 @@ interface StepStatusCount { succeeded: number; } -function isStepEnd(step: JourneyStep) { +export function isStepEnd(step: JourneyStep) { return step.synthetics?.type === 'step/end'; } @@ -83,12 +85,13 @@ function reduceStepStatus(prev: StepStatusCount, cur: JourneyStep): StepStatusCo return prev; } -export const StepsList = ({ data, error, loading }: Props) => { +export const StepsList = ({ data, error, loading, compactView = false }: Props) => { const steps: JourneyStep[] = data.filter(isStepEnd); const { expandedRows, toggleExpand } = useExpandedRow({ steps, allSteps: data, loading }); const [durationPopoverOpenIndex, setDurationPopoverOpenIndex] = useState(null); + const { basePath } = useUptimeSettingsContext(); const columns: Array> = [ { @@ -116,7 +119,7 @@ export const StepsList = ({ data, error, loading }: Props) => { align: 'left', field: 'timestamp', name: STEP_NAME_LABEL, - render: (_timestamp: string, item) => , + render: (_timestamp: string, item) => , mobileOptions: { render: (item: JourneyStep) => ( @@ -137,6 +140,7 @@ export const StepsList = ({ data, error, loading }: Props) => { step={item} durationPopoverOpenIndex={durationPopoverOpenIndex} setDurationPopoverOpenIndex={setDurationPopoverOpenIndex} + compactView={compactView} /> ); }, @@ -151,17 +155,23 @@ export const StepsList = ({ data, error, loading }: Props) => { align: 'left', field: 'timestamp', name: '', - render: (_val: string, item) => ( - - {VIEW_PERFORMANCE} - - ), mobileOptions: { show: false }, + render: (_val: string, item) => + compactView ? ( + + ) : ( + + {VIEW_PERFORMANCE} + + ), }, - { width: '40px', align: RIGHT_ALIGNMENT, @@ -203,15 +213,18 @@ export const StepsList = ({ data, error, loading }: Props) => { return ( <> - -

- {statusMessage( - steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }), - loading - )} -

-
+ {!compactView && ( + +

+ {statusMessage( + steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }), + loading + )} +

+
+ )} = ({ status, stepNo, isMobile }) {!isMobile && ( - + {stepNo}. diff --git a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx index 7fde4033c0409..7dfc86575205b 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/step_screenshot_display.tsx @@ -22,7 +22,7 @@ import { isScreenshotRef as isAScreenshotRef, ScreenshotRefImageData, } from '../../../common/runtime_types'; -import { UptimeSettingsContext, UptimeThemeContext } from '../../contexts'; +import { UptimeRefreshContext, UptimeSettingsContext, UptimeThemeContext } from '../../contexts'; import { useFetcher } from '../../../../observability/public'; import { getJourneyScreenshot } from '../../state/api/journey'; import { useCompositeImage } from '../../hooks'; @@ -114,6 +114,7 @@ export const StepScreenshotDisplay: FC = ({ rootMargin: '0px', threshold: 1, }); + const { lastRefresh } = useContext(UptimeRefreshContext); const [hasIntersected, setHasIntersected] = useState(false); const isIntersecting = intersection?.isIntersecting; @@ -134,7 +135,7 @@ export const StepScreenshotDisplay: FC = ({ if (isScreenshotRef) { return getJourneyScreenshot(imgSrc); } - }, [basePath, checkGroup, imgSrc, stepIndex, isScreenshotRef]); + }, [basePath, checkGroup, imgSrc, stepIndex, isScreenshotRef, lastRefresh]); const refDimensions = useMemo(() => { if (isAScreenshotRef(screenshotRef)) { diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index 9374012d094ab..b0c8f61477d28 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -37,6 +37,7 @@ import { ClientPluginsStart } from '../../apps/plugin'; import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { UptimeRefreshContextProvider, UptimeStartupPluginsContextProvider } from '../../contexts'; +import { kibanaService } from '../../state/kibana_service'; type DeepPartial = { [P in keyof T]?: DeepPartial; @@ -149,6 +150,8 @@ export function MockKibanaProvider({ }: MockKibanaProviderProps) { const coreOptions = merge({}, mockCore(), core); + kibanaService.core = coreOptions as any; + return ( @@ -208,6 +211,27 @@ export const MockRedux = ({ ); }; +export function WrappedHelper({ + children, + core, + kibanaProps, + state, + url, + useRealStore, + path, + history = createMemoryHistory(), +}: RenderRouterOptions & { children: ReactElement; useRealStore?: boolean }) { + const testState: AppState = merge({}, mockState, state); + + return ( + + + {children} + + + ); +} + /* Custom react testing library render */ export function render( ui: ReactElement, @@ -222,19 +246,23 @@ export function render( useRealStore, }: RenderRouterOptions & { useRealStore?: boolean } = {} ) { - const testState: AppState = merge({}, mockState, state); - if (url) { history = getHistoryFromUrl(url); } return { ...reactTestLibRender( - - - {ui} - - , + + {ui} + , renderOptions ), history, diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index cb43dc9c90d7d..aa4a9ca995b1b 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -26,7 +26,7 @@ export const MonitorManagementPage: React.FC = () => { useEffect(() => { if (refresh) { dispatch(getMonitors({ page: pageIndex, perPage: pageSize })); - setRefresh(false); // TODO: avoid extra re-rendering when `refresh` turn to false (pass down the handler instead) + setRefresh(false); } }, [dispatch, refresh, pageIndex, pageSize]); diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index bcb942250c6f1..aa7bf22593abe 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -206,6 +206,7 @@ const getRoutes = (config: UptimeConfig): RouteProps[] => { ), }, bottomBar: , + bottomBarProps: { paddingSize: 'm' as const }, }, { title: i18n.translate('xpack.uptime.editMonitorRoute.title', { @@ -225,6 +226,7 @@ const getRoutes = (config: UptimeConfig): RouteProps[] => { ), }, bottomBar: , + bottomBarProps: { paddingSize: 'm' as const }, }, { title: i18n.translate('xpack.uptime.monitorManagementRoute.title', { diff --git a/x-pack/plugins/uptime/public/state/api/monitor_management.ts b/x-pack/plugins/uptime/public/state/api/monitor_management.ts index 5f18869257386..ee2e376990d2d 100644 --- a/x-pack/plugins/uptime/public/state/api/monitor_management.ts +++ b/x-pack/plugins/uptime/public/state/api/monitor_management.ts @@ -17,6 +17,7 @@ import { import { SyntheticsMonitorSavedObject } from '../../../common/types'; import { apiService } from './utils'; +// TODO: Type the return type from runtime types export const setMonitor = async ({ monitor, id, @@ -31,6 +32,7 @@ export const setMonitor = async ({ } }; +// TODO, change to monitor runtime type export const getMonitor = async ({ id }: { id: string }): Promise => { return await apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${id}`); }; @@ -57,3 +59,13 @@ export const fetchServiceLocations = async (): Promise => { ); return locations; }; + +export const runOnceMonitor = async ({ + monitor, + id, +}: { + monitor: SyntheticsMonitor; + id: string; +}): Promise<{ errors: Array<{ error: Error }> }> => { + return await apiService.post(API_URLS.RUN_ONCE_MONITOR + `/${id}`, monitor); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index a8d722b4e059d..8fedd926b8371 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -11,6 +11,7 @@ import { QUERY } from '../../../common/constants'; import { UMElasticsearchQueryFn } from '../adapters/framework'; import { createEsQuery } from '../../../common/utils/es_search'; import { getHistogramInterval } from '../../../common/lib/get_histogram_interval'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../common/constants/client_defaults'; export const getPingHistogram: UMElasticsearchQueryFn< GetPingHistogramParams, @@ -47,6 +48,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< field: 'summary', }, }, + EXCLUDE_RUN_ONCE_FILTER, ], ...(query ? { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts index ee4e3eb96eb5a..2a43d539125be 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts @@ -10,6 +10,7 @@ import { CONTEXT_DEFAULTS } from '../../../common/constants'; import { Snapshot } from '../../../common/runtime_types'; import { QueryContext } from './search'; import { ESFilter } from '../../../../../../src/core/types/elasticsearch'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../common/constants/client_defaults'; export interface GetSnapshotCountParams { dateRangeStart: string; @@ -84,7 +85,7 @@ const statusCountBody = (filters: ESFilter[], context: QueryContext) => { field: 'summary', }, }, - + EXCLUDE_RUN_ONCE_FILTER, ...filters, ], }, diff --git a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts index d0d8e61d02181..1963afaf89a34 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts @@ -7,6 +7,7 @@ import { set } from '@elastic/safer-lodash-set'; import { QueryContext } from './query_context'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; /** * This is the first phase of the query. In it, we find all monitor IDs that have ever matched the given filters. @@ -51,6 +52,8 @@ const queryBody = async (queryContext: QueryContext, searchAfter: any, size: num filters.push({ match: { 'monitor.status': queryContext.statusFilter } }); } + filters.push(EXCLUDE_RUN_ONCE_FILTER); + const body = { size: 0, query: { diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts index c72f3598533b0..7eb89f17b44ad 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts @@ -22,7 +22,7 @@ export const browserFormatters: BrowserFormatMap = { [ConfigKey.SOURCE_INLINE]: null, [ConfigKey.PARAMS]: null, [ConfigKey.SCREENSHOTS]: null, - [ConfigKey.SYNTHETICS_ARGS]: (fields) => null, + [ConfigKey.SYNTHETICS_ARGS]: (fields) => arrayFormatter(fields[ConfigKey.SYNTHETICS_ARGS]), [ConfigKey.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: null, [ConfigKey.ZIP_URL_TLS_CERTIFICATE]: null, [ConfigKey.ZIP_URL_TLS_KEY]: null, diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/convert_to_data_stream.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/convert_to_data_stream.ts index bcddd6fffba95..a7597c7c3ef34 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/convert_to_data_stream.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/convert_to_data_stream.ts @@ -31,7 +31,7 @@ export function convertToDataStreamFormat(monitor: Record): DataStr id: monitor.id, // Schedule is needed by service at root level as well schedule: monitor.schedule, - enabled: monitor.enabled, + enabled: monitor.enabled ?? true, data_stream: { namespace: monitor.namespace ?? 'default', }, diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/format_configs.test.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/format_configs.test.ts index afb12ae505957..815a02b9d4b3a 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/format_configs.test.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/format_configs.test.ts @@ -95,7 +95,7 @@ describe('formatMonitorConfig', () => { "step('Go to https://www.google.com/', async () => {\n await page.goto('https://www.google.com/');\n});", params: '', screenshots: 'on', - synthetics_args: [], + synthetics_args: ['--hasTouch true'], 'filter_journeys.match': '', 'filter_journeys.tags': ['dev'], ignore_https_errors: false, @@ -119,6 +119,7 @@ describe('formatMonitorConfig', () => { throttling: '5d/3u/20l', timeout: '16s', type: 'browser', + synthetics_args: ['--hasTouch true'], }; }); diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/service_api_client.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/service_api_client.ts index 1c55b8812d64f..40759e64eb6ba 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/service_api_client.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/service_api_client.ts @@ -24,6 +24,7 @@ export interface ServiceData { hosts: string[]; api_key: string; }; + runOnce?: boolean; } export class ServiceAPIClient { @@ -79,7 +80,14 @@ export class ServiceAPIClient { return this.callAPI('DELETE', data); } - async callAPI(method: 'POST' | 'PUT' | 'DELETE', { monitors: allMonitors, output }: ServiceData) { + async runOnce(data: ServiceData) { + return this.callAPI('POST', { ...data, runOnce: true }); + } + + async callAPI( + method: 'POST' | 'PUT' | 'DELETE', + { monitors: allMonitors, output, runOnce }: ServiceData + ) { if (this.username === TEST_SERVICE_USERNAME) { // we don't want to call service while local integration tests are running return; @@ -93,7 +101,7 @@ export class ServiceAPIClient { return axios({ method, - url: (this.devUrl ?? url) + '/monitors', + url: (this.devUrl ?? url) + (runOnce ? '/run' : '/monitors'), data: { monitors: monitorsStreams, output }, headers: this.authorization ? { diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts index d6fe86453a1c0..43adfabddad98 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts @@ -189,6 +189,32 @@ export class SyntheticsService { } } + async runOnceConfigs( + request?: KibanaRequest, + configs?: Array< + SyntheticsMonitorWithId & { + fields_under_root?: boolean; + fields?: { run_once: boolean; config_id: string }; + } + > + ) { + const monitors = this.formatConfigs(configs || (await this.getMonitorConfigs())); + if (monitors.length === 0) { + return; + } + const data = { + monitors, + output: await this.getOutput(request), + }; + + try { + return await this.apiClient.runOnce(data); + } catch (e) { + this.logger.error(e); + throw e; + } + } + async deleteConfigs(request: KibanaRequest, configs: SyntheticsMonitorWithId[]) { const data = { monitors: this.formatConfigs(configs), @@ -211,6 +237,8 @@ export class SyntheticsService { return (findResult.saved_objects ?? []).map(({ attributes, id }) => ({ ...attributes, id, + fields_under_root: true, + fields: { config_id: id }, })) as SyntheticsMonitorWithId[]; } diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 905af60961d9a..383d999f29cc6 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -36,6 +36,7 @@ import { import { addSyntheticsMonitorRoute } from './synthetics_service/add_monitor'; import { editSyntheticsMonitorRoute } from './synthetics_service/edit_monitor'; import { deleteSyntheticsMonitorRoute } from './synthetics_service/delete_monitor'; +import { runOnceSyntheticsMonitorRoute } from './synthetics_service/run_once_monitor'; export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; @@ -67,4 +68,5 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ addSyntheticsMonitorRoute, editSyntheticsMonitorRoute, deleteSyntheticsMonitorRoute, + runOnceSyntheticsMonitorRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics_service/add_monitor.ts b/x-pack/plugins/uptime/server/rest_api/synthetics_service/add_monitor.ts index 319d68d1b6e8b..1750466b6c3e6 100644 --- a/x-pack/plugins/uptime/server/rest_api/synthetics_service/add_monitor.ts +++ b/x-pack/plugins/uptime/server/rest_api/synthetics_service/add_monitor.ts @@ -35,7 +35,10 @@ export const addSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ const { syntheticsService } = server; const errors = await syntheticsService.pushConfigs(request, [ - { ...newMonitor.attributes, id: newMonitor.id }, + { + ...newMonitor.attributes, + id: newMonitor.id, + }, ]); if (errors) { diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics_service/run_once_monitor.ts b/x-pack/plugins/uptime/server/rest_api/synthetics_service/run_once_monitor.ts new file mode 100644 index 0000000000000..409990a12fcf0 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/synthetics_service/run_once_monitor.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 { schema } from '@kbn/config-schema'; +import { MonitorFields } from '../../../common/runtime_types'; +import { UMRestApiRouteFactory } from '../types'; +import { API_URLS } from '../../../common/constants'; +import { validateMonitor } from './monitor_validation'; + +export const runOnceSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ + method: 'POST', + path: API_URLS.RUN_ONCE_MONITOR + '/{monitorId}', + validate: { + body: schema.any(), + params: schema.object({ + monitorId: schema.string({ minLength: 1, maxLength: 1024 }), + }), + }, + handler: async ({ request, response, server }): Promise => { + const monitor = request.body as MonitorFields; + const { monitorId } = request.params; + + const validationResult = validateMonitor(monitor); + + if (!validationResult.valid) { + const { reason: message, details, payload } = validationResult; + return response.badRequest({ body: { message, attributes: { details, ...payload } } }); + } + + const { syntheticsService } = server; + + const errors = await syntheticsService.runOnceConfigs(request, [ + { + ...monitor, + id: monitorId, + fields_under_root: true, + fields: { run_once: true, config_id: monitorId }, + }, + ]); + + if (errors) { + return { errors }; + } + + return monitor; + }, +}); diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 868c91cd9ed12..2c8a03ddbc4f2 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -16,8 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); const spacesService = getService('spaces'); - // FLAKY: https://github.com/elastic/kibana/issues/119660 - describe.skip('search session', () => { + describe('search session', () => { describe('session management', () => { it('should fail to create a session with no name', async () => { const sessionId = `my-session-${Math.random()}`; @@ -189,7 +188,7 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + await retry.waitFor('searches persisted into session', async () => { const resp = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -285,7 +284,7 @@ export default function ({ getService }: FtrProviderContext) { const { id: id2 } = searchRes2.body; - await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + await retry.waitFor('searches persisted into session', async () => { const resp = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -342,7 +341,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + await retry.waitFor('searches persisted into session', async () => { const resp = await supertest .get(`/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -361,9 +360,8 @@ export default function ({ getService }: FtrProviderContext) { // session refresh interval is 10 seconds, wait to give a chance for status to update await new Promise((resolve) => setTimeout(resolve, 10000)); - await retry.waitForWithTimeout( + await retry.waitFor( 'searches eventually complete and session gets into the complete state', - 5000, async () => { const resp = await supertest .get(`/internal/session/${sessionId}`) @@ -428,17 +426,21 @@ export default function ({ getService }: FtrProviderContext) { // it might take the session a moment to be updated await new Promise((resolve) => setTimeout(resolve, 2500)); - const getSessionSecondTime = await supertest - .get(`/internal/session/${sessionId}`) - .set('kbn-xsrf', 'foo') - .expect(200); + await retry.waitFor('search session touched time updated', async () => { + const getSessionSecondTime = await supertest + .get(`/internal/session/${sessionId}`) + .set('kbn-xsrf', 'foo') + .expect(200); - expect(getSessionFirstTime.body.attributes.sessionId).to.be.equal( - getSessionSecondTime.body.attributes.sessionId - ); - expect(getSessionFirstTime.body.attributes.touched).to.be.lessThan( - getSessionSecondTime.body.attributes.touched - ); + expect(getSessionFirstTime.body.attributes.sessionId).to.be.equal( + getSessionSecondTime.body.attributes.sessionId + ); + expect(getSessionFirstTime.body.attributes.touched).to.be.lessThan( + getSessionSecondTime.body.attributes.touched + ); + + return true; + }); }); describe('with security', () => { @@ -645,7 +647,7 @@ export default function ({ getService }: FtrProviderContext) { const { id } = searchRes.body; - await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + await retry.waitFor('searches persisted into session', async () => { const resp = await supertest .get(`/s/${spaceId}/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -682,7 +684,7 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('should complete persisten session', async () => { + it('should complete persisted session', async () => { const sessionId = `my-session-${Math.random()}`; // run search @@ -719,7 +721,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); - await retry.waitForWithTimeout('searches persisted into session', 5000, async () => { + await retry.waitFor('searches persisted into session', async () => { const resp = await supertest .get(`/s/${spaceId}/internal/session/${sessionId}`) .set('kbn-xsrf', 'foo') @@ -738,9 +740,8 @@ export default function ({ getService }: FtrProviderContext) { // session refresh interval is 5 seconds, wait to give a chance for status to update await new Promise((resolve) => setTimeout(resolve, 5000)); - await retry.waitForWithTimeout( + await retry.waitFor( 'searches eventually complete and session gets into the complete state', - 5000, async () => { const resp = await supertest .get(`/s/${spaceId}/internal/session/${sessionId}`) diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts index 66fbd96d8f62d..74b9401904d8c 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts @@ -42,6 +42,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./metrics/get_case_metrics')); loadTestFile(require.resolve('./metrics/get_case_metrics_alerts')); loadTestFile(require.resolve('./metrics/get_case_metrics_actions')); + loadTestFile(require.resolve('./metrics/get_case_metrics_connectors')); // NOTE: Migrations are not included because they can inadvertently remove the .kibana indices which removes the users and spaces // which causes errors in any tests after them that relies on those diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts new file mode 100644 index 0000000000000..adfb22d3cf145 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_case_metrics_connectors.ts @@ -0,0 +1,138 @@ +/* + * 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 expect from '@kbn/expect'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + createCase, + deleteAllCaseItems, + getCaseMetrics, + updateCase, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('case connector metrics', () => { + const actionsRemover = new ActionsRemover(supertest); + const jiraConnector = { + id: 'jira', + name: 'Jira', + type: ConnectorTypes.jira as const, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const snConnector = { + id: 'sn', + name: 'SN', + type: ConnectorTypes.serviceNowITSM as const, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + }; + + afterEach(async () => { + await deleteAllCaseItems(es); + await actionsRemover.removeAll(); + }); + + describe('total connectors', () => { + const expectConnectorsToBe = async (caseId: string, expectedConnectors: number) => { + const metrics = await getCaseMetrics({ + supertest, + caseId, + features: ['connectors'], + }); + + expect(metrics).to.eql({ + connectors: { + total: expectedConnectors, + }, + }); + }; + + it('returns zero total connectors for a case with no connectors attached', async () => { + const theCase = await createCase(supertest, getPostCaseRequest()); + await expectConnectorsToBe(theCase.id, 0); + }); + + it('takes into account the connector from the create_case user action', async () => { + const theCase = await createCase( + supertest, + getPostCaseRequest({ + connector: jiraConnector, + }) + ); + await expectConnectorsToBe(theCase.id, 1); + }); + + it('returns the correct total number of connectors', async () => { + const theCase = await createCase(supertest, getPostCaseRequest()); + + /** + * We update the case three times to create three user actions + * Each user action created is of type connector. + * Although we have three user actions the metric + * should return two total connectors + * as the third update changes the fields + * of the Jira connector and does not adds + * a new connector. + */ + let patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + connector: jiraConnector, + }, + ], + }, + }); + + patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: patchedCases[0].version, + connector: snConnector, + }, + ], + }, + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: patchedCases[0].version, + connector: { ...jiraConnector, fields: { ...jiraConnector.fields, urgency: '1' } }, + }, + ], + }, + }); + + await expectConnectorsToBe(theCase.id, 2); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts index 561c8bc356476..f4228ed31f279 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/detection_rules.ts @@ -1241,7 +1241,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show "notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + it('should show "notifications_disabled" to be "1", "has_notification" to be "true, "has_legacy_notification" to be "false" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { await installPrePackagedRules(supertest, log); // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json @@ -1296,7 +1296,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show "notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + it('should show "notifications_enabled" to be "1", "has_notification" to be "true, "has_legacy_notification" to be "false" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { await installPrePackagedRules(supertest, log); // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json @@ -1351,7 +1351,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show "legacy_notifications_disabled" to be "1" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { + it('should show "legacy_notifications_disabled" to be "1", "has_notification" to be "false, "has_legacy_notification" to be "true" for rule that has at least "1" action(s) and the alert is "disabled"/"in-active"', async () => { await installPrePackagedRules(supertest, log); // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json @@ -1405,7 +1405,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - it('should show "legacy_notifications_enabled" to be "1" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { + it('should show "legacy_notifications_enabled" to be "1", "has_notification" to be "false, "has_legacy_notification" to be "true" for rule that has at least "1" action(s) and the alert is "enabled"/"active"', async () => { await installPrePackagedRules(supertest, log); // Rule id of "9a1a2dae-0b5f-4c3d-8305-a268d404c306" is from the file: // x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security.json diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index 6af60018d01da..6535694db042c 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -12,8 +12,8 @@ import { } from '../../../plugins/reporting/common/constants'; import { JobParamsCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource/types'; import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; -import { JobParamsPNG } from '../../../plugins/reporting/server/export_types/png/types'; -import { JobParamsPDF } from '../../../plugins/reporting/server/export_types/printable_pdf/types'; +import { JobParamsPNGDeprecated } from '../../../plugins/reporting/server/export_types/png/types'; +import { JobParamsPDFDeprecated } from '../../../plugins/reporting/server/export_types/printable_pdf/types'; import { FtrProviderContext } from '../ftr_provider_context'; function removeWhitespace(str: string) { @@ -141,7 +141,7 @@ export function createScenarios({ getService }: Pick { + const generatePdf = async (username: string, password: string, job: JobParamsPDFDeprecated) => { const jobParams = rison.encode(job as object as RisonValue); return await supertestWithoutAuth .post(`/api/reporting/generate/printablePdf`) @@ -149,7 +149,7 @@ export function createScenarios({ getService }: Pick { + const generatePng = async (username: string, password: string, job: JobParamsPNGDeprecated) => { const jobParams = rison.encode(job as object as RisonValue); return await supertestWithoutAuth .post(`/api/reporting/generate/png`) diff --git a/x-pack/test/security_solution_endpoint/services/endpoint_artifacts.ts b/x-pack/test/security_solution_endpoint/services/endpoint_artifacts.ts new file mode 100644 index 0000000000000..093e55216b9af --- /dev/null +++ b/x-pack/test/security_solution_endpoint/services/endpoint_artifacts.ts @@ -0,0 +1,89 @@ +/* + * 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 { + ExceptionListItemSchema, + CreateExceptionListSchema, + CreateExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { Response } from 'superagent'; +import { FtrService } from '../../functional/ftr_provider_context'; +import { ExceptionsListItemGenerator } from '../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { TRUSTED_APPS_EXCEPTION_LIST_DEFINITION } from '../../../plugins/security_solution/public/management/pages/trusted_apps/constants'; +import { EndpointError } from '../../../plugins/security_solution/common/endpoint/errors'; + +export interface ArtifactTestData { + artifact: ExceptionListItemSchema; + cleanup: () => Promise; +} + +export class EndpointArtifactsTestResources extends FtrService { + private readonly exceptionsGenerator = new ExceptionsListItemGenerator(); + private readonly supertest = this.ctx.getService('supertest'); + private readonly log = this.ctx.getService('log'); + + private getHttpResponseFailureHandler( + ignoredStatusCodes: number[] = [] + ): (res: Response) => Promise { + return async (res) => { + if (!res.ok && !ignoredStatusCodes.includes(res.status)) { + throw new EndpointError(JSON.stringify(res.error, null, 2)); + } + + return res; + }; + } + + private async ensureListExists(listDefinition: CreateExceptionListSchema): Promise { + // attempt to create it and ignore 409 (already exists) errors + await this.supertest + .post(EXCEPTION_LIST_URL) + .set('kbn-xsrf', 'true') + .send(listDefinition) + .then(this.getHttpResponseFailureHandler([409])); + } + + private async createExceptionItem( + createPayload: CreateExceptionListItemSchema + ): Promise { + const artifact = await this.supertest + .post(EXCEPTION_LIST_ITEM_URL) + .set('kbn-xsrf', 'true') + .send(createPayload) + .then(this.getHttpResponseFailureHandler()) + .then((response) => response.body as ExceptionListItemSchema); + + const { item_id: itemId, namespace_type: namespaceType } = artifact; + + this.log.info(`Created exception list item: ${itemId}`); + + const cleanup = async () => { + const deleteResponse = await this.supertest + .delete(`${EXCEPTION_LIST_ITEM_URL}?item_id=${itemId}&namespace_type=${namespaceType}`) + .set('kbn-xsrf', 'true') + .send() + .then(this.getHttpResponseFailureHandler([404])); + + this.log.info(`Deleted exception list item: ${itemId} (${deleteResponse.status})`); + }; + + return { + artifact, + cleanup, + }; + } + + async createTrustedApp( + overrides: Partial = {} + ): Promise { + await this.ensureListExists(TRUSTED_APPS_EXCEPTION_LIST_DEFINITION); + const trustedApp = this.exceptionsGenerator.generateTrustedAppForCreate(overrides); + + return this.createExceptionItem(trustedApp); + } +} diff --git a/x-pack/test/security_solution_endpoint/services/index.ts b/x-pack/test/security_solution_endpoint/services/index.ts index a7e8985541191..6880ce54e99aa 100644 --- a/x-pack/test/security_solution_endpoint/services/index.ts +++ b/x-pack/test/security_solution_endpoint/services/index.ts @@ -10,10 +10,12 @@ import { EndpointPolicyTestResourcesProvider } from './endpoint_policy'; import { IngestManagerProvider } from '../../common/services/ingest_manager'; import { EndpointTelemetryTestResourcesProvider } from './endpoint_telemetry'; import { EndpointTestResources } from './endpoint'; +import { EndpointArtifactsTestResources } from './endpoint_artifacts'; export const services = { ...xPackFunctionalServices, endpointTestResources: EndpointTestResources, + endpointArtifactTestResources: EndpointArtifactsTestResources, policyTestResources: EndpointPolicyTestResourcesProvider, telemetryTestResources: EndpointTelemetryTestResourcesProvider, ingestManager: IngestManagerProvider, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts.ts new file mode 100644 index 0000000000000..58f6fbf58ccb5 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts.ts @@ -0,0 +1,229 @@ +/* + * 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 { EXCEPTION_LIST_ITEM_URL } from '@kbn/securitysolution-list-constants'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../security_solution_endpoint/services/endpoint_policy'; +import { ArtifactTestData } from '../../security_solution_endpoint/services/endpoint_artifacts'; +import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../plugins/security_solution/common/endpoint/service/artifacts'; +import { ExceptionsListItemGenerator } from '../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { + createUserAndRole, + deleteUserAndRole, + ROLES, +} from '../../common/services/security_solution'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + describe('Endpoint artifacts (via lists plugin)', () => { + let fleetEndpointPolicy: PolicyTestResourceInfo; + + before(async () => { + // Create an endpoint policy in fleet we can work with + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + + // create role/user + await createUserAndRole(getService, ROLES.detections_admin); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + + // delete role/user + await deleteUserAndRole(getService, ROLES.detections_admin); + }); + + const anEndpointArtifactError = (res: { body: { message: string } }) => { + expect(res.body.message).to.match(/EndpointArtifactError/); + }; + const anErrorMessageWith = ( + value: string | RegExp + ): ((res: { body: { message: string } }) => void) => { + return (res) => { + if (value instanceof RegExp) { + expect(res.body.message).to.match(value); + } else { + expect(res.body.message).to.be(value); + } + }; + }; + + describe('and accessing trusted apps', () => { + const exceptionsGenerator = new ExceptionsListItemGenerator(); + let trustedAppData: ArtifactTestData; + + type TrustedAppApiCallsInterface = Array<{ + method: keyof Pick; + path: string; + // The body just needs to have the properties we care about in the tests. This should cover most + // mocks used for testing that support different interfaces + getBody: () => Pick; + }>; + + beforeEach(async () => { + trustedAppData = await endpointArtifactTestResources.createTrustedApp({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${fleetEndpointPolicy.packagePolicy.id}`], + }); + }); + + afterEach(async () => { + if (trustedAppData) { + await trustedAppData.cleanup(); + } + }); + + const trustedAppApiCalls: TrustedAppApiCallsInterface = [ + { + method: 'post', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => exceptionsGenerator.generateTrustedAppForCreate(), + }, + { + method: 'put', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => + exceptionsGenerator.generateTrustedAppForUpdate({ + id: trustedAppData.artifact.id, + item_id: trustedAppData.artifact.item_id, + }), + }, + ]; + + describe('and has authorization to manage endpoint security', () => { + for (const trustedAppApiCall of trustedAppApiCalls) { + it(`should error on [${trustedAppApiCall.method}] if invalid condition entry fields are used`, async () => { + const body = trustedAppApiCall.getBody(); + + body.entries[0].field = 'some.invalid.field'; + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/types that failed validation:/)); + }); + + it(`should error on [${trustedAppApiCall.method}] if a condition entry field is used more than once`, async () => { + const body = trustedAppApiCall.getBody(); + + body.entries.push({ ...body.entries[0] }); + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/Duplicate/)); + }); + + it(`should error on [${trustedAppApiCall.method}] if an invalid hash is used`, async () => { + const body = trustedAppApiCall.getBody(); + + body.entries = [ + { + field: 'process.hash.md5', + operator: 'included', + type: 'match', + value: '1', + }, + ]; + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid hash/)); + }); + + it(`should error on [${trustedAppApiCall.method}] if signer is set for a non windows os entry item`, async () => { + const body = trustedAppApiCall.getBody(); + + body.os_types = ['linux']; + body.entries = [ + { + field: 'process.Ext.code_signature', + entries: [ + { + field: 'trusted', + value: 'true', + type: 'match', + operator: 'included', + }, + { + field: 'subject_name', + value: 'foo', + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/^.*(?!process\.Ext\.code_signature)/)); + }); + + it(`should error on [${trustedAppApiCall.method}] if more than one OS is set`, async () => { + const body = trustedAppApiCall.getBody(); + + body.os_types = ['linux', 'windows']; + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/\[osTypes\]: array size is \[2\]/)); + }); + + it(`should error on [${trustedAppApiCall.method}] if policy id is invalid`, async () => { + const body = trustedAppApiCall.getBody(); + + body.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`]; + + await supertest[trustedAppApiCall.method](trustedAppApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid policy ids/)); + }); + } + }); + + describe('and user DOES NOT have authorization to manage endpoint security', () => { + for (const trustedAppApiCall of trustedAppApiCalls) { + it(`should error on [${trustedAppApiCall.method}]`, async () => { + await supertestWithoutAuth[trustedAppApiCall.method](trustedAppApiCall.path) + .auth(ROLES.detections_admin, 'changeme') + .set('kbn-xsrf', 'true') + .send(trustedAppApiCall.getBody()) + .expect(403, { + status_code: 403, + message: 'EndpointArtifactError: Endpoint authorization failure', + }); + }); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index db2de64b3bbe4..5c2a3f168248c 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -32,5 +32,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./policy')); loadTestFile(require.resolve('./package')); loadTestFile(require.resolve('./endpoint_authz')); + loadTestFile(require.resolve('./endpoint_artifacts')); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/services/index.ts b/x-pack/test/security_solution_endpoint_api_int/services/index.ts index e4f96333bac31..eb66d738af1b0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/services/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/services/index.ts @@ -8,9 +8,13 @@ import { services as xPackAPIServices } from '../../api_integration/services'; import { ResolverGeneratorProvider } from './resolver'; import { EndpointTestResources } from '../../security_solution_endpoint/services/endpoint'; +import { EndpointPolicyTestResourcesProvider } from '../../security_solution_endpoint/services/endpoint_policy'; +import { EndpointArtifactsTestResources } from '../../security_solution_endpoint/services/endpoint_artifacts'; export const services = { ...xPackAPIServices, resolverGenerator: ResolverGeneratorProvider, endpointTestResources: EndpointTestResources, + endpointPolicyTestResources: EndpointPolicyTestResourcesProvider, + endpointArtifactTestResources: EndpointArtifactsTestResources, };