diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 390072436366f..6e57f2b02ed86 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -14306,6 +14306,14 @@ paths: required: false schema: type: string + - description: Gap fill statuses + in: query + name: gap_fill_statuses + required: false + schema: + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array responses: '200': content: @@ -84145,6 +84153,11 @@ components: enum: - delete type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84171,6 +84184,11 @@ components: enum: - disable type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84210,6 +84228,11 @@ components: required: - include_exceptions - include_expired_exceptions + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84310,6 +84333,11 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84341,6 +84369,11 @@ components: enum: - enable type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84369,6 +84402,11 @@ components: enum: - export type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84412,6 +84450,11 @@ components: required: - start_date - end_date + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -84439,6 +84482,11 @@ components: enum: - run type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -85607,6 +85655,12 @@ components: - updated_at - updatedAt type: string + Security_Detections_API_GapFillStatus: + enum: + - unfilled + - in_progress + - filled + type: string Security_Detections_API_HistoryWindowStart: description: Start date to use when checking if a term has been seen before. Supports relative dates – for example, now-30d will search the last 30 days of data when checking if a term is new. We do not recommend using absolute dates, which can cause issues with rule performance due to querying increasing amounts of data over time. format: nonempty diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 786aacda1d92e..1f28e0435ba27 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -16237,6 +16237,14 @@ paths: required: false schema: type: string + - description: Gap fill statuses + in: query + name: gap_fill_statuses + required: false + schema: + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array responses: '200': content: @@ -94719,6 +94727,11 @@ components: enum: - delete type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94745,6 +94758,11 @@ components: enum: - disable type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94784,6 +94802,11 @@ components: required: - include_exceptions - include_expired_exceptions + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94884,6 +94907,11 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94915,6 +94943,11 @@ components: enum: - enable type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94943,6 +94976,11 @@ components: enum: - export type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -94986,6 +95024,11 @@ components: required: - start_date - end_date + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -95013,6 +95056,11 @@ components: enum: - run type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + items: + $ref: '#/components/schemas/Security_Detections_API_GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -96181,6 +96229,12 @@ components: - updated_at - updatedAt type: string + Security_Detections_API_GapFillStatus: + enum: + - unfilled + - in_progress + - filled + type: string Security_Detections_API_HistoryWindowStart: description: Start date to use when checking if a term has been seen before. Supports relative dates – for example, now-30d will search the last 30 days of data when checking if a term is new. We do not recommend using absolute dates, which can cause issues with rule performance due to querying increasing amounts of data over time. format: nonempty diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index a4420b05bc39a..684307e1e1da4 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -38313,11 +38313,6 @@ "xpack.securitySolution.ruleExceptions.logic.closeAlerts.error": "Alerts konnten nicht geschlossen werden", "xpack.securitySolution.ruleFromTimeline.error.title": "Regel konnte nicht aus der Zeitleiste importiert werden", "xpack.securitySolution.ruleFromTimeline.error.toastMessage": "Fehler beim Erstellen einer Regel aus der Zeitachse mit der ID: {id}", - "xpack.securitySolution.ruleGapsOverviewPanel.label": "Gesamtanzahl der Regeln mit Lücken:", - "xpack.securitySolution.ruleGapsOverviewPanel.last24HoursLabel": "Letzte 24 Stunden", - "xpack.securitySolution.ruleGapsOverviewPanel.last3DaysLabel": "Letzte 3 Tage", - "xpack.securitySolution.ruleGapsOverviewPanel.last7DaysLabel": "Letzte 7 Tage", - "xpack.securitySolution.ruleGapsOverviewPanel.showRulesWithGapsLabel": "Nur Regeln mit Lücken", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipContent": "Sehen Sie sich unser {createEsqlRuleTypeLink} an, um mit der Nutzung der ES|QL-Regeln zu beginnen.", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipLink": "Dokumentation", "xpack.securitySolution.ruleManagement.esqlValidation.errorMessage": "Fehler bei der Validierung von ES|QL: \"{message}\"", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index a7e5335e830ea..60551c48d4917 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -38682,11 +38682,6 @@ "xpack.securitySolution.ruleExceptions.logic.closeAlerts.success": "Mise à jour réussie de {numAlerts} {numAlerts, plural, =1 {alert} other {alertes}}", "xpack.securitySolution.ruleFromTimeline.error.title": "Échec de l'importation de la règle à partir de la chronologie", "xpack.securitySolution.ruleFromTimeline.error.toastMessage": "Impossible de créer la règle à partir de la chronologie avec l'ID : {id}", - "xpack.securitySolution.ruleGapsOverviewPanel.label": "Nombre total de règles avec des écarts :", - "xpack.securitySolution.ruleGapsOverviewPanel.last24HoursLabel": "Dernières 24 heures", - "xpack.securitySolution.ruleGapsOverviewPanel.last3DaysLabel": "3 derniers jours", - "xpack.securitySolution.ruleGapsOverviewPanel.last7DaysLabel": "7 derniers jours", - "xpack.securitySolution.ruleGapsOverviewPanel.showRulesWithGapsLabel": "Seulement les règles avec des écarts", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipContent": "Consultez {createEsqlRuleTypeLink} pour commencer à utiliser les règles ES|QL.", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipLink": "documentation", "xpack.securitySolution.ruleManagement.esqlValidation.errorMessage": "Erreur lors de la validation ES|QL : \"{message}\"", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index d9d7795e6a0fd..7f137bc030935 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -38721,11 +38721,6 @@ "xpack.securitySolution.ruleExceptions.logic.closeAlerts.success": "{numAlerts} {numAlerts, plural, =1 {alert} other {件のアラート}}が正常に更新されました", "xpack.securitySolution.ruleFromTimeline.error.title": "タイムラインからルールをインポートできませんでした", "xpack.securitySolution.ruleFromTimeline.error.toastMessage": "次のIDでタイムラインからのルール作成に失敗しました:{id}", - "xpack.securitySolution.ruleGapsOverviewPanel.label": "ギャップがあるルールの合計数:", - "xpack.securitySolution.ruleGapsOverviewPanel.last24HoursLabel": "過去 24 時間", - "xpack.securitySolution.ruleGapsOverviewPanel.last3DaysLabel": "過去3日間", - "xpack.securitySolution.ruleGapsOverviewPanel.last7DaysLabel": "過去 7 日間", - "xpack.securitySolution.ruleGapsOverviewPanel.showRulesWithGapsLabel": "ギャップがあるルールのみ", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipContent": "ES|QL ルールの使用を開始するには、{createEsqlRuleTypeLink}を確認してください。", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipLink": "ドキュメンテーション", "xpack.securitySolution.ruleManagement.esqlValidation.errorMessage": "ES|QLの検証エラー:\"{message}\"", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 0b719db4e6a31..b467d095a9660 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -38701,11 +38701,6 @@ "xpack.securitySolution.ruleExceptions.logic.closeAlerts.success": "已成功更新 {numAlerts} 个{numAlerts, plural, =1 {告警} other {告警}}", "xpack.securitySolution.ruleFromTimeline.error.title": "无法从时间线导入规则", "xpack.securitySolution.ruleFromTimeline.error.toastMessage": "无法用以下 ID 从时间线创建规则:{id}", - "xpack.securitySolution.ruleGapsOverviewPanel.label": "存在缺口的规则总数:", - "xpack.securitySolution.ruleGapsOverviewPanel.last24HoursLabel": "过去 24 小时", - "xpack.securitySolution.ruleGapsOverviewPanel.last3DaysLabel": "过去 3 天", - "xpack.securitySolution.ruleGapsOverviewPanel.last7DaysLabel": "过去 7 天", - "xpack.securitySolution.ruleGapsOverviewPanel.showRulesWithGapsLabel": "仅限存在缺口的规则", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipContent": "请访问我们的{createEsqlRuleTypeLink}以开始使用 ES|QL 规则。", "xpack.securitySolution.ruleManagement.esqlQuery.esqlInfoTooltipLink": "文档", "xpack.securitySolution.ruleManagement.esqlValidation.errorMessage": "验证 ES|QL 时出错:“{message}”", diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts b/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts index e4b1995312dc7..ba600275e3bff 100644 --- a/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts +++ b/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts @@ -5,6 +5,9 @@ * 2.0. */ +/** + * Describes the raw status of individual gap documents regarding to the gap fill process. + */ export const gapStatus = { UNFILLED: 'unfilled', FILLED: 'filled', @@ -12,3 +15,17 @@ export const gapStatus = { } as const; export type GapStatus = (typeof gapStatus)[keyof typeof gapStatus]; + +/** + * Represents the status of a gap fill process for a rule. + * This is a derived, per-rule aggregation that summarizes how well + * the set of gaps for a rule has been filled. It is calculated from the gap + * duration sums with precedence: unfilled > in_progress > filled. + */ +export const gapFillStatus = { + UNFILLED: 'unfilled', + IN_PROGRESS: 'in_progress', + FILLED: 'filled', +} as const; + +export type GapFillStatus = (typeof gapFillStatus)[keyof typeof gapFillStatus]; diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts index a13f3934e8dcf..49c9da5621e57 100644 --- a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts @@ -13,8 +13,8 @@ export { MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_MS, } from './backfill'; export { PLUGIN } from './plugin'; -export { gapStatus } from './gap_status'; -export type { GapStatus } from './gap_status'; +export { gapStatus, gapFillStatus } from './gap_status'; +export type { GapStatus, GapFillStatus } from './gap_status'; export { alertDeleteCategoryIds } from './alert_delete'; export type { BackfillInitiator } from './backfill'; export { backfillInitiator } from './backfill'; diff --git a/x-pack/platform/plugins/shared/alerting/common/index.ts b/x-pack/platform/plugins/shared/alerting/common/index.ts index 39d3b82680225..655aa7af35358 100644 --- a/x-pack/platform/plugins/shared/alerting/common/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/index.ts @@ -179,7 +179,8 @@ export { preconfiguredConnectorActionRefPrefix, systemConnectorActionRefPrefix, } from './action_ref_prefix'; -export { gapStatus } from './constants'; +export { gapStatus, gapFillStatus } from './constants'; +export type { GapStatus, GapFillStatus } from './constants'; export { mappingFromFieldMap, diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_gaps_summary_by_rule_ids/schemas/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_gaps_summary_by_rule_ids/schemas/v1.ts index ebcb04631774d..d140577ef85a1 100644 --- a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_gaps_summary_by_rule_ids/schemas/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_gaps_summary_by_rule_ids/schemas/v1.ts @@ -38,6 +38,7 @@ export const getGapsSummaryByRuleIdsResponseSchema = schema.object({ total_unfilled_duration_ms: schema.number(), total_in_progress_duration_ms: schema.number(), total_filled_duration_ms: schema.number(), + gap_fill_status: schema.maybe(schema.string()), }) ), }); diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_rules_with_gaps/schemas/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_rules_with_gaps/schemas/v1.ts index ed61277ed50d3..efcac07531c01 100644 --- a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_rules_with_gaps/schemas/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/get_rules_with_gaps/schemas/v1.ts @@ -5,12 +5,34 @@ * 2.0. */ import { schema } from '@kbn/config-schema'; +import { gapFillStatus, gapStatus } from '../../../../../constants'; export const getRuleIdsWithGapBodySchema = schema.object( { end: schema.string(), start: schema.string(), - statuses: schema.maybe(schema.arrayOf(schema.string())), + // Filters the underlying gap documents before aggregation. Matches the raw + // per-gap statuses. + statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal(gapStatus.UNFILLED), + schema.literal(gapStatus.PARTIALLY_FILLED), + schema.literal(gapStatus.FILLED), + ]) + ) + ), + // Filters by the derived, per-rule status that is calculated from gap + // duration sums (unfilled > in_progress > filled precedence). + highest_priority_gap_fill_statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal(gapFillStatus.UNFILLED), + schema.literal(gapFillStatus.IN_PROGRESS), + schema.literal(gapFillStatus.FILLED), + ]) + ) + ), has_unfilled_intervals: schema.maybe(schema.boolean()), has_in_progress_intervals: schema.maybe(schema.boolean()), has_filled_intervals: schema.maybe(schema.boolean()), diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.test.ts index 13fdc16bcf507..2bac1b5cfe9a8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.test.ts @@ -88,121 +88,154 @@ describe('getGapsSummaryByRuleIds', () => { }); }); - test('should successfully get gaps summary for rules', async () => { + describe('success', () => { const ruleIds = ['1', '2']; const start = '2023-11-16T08:00:00.000Z'; const end = '2023-11-16T09:00:00.000Z'; - unsecuredSavedObjectsClient.find.mockResolvedValue({ - aggregations: { - alertTypeId: { - buckets: [{ key: ['myType', 'myApp'], doc_count: 1 }], + beforeEach(() => { + unsecuredSavedObjectsClient.find.mockResolvedValue({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], doc_count: 1 }], + }, }, - }, - saved_objects: [], - per_page: 0, - page: 0, - total: 1, - }); + saved_objects: [], + per_page: 0, + page: 0, + total: 1, + }); - eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValue({ - aggregations: { - unique_rule_ids: { - buckets: [ - { - key: '1', - totalUnfilledDurationMs: { value: 1000 }, - totalInProgressDurationMs: { value: 2000 }, - totalFilledDurationMs: { value: 3000 }, - }, - { - key: '2', - totalUnfilledDurationMs: { value: 4000 }, - totalInProgressDurationMs: { value: 5000 }, - totalFilledDurationMs: { value: 6000 }, - }, - ], + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValue({ + aggregations: { + unique_rule_ids: { + buckets: [ + { + key: '1', + totalUnfilledDurationMs: { value: 1000 }, + totalInProgressDurationMs: { value: 2000 }, + totalFilledDurationMs: { value: 3000 }, + totalDurationMs: { value: 6000 }, + }, + { + key: '2', + totalUnfilledDurationMs: { value: 4000 }, + totalInProgressDurationMs: { value: 5000 }, + totalFilledDurationMs: { value: 6000 }, + totalDurationMs: { value: 15000 }, + }, + ], + }, }, - }, + }); }); - const result = await rulesClient.getGapsSummaryByRuleIds({ - ruleIds, - start, - end, - }); + test('returns gaps summary response', async () => { + const result = await rulesClient.getGapsSummaryByRuleIds({ + ruleIds, + start, + end, + }); - expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith({ - authorizationEntity: 'rule', - filterOpts: { - fieldNames: { - consumer: 'alert.attributes.consumer', - ruleTypeId: 'alert.attributes.alertTypeId', - }, - type: 'kql', - }, + expect(result).toEqual({ + data: [ + { + ruleId: '1', + totalUnfilledDurationMs: 1000, + totalInProgressDurationMs: 2000, + totalFilledDurationMs: 3000, + gapFillStatus: 'unfilled', + }, + { + ruleId: '2', + totalUnfilledDurationMs: 4000, + totalInProgressDurationMs: 5000, + totalFilledDurationMs: 6000, + gapFillStatus: 'unfilled', + }, + ], + }); }); - expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith( - expect.objectContaining({ - filter: expect.any(Object), - aggs: { - alertTypeId: { - multi_terms: { - terms: [ - { field: 'alert.attributes.alertTypeId' }, - { field: 'alert.attributes.consumer' }, - ], - }, - }, - }, - }) - ); + test('requests expected event log aggregations', async () => { + await rulesClient.getGapsSummaryByRuleIds({ + ruleIds, + start, + end, + }); - expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledWith('alert', ruleIds, { - filter: `event.action: gap AND event.provider: alerting AND not kibana.alert.rule.gap.deleted:true AND kibana.alert.rule.gap.range <= "2023-11-16T09:00:00.000Z" AND kibana.alert.rule.gap.range >= "2023-11-16T08:00:00.000Z"`, - aggs: { - unique_rule_ids: { - terms: { - field: 'rule.id', - size: 10000, - }, + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledWith( + 'alert', + ruleIds, + { + filter: `event.action: gap AND event.provider: alerting AND not kibana.alert.rule.gap.deleted:true AND kibana.alert.rule.gap.range <= "2023-11-16T09:00:00.000Z" AND kibana.alert.rule.gap.range >= "2023-11-16T08:00:00.000Z"`, aggs: { - totalUnfilledDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.unfilled_duration_ms', - }, - }, - totalInProgressDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.in_progress_duration_ms', + unique_rule_ids: { + terms: { + field: 'rule.id', + size: 10000, }, - }, - totalFilledDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.filled_duration_ms', + aggs: { + totalUnfilledDurationMs: { + sum: { + field: 'kibana.alert.rule.gap.unfilled_duration_ms', + }, + }, + totalInProgressDurationMs: { + sum: { + field: 'kibana.alert.rule.gap.in_progress_duration_ms', + }, + }, + totalFilledDurationMs: { + sum: { + field: 'kibana.alert.rule.gap.filled_duration_ms', + }, + }, + totalDurationMs: { + sum: { + field: 'kibana.alert.rule.gap.total_gap_duration_ms', + }, + }, }, }, }, - }, - }, + } + ); }); - expect(result).toEqual({ - data: [ - { - ruleId: '1', - totalUnfilledDurationMs: 1000, - totalInProgressDurationMs: 2000, - totalFilledDurationMs: 3000, - }, - { - ruleId: '2', - totalUnfilledDurationMs: 4000, - totalInProgressDurationMs: 5000, - totalFilledDurationMs: 6000, + test('requests required authorizations', async () => { + await rulesClient.getGapsSummaryByRuleIds({ + ruleIds, + start, + end, + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith({ + authorizationEntity: 'rule', + filterOpts: { + fieldNames: { + consumer: 'alert.attributes.consumer', + ruleTypeId: 'alert.attributes.alertTypeId', + }, + type: 'kql', }, - ], + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + filter: expect.any(Object), + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { field: 'alert.attributes.alertTypeId' }, + { field: 'alert.attributes.consumer' }, + ], + }, + }, + }, + }) + ); }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.ts index eb116a1182569..022e64d1d97eb 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids.ts @@ -19,6 +19,12 @@ import { alertingAuthorizationFilterOpts, RULE_TYPE_CHECKS_CONCURRENCY, } from '../../../../rules_client/common/constants'; +import { + extractGapDurationSums, + calculateHighestPriorityGapFillStatus, + type GapDurationBucket, + RULE_GAP_AGGREGATIONS, +} from '../utils'; import { buildGapsFilter } from '../../../../lib/rule_gaps/build_gaps_filter'; export async function getGapsSummaryByRuleIds( @@ -116,21 +122,7 @@ export async function getGapsSummaryByRuleIds( size: 10000, }, aggs: { - totalUnfilledDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.unfilled_duration_ms', - }, - }, - totalInProgressDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.in_progress_duration_ms', - }, - }, - totalFilledDurationMs: { - sum: { - field: 'kibana.alert.rule.gap.filled_duration_ms', - }, - }, + ...RULE_GAP_AGGREGATIONS, }, }, }, @@ -138,24 +130,24 @@ export async function getGapsSummaryByRuleIds( ); interface UniqueRuleIdsAgg { - buckets: Array<{ - key: string; - totalUnfilledDurationMs: { value: number }; - totalInProgressDurationMs: { value: number }; - totalFilledDurationMs: { value: number }; - }>; + buckets: Array; } const uniqueRuleIdsAgg = aggs.aggregations?.unique_rule_ids as UniqueRuleIdsAgg; const resultBuckets = uniqueRuleIdsAgg?.buckets ?? []; const result: GetGapsSummaryByRuleIdsResponse = { - data: resultBuckets.map((bucket) => ({ - ruleId: bucket.key, - totalUnfilledDurationMs: bucket.totalUnfilledDurationMs.value, - totalInProgressDurationMs: bucket.totalInProgressDurationMs.value, - totalFilledDurationMs: bucket.totalFilledDurationMs.value, - })), + data: resultBuckets.map((bucket) => { + const sums = extractGapDurationSums(bucket); + const gapFillStatus = calculateHighestPriorityGapFillStatus(sums); + return { + ruleId: bucket.key, + totalUnfilledDurationMs: sums.totalUnfilledDurationMs, + totalInProgressDurationMs: sums.totalInProgressDurationMs, + totalFilledDurationMs: sums.totalFilledDurationMs, + ...(gapFillStatus ? { gapFillStatus } : {}), + }; + }), }; return result; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/schemas/get_gaps_summary_by_rule_ids.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/schemas/get_gaps_summary_by_rule_ids.ts index 7adc585fb9fd7..2fa54573e58f4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/schemas/get_gaps_summary_by_rule_ids.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_gaps_summary_by_rule_ids/schemas/get_gaps_summary_by_rule_ids.ts @@ -20,6 +20,7 @@ export const getGapsSummaryByRuleIdsResponseSchema = schema.object({ totalUnfilledDurationMs: schema.number(), totalInProgressDurationMs: schema.number(), totalFilledDurationMs: schema.number(), + gapFillStatus: schema.maybe(schema.string()), }) ), }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.test.ts index ed01c285d650e..08744017727c6 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.test.ts @@ -26,6 +26,7 @@ import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connect import type { ConstructorOptions } from '../../../../rules_client'; import { RulesClient } from '../../../../rules_client'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { gapFillStatus, gapStatus } from '../../../../../common'; describe('getRuleIdsWithGaps', () => { let rulesClient: RulesClient; @@ -48,7 +49,8 @@ describe('getRuleIdsWithGaps', () => { const params = { start: '2024-01-01T00:00:00.000Z', end: '2024-01-02T00:00:00.000Z', - statuses: ['unfilled', 'partially_filled'], + statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + highestPriorityGapFillStatuses: [gapFillStatus.UNFILLED, gapFillStatus.IN_PROGRESS], }; const filter = { type: 'mock_filter' }; @@ -112,7 +114,10 @@ describe('getRuleIdsWithGaps', () => { it('should get authorization filter with correct parameters', async () => { eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue({ aggregations: { - unique_rule_ids: { + latest_gap_timestamp: { + value: null, + }, + by_rule: { buckets: [], }, }, @@ -147,8 +152,23 @@ describe('getRuleIdsWithGaps', () => { describe('event log aggregation', () => { it('should aggregate events with correct parameters', async () => { const mockAggregations = { - unique_rule_ids: { - buckets: [{ key: 'rule-1' }, { key: 'rule-2' }], + by_rule: { + buckets: [ + { + key: 'rule-1', + totalUnfilledDurationMs: { value: 100 }, + totalInProgressDurationMs: { value: 0 }, + totalFilledDurationMs: { value: 0 }, + totalDurationMs: { value: 100 }, + }, + { + key: 'rule-2', + totalUnfilledDurationMs: { value: 0 }, + totalInProgressDurationMs: { value: 50 }, + totalFilledDurationMs: { value: 0 }, + totalDurationMs: { value: 50 }, + }, + ], }, latest_gap_timestamp: { value: 1704067200000, @@ -166,17 +186,27 @@ describe('getRuleIdsWithGaps', () => { filter, expect.objectContaining({ filter: `event.action: gap AND event.provider: alerting AND not kibana.alert.rule.gap.deleted:true AND kibana.alert.rule.gap.range <= "2024-01-02T00:00:00.000Z" AND kibana.alert.rule.gap.range >= "2024-01-01T00:00:00.000Z" AND (kibana.alert.rule.gap.status : unfilled OR kibana.alert.rule.gap.status : partially_filled)`, - aggs: { + aggs: expect.objectContaining({ latest_gap_timestamp: { max: { field: '@timestamp' } }, - unique_rule_ids: expect.objectContaining({ - terms: expect.objectContaining({ - field: 'rule.id', - size: 10000, - order: { oldest_gap_timestamp: 'asc' }, - }), - aggs: { oldest_gap_timestamp: { min: { field: '@timestamp' } } }, + by_rule: expect.objectContaining({ + terms: { field: 'rule.id', size: 10000, order: { oldest_gap_timestamp: 'asc' } }, + aggs: { + totalUnfilledDurationMs: { + sum: { field: 'kibana.alert.rule.gap.unfilled_duration_ms' }, + }, + totalInProgressDurationMs: { + sum: { field: 'kibana.alert.rule.gap.in_progress_duration_ms' }, + }, + totalFilledDurationMs: { + sum: { field: 'kibana.alert.rule.gap.filled_duration_ms' }, + }, + totalDurationMs: { + sum: { field: 'kibana.alert.rule.gap.total_gap_duration_ms' }, + }, + oldest_gap_timestamp: { min: { field: '@timestamp' } }, + }, }), - }, + }), }) ); @@ -190,9 +220,7 @@ describe('getRuleIdsWithGaps', () => { it('should handle empty aggregation results', async () => { eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue({ aggregations: { - unique_rule_ids: { - buckets: [], - }, + by_rule: { buckets: [] }, latest_gap_timestamp: { value: null, }, @@ -208,24 +236,19 @@ describe('getRuleIdsWithGaps', () => { filter: expect.stringContaining( 'event.action: gap AND event.provider: alerting AND not kibana.alert.rule.gap.deleted:true' ), - aggs: { + aggs: expect.objectContaining({ latest_gap_timestamp: { max: { field: '@timestamp' } }, - unique_rule_ids: expect.objectContaining({ - terms: expect.objectContaining({ - field: 'rule.id', - size: 10000, - order: { oldest_gap_timestamp: 'asc' }, - }), - aggs: { oldest_gap_timestamp: { min: { field: '@timestamp' } } }, + by_rule: expect.objectContaining({ + terms: { field: 'rule.id', size: 10000, order: { oldest_gap_timestamp: 'asc' } }, }), - }, + }), }) ); expect(result).toEqual({ total: 0, ruleIds: [], - latestGapTimestamp: null, + latestGapTimestamp: undefined, }); }); @@ -235,6 +258,15 @@ describe('getRuleIdsWithGaps', () => { end: params.end, }; + eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue({ + aggregations: { + by_rule: { buckets: [] }, + latest_gap_timestamp: { + value: null, + }, + }, + }); + await rulesClient.getRuleIdsWithGaps(paramsWithoutStatuses); expect(eventLogClient.aggregateEventsWithAuthFilter).toHaveBeenCalledWith( @@ -242,22 +274,26 @@ describe('getRuleIdsWithGaps', () => { filter, expect.objectContaining({ filter: `event.action: gap AND event.provider: alerting AND not kibana.alert.rule.gap.deleted:true AND kibana.alert.rule.gap.range <= "2024-01-02T00:00:00.000Z" AND kibana.alert.rule.gap.range >= "2024-01-01T00:00:00.000Z"`, - aggs: { + aggs: expect.objectContaining({ latest_gap_timestamp: { max: { field: '@timestamp' } }, - unique_rule_ids: expect.objectContaining({ - terms: expect.objectContaining({ - field: 'rule.id', - size: 10000, - order: { oldest_gap_timestamp: 'asc' }, - }), - aggs: { oldest_gap_timestamp: { min: { field: '@timestamp' } } }, + by_rule: expect.objectContaining({ + terms: { field: 'rule.id', size: 10000, order: { oldest_gap_timestamp: 'asc' } }, }), - }, + }), }) ); }); it('should use the default maxRulesToFetch limit when param not provided', async () => { + eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue({ + aggregations: { + by_rule: { buckets: [] }, + latest_gap_timestamp: { + value: null, + }, + }, + }); + await rulesClient.getRuleIdsWithGaps(params); expect(eventLogClient.aggregateEventsWithAuthFilter).toHaveBeenCalledWith( @@ -265,7 +301,7 @@ describe('getRuleIdsWithGaps', () => { filter, expect.objectContaining({ aggs: expect.objectContaining({ - unique_rule_ids: expect.objectContaining({ + by_rule: expect.objectContaining({ terms: expect.objectContaining({ size: 10000, }), @@ -276,6 +312,15 @@ describe('getRuleIdsWithGaps', () => { }); it('should respect custom maxRulesToFetch value', async () => { + eventLogClient.aggregateEventsWithAuthFilter.mockResolvedValue({ + aggregations: { + by_rule: { buckets: [] }, + latest_gap_timestamp: { + value: null, + }, + }, + }); + await rulesClient.getRuleIdsWithGaps({ ...params, maxRulesToFetch: 123, @@ -286,7 +331,7 @@ describe('getRuleIdsWithGaps', () => { filter, expect.objectContaining({ aggs: expect.objectContaining({ - unique_rule_ids: expect.objectContaining({ + by_rule: expect.objectContaining({ terms: expect.objectContaining({ size: 123, }), diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.ts index bcd8fd0eea2d1..b1052201ea4b6 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/get_rule_ids_with_gaps.ts @@ -7,7 +7,10 @@ import Boom from '@hapi/boom'; import type { KueryNode } from '@kbn/es-query'; -import type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { + AggregationsMaxAggregate, + AggregationsAggregationContainer, +} from '@elastic/elasticsearch/lib/api/types'; import { AlertingAuthorizationEntity, AlertingAuthorizationFilterType, @@ -15,9 +18,25 @@ import { import type { RulesClientContext } from '../../../../rules_client'; import type { GetRuleIdsWithGapsParams, GetRuleIdsWithGapsResponse } from './types'; import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; -import { buildGapsFilter } from '../../../../lib/rule_gaps/build_gaps_filter'; +import { hasMatchedGapFillStatus, RULE_GAP_AGGREGATIONS, type GapDurationBucket } from '../utils'; export const RULE_SAVED_OBJECT_TYPE = 'alert'; +import { buildGapsFilter } from '../../../../lib/rule_gaps/build_gaps_filter'; +/** + * Returns rule ids that have gaps within the requested time range. + * + * Parameters: + * - statuses: Direct per-gap status filter applied to event log gap documents + * before aggregation. This corresponds to gap-level statuses + * (e.g. 'unfilled' | 'partially_filled' | 'filled') and controls which + * gaps are considered in the aggregations and latest timestamp query. + * + * - highestPriorityGapFillStatuses: Computed, per-rule status filter applied after + * aggregation. For each rule we compute an aggregated status from the + * summed gap durations with precedence: unfilled > in_progress > filled. + * Only rules whose computed aggregated status matches one of the provided + * values ('unfilled' | 'in_progress' | 'filled') are returned. + */ const MAX_RULES_TO_FETCH = 10000; export async function getRuleIdsWithGaps( context: RulesClientContext, @@ -46,7 +65,14 @@ export async function getRuleIdsWithGaps( throw error; } - const { start, end, statuses, sortOrder, ruleTypes } = params; + const { + start, + end, + statuses, + sortOrder, + ruleTypes, + highestPriorityGapFillStatuses = [], + } = params; const eventLogClient = await context.getEventLogClient(); let filter = buildGapsFilter({ @@ -84,7 +110,7 @@ export async function getRuleIdsWithGaps( field: '@timestamp', }, }, - unique_rule_ids: { + by_rule: { terms: { field: 'rule.id', size: params.maxRulesToFetch ?? MAX_RULES_TO_FETCH, @@ -93,29 +119,36 @@ export async function getRuleIdsWithGaps( ? { newest_gap_timestamp: 'desc' } : { oldest_gap_timestamp: 'asc' }, }, - aggs: perBucketAgg, + aggs: { + ...perBucketAgg, + ...RULE_GAP_AGGREGATIONS, + }, }, }, } ); - interface UniqueRuleIdsAgg { - buckets: Array<{ key: string }>; - } - - const uniqueRuleIdsAgg = aggs.aggregations?.unique_rule_ids as UniqueRuleIdsAgg; - const latestGapTimestampAgg = aggs.aggregations?.latest_gap_timestamp as { value: number }; + const byRuleAgg = aggs.aggregations?.by_rule as { buckets: GapDurationBucket[] }; + const buckets = byRuleAgg?.buckets ?? []; - const resultBuckets = uniqueRuleIdsAgg?.buckets ?? []; + const ruleIds: string[] = []; - const ruleIds = resultBuckets.map((bucket) => bucket.key) ?? []; + for (const b of buckets) { + if ( + highestPriorityGapFillStatuses.length === 0 || + hasMatchedGapFillStatus(b, highestPriorityGapFillStatuses) + ) { + ruleIds.push(b.key); + } + } + const latestGapTimestampAgg = aggs.aggregations + ?.latest_gap_timestamp as AggregationsMaxAggregate; const result: GetRuleIdsWithGapsResponse = { - total: ruleIds?.length, + total: ruleIds.length, ruleIds, - latestGapTimestamp: latestGapTimestampAgg?.value, + latestGapTimestamp: latestGapTimestampAgg.value ?? undefined, }; - return result; } catch (err) { const errorMessage = `Failed to find rules with gaps`; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/schemas/get_rules_with_gaps.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/schemas/get_rules_with_gaps.ts index e7e1f7347efb0..3b67728a81b90 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/schemas/get_rules_with_gaps.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/get_rule_ids_with_gaps/schemas/get_rules_with_gaps.ts @@ -6,11 +6,33 @@ */ import { schema } from '@kbn/config-schema'; +import { gapFillStatus, gapStatus } from '../../../../../../common'; export const getRuleIdsWithGapsParamsSchema = schema.object({ - start: schema.string(), - end: schema.string(), - statuses: schema.maybe(schema.arrayOf(schema.string())), + start: schema.maybe(schema.string()), + end: schema.maybe(schema.string()), + // Filters the underlying gap documents before aggregation. Matches the raw + // per-gap statuses. + statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal(gapStatus.UNFILLED), + schema.literal(gapStatus.PARTIALLY_FILLED), + schema.literal(gapStatus.FILLED), + ]) + ) + ), + // Derived, per-rule status filter computed from the aggregated gap duration + // sums with precedence: unfilled > in_progress > filled. + highestPriorityGapFillStatuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal(gapFillStatus.UNFILLED), + schema.literal(gapFillStatus.IN_PROGRESS), + schema.literal(gapFillStatus.FILLED), + ]) + ) + ), hasUnfilledIntervals: schema.maybe(schema.boolean()), hasInProgressIntervals: schema.maybe(schema.boolean()), hasFilledIntervals: schema.maybe(schema.boolean()), diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.test.ts new file mode 100644 index 0000000000000..c56644018b2ca --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + extractGapDurationSums, + calculateHighestPriorityGapFillStatus, + hasMatchedGapFillStatus, + RULE_GAP_AGGREGATIONS, + type GapDurationBucket, +} from './utils'; + +jest.mock('../../../lib/rule_gaps/build_gaps_filter', () => ({ + buildGapsFilter: jest.fn(() => 'mocked_filter'), +})); + +describe('utils', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('extractGapDurationSums', () => { + it('extracts all sums when present', () => { + const bucket: GapDurationBucket = { + totalUnfilledDurationMs: { value: 100 }, + totalInProgressDurationMs: { value: 50 }, + totalFilledDurationMs: { value: 25 }, + totalDurationMs: { value: 175 }, + key: 'test', + }; + expect(extractGapDurationSums(bucket)).toEqual({ + totalUnfilledDurationMs: 100, + totalInProgressDurationMs: 50, + totalFilledDurationMs: 25, + totalDurationMs: 175, + }); + }); + + it('defaults missing fields to 0', () => { + const bucket: GapDurationBucket = { key: 'test' }; + expect(extractGapDurationSums(bucket)).toEqual({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 0, + totalDurationMs: 0, + }); + }); + + it('treats null values as 0', () => { + const bucket: GapDurationBucket = { + totalUnfilledDurationMs: { value: null }, + totalInProgressDurationMs: { value: null }, + totalFilledDurationMs: { value: null }, + totalDurationMs: { value: null }, + key: 'test', + }; + expect(extractGapDurationSums(bucket)).toEqual({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 0, + totalDurationMs: 0, + }); + }); + + it('clamps negative values to 0', () => { + const bucket: GapDurationBucket = { + totalUnfilledDurationMs: { value: -10 }, + totalInProgressDurationMs: { value: -1 }, + totalFilledDurationMs: { value: -5 }, + totalDurationMs: { value: -16 }, + key: 'test', + }; + expect(extractGapDurationSums(bucket)).toEqual({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 0, + totalDurationMs: 0, + }); + }); + }); + + describe('calculateHighestPriorityGapFillStatus', () => { + it('returns unfilled when any unfilled ms > 0', () => { + expect( + calculateHighestPriorityGapFillStatus({ + totalUnfilledDurationMs: 1, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 100, + totalDurationMs: 101, + }) + ).toBe('unfilled'); + }); + + it('returns in_progress when no unfilled and any in_progress > 0', () => { + expect( + calculateHighestPriorityGapFillStatus({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 50, + totalFilledDurationMs: 0, + totalDurationMs: 50, + }) + ).toBe('in_progress'); + }); + + it('returns filled when no unfilled/in_progress and filled > 0', () => { + expect( + calculateHighestPriorityGapFillStatus({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 10, + totalDurationMs: 10, + }) + ).toBe('filled'); + }); + + it('returns null when all sums are 0', () => { + expect( + calculateHighestPriorityGapFillStatus({ + totalUnfilledDurationMs: 0, + totalInProgressDurationMs: 0, + totalFilledDurationMs: 0, + totalDurationMs: 0, + }) + ).toBeNull(); + }); + }); + + describe('RULE_GAP_AGGREGATIONS', () => { + it('contains expected sum field mappings', () => { + expect(RULE_GAP_AGGREGATIONS.totalUnfilledDurationMs).toEqual({ + sum: { field: 'kibana.alert.rule.gap.unfilled_duration_ms' }, + }); + expect(RULE_GAP_AGGREGATIONS.totalInProgressDurationMs).toEqual({ + sum: { field: 'kibana.alert.rule.gap.in_progress_duration_ms' }, + }); + expect(RULE_GAP_AGGREGATIONS.totalFilledDurationMs).toEqual({ + sum: { field: 'kibana.alert.rule.gap.filled_duration_ms' }, + }); + expect(RULE_GAP_AGGREGATIONS.totalDurationMs).toEqual({ + sum: { field: 'kibana.alert.rule.gap.total_gap_duration_ms' }, + }); + }); + }); + + describe('hasMatchedGapFillStatus', () => { + it('returns true when the gap fill status of the bucket matches the given gap fill statuses', () => { + expect( + hasMatchedGapFillStatus({ key: 'test', totalUnfilledDurationMs: { value: 1 } }, [ + 'unfilled', + ]) + ).toBe(true); + }); + + it('returns false when the gap fill status of the bucket does not match the given gap fill statuses', () => { + expect( + hasMatchedGapFillStatus({ key: 'test', totalUnfilledDurationMs: { value: 1 } }, ['filled']) + ).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.ts b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.ts new file mode 100644 index 0000000000000..1ceb25f8e8af9 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/gaps/methods/utils.ts @@ -0,0 +1,73 @@ +/* + * 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 { AggregationsSumAggregate } from '@elastic/elasticsearch/lib/api/types'; +import type { GapFillStatus } from '../../../../common'; +export interface GapDurationSums { + totalUnfilledDurationMs: number; + totalInProgressDurationMs: number; + totalFilledDurationMs: number; + totalDurationMs: number; +} + +/** + * Extracts and normalizes gap duration sums from an aggregation bucket + */ +export function extractGapDurationSums(bucket: GapDurationBucket): GapDurationSums { + return { + totalUnfilledDurationMs: Math.max(0, bucket.totalUnfilledDurationMs?.value ?? 0), + totalInProgressDurationMs: Math.max(0, bucket.totalInProgressDurationMs?.value ?? 0), + totalFilledDurationMs: Math.max(0, bucket.totalFilledDurationMs?.value ?? 0), + totalDurationMs: Math.max(0, bucket.totalDurationMs?.value ?? 0), + }; +} + +/** + * Calculates aggregated gap fill status based on duration sums + * Precedence: unfilled > in_progress > filled + */ +export function calculateHighestPriorityGapFillStatus(sums: GapDurationSums): GapFillStatus | null { + const { totalInProgressDurationMs, totalUnfilledDurationMs, totalFilledDurationMs } = sums; + if (totalUnfilledDurationMs > 0) return 'unfilled'; + if (totalInProgressDurationMs > 0) return 'in_progress'; + if (totalFilledDurationMs > 0) return 'filled'; + return null; +} + +/** + * Common aggregation fields for gap duration tracking + */ +export const RULE_GAP_AGGREGATIONS = { + totalUnfilledDurationMs: { + sum: { field: 'kibana.alert.rule.gap.unfilled_duration_ms' }, + }, + totalInProgressDurationMs: { + sum: { field: 'kibana.alert.rule.gap.in_progress_duration_ms' }, + }, + totalFilledDurationMs: { + sum: { field: 'kibana.alert.rule.gap.filled_duration_ms' }, + }, + totalDurationMs: { + sum: { field: 'kibana.alert.rule.gap.total_gap_duration_ms' }, + }, +} as const; + +export type GapDurationBucket = { key: string } & Partial< + Record +>; + +/** + * Checks if the gap fill status of the bucket matches the given gap fill statuses + */ +export const hasMatchedGapFillStatus = ( + bucket: GapDurationBucket, + gapFillStatuses: GapFillStatus[] +): boolean => { + const sums = extractGapDurationSums(bucket); + const gapFillStatus = calculateHighestPriorityGapFillStatus(sums); + + return Boolean(gapFillStatus && gapFillStatuses.includes(gapFillStatus)); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route.test.ts index 1300e6fe467fc..50cf3219acc5b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/get_gaps_summary_by_rule_ids_route.test.ts @@ -11,6 +11,7 @@ import { verifyApiAccess } from '../../../../lib/license_api_access'; import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; import { getGapsSummaryByRuleIdsRoute } from './get_gaps_summary_by_rule_ids_route'; +import { gapFillStatus } from '../../../../../common'; jest.mock('../../../../lib/license_api_access', () => ({ verifyApiAccess: jest.fn(), @@ -33,6 +34,7 @@ describe('getGapsSummaryByRuleIdsRoute', () => { data: [ { ruleId: 'rule-1', + gapFillStatus: gapFillStatus.UNFILLED, totalUnfilledDurationMs: 3600000, totalInProgressDurationMs: 0, totalFilledDurationMs: 82800000, @@ -64,6 +66,7 @@ describe('getGapsSummaryByRuleIdsRoute', () => { data: [ { rule_id: 'rule-1', + gap_fill_status: gapFillStatus.UNFILLED, total_unfilled_duration_ms: 3600000, total_in_progress_duration_ms: 0, total_filled_duration_ms: 82800000, diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/transforms/transform_response/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/transforms/transform_response/v1.ts index acf888c210d6c..635850e84d412 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/transforms/transform_response/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_gaps_summary_by_rule_ids/transforms/transform_response/v1.ts @@ -16,5 +16,6 @@ export const transformResponse = ( total_unfilled_duration_ms: gap.totalUnfilledDurationMs, total_in_progress_duration_ms: gap.totalInProgressDurationMs, total_filled_duration_ms: gap.totalFilledDurationMs, + gap_fill_status: gap.gapFillStatus, })), }); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_rule_ids_with_gaps/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_rule_ids_with_gaps/transforms/transform_request/v1.ts index 6c885ae643043..bf9ee37ce57db 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_rule_ids_with_gaps/transforms/transform_request/v1.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/get_rule_ids_with_gaps/transforms/transform_request/v1.ts @@ -12,6 +12,7 @@ export const transformRequest = (request: GetRuleIdsWithGapBodyV1): GetRuleIdsWi start: request.start, end: request.end, statuses: request.statuses, + highestPriorityGapFillStatuses: request.highest_priority_gap_fill_statuses, hasUnfilledIntervals: request.has_unfilled_intervals, hasInProgressIntervals: request.has_in_progress_intervals, hasFilledIntervals: request.has_filled_intervals, diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gaps_summary_by_rule_ids.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gaps_summary_by_rule_ids.ts index b082c65c1dced..a519d39544a41 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gaps_summary_by_rule_ids.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_gaps_summary_by_rule_ids.ts @@ -144,6 +144,8 @@ export default function getGapsSummaryByRuleIdsTests({ getService }: FtrProvider expect(rule2Data.total_in_progress_duration_ms).to.eql(0); expect(rule1Data.total_filled_duration_ms).to.eql(0); expect(rule2Data.total_filled_duration_ms).to.eql(0); + expect(rule1Data.gap_fill_status).to.eql('unfilled'); + expect(rule2Data.gap_fill_status).to.eql('unfilled'); break; default: diff --git a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_rules_with_gaps.ts b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_rules_with_gaps.ts index 300e74994e57f..f62791c22698c 100644 --- a/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_rules_with_gaps.ts +++ b/x-pack/platform/test/alerting_api_integration/security_and_spaces/group8/tests/alerting/gap/get_rules_with_gaps.ts @@ -369,6 +369,78 @@ export default function getRuleIdsWithGapsTests({ getService }: FtrProviderConte } }); + it('should filter rules by aggregated status', async () => { + // Create a rule + const ruleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // Create an unfilled gap + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gap1Start, + end: gap1End, + spaceId: apiOptions.spaceId, + }); + + // Test filtering by aggregated status 'unfilled' + const aggUnfilledResponse = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/_get_rules`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + start: searchStart, + end: searchEnd, + highest_priority_gap_fill_statuses: ['unfilled'], + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(aggUnfilledResponse.statusCode).to.eql(403); + break; + + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(aggUnfilledResponse.statusCode).to.eql(200); + expect(aggUnfilledResponse.body.total).to.eql(1); + expect(aggUnfilledResponse.body.rule_ids).to.eql([ruleId]); + break; + + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + + // Test filtering by aggregated status 'filled' (should return empty) + const aggFilledResponse = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/_get_rules`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send({ + start: searchStart, + end: searchEnd, + highest_priority_gap_fill_statuses: ['filled'], + }); + + if ( + !['no_kibana_privileges at space1', 'space_1_all at space2'].includes(scenario.id) + ) { + expect(aggFilledResponse.statusCode).to.eql(200); + expect(aggFilledResponse.body.total).to.eql(0); + expect(aggFilledResponse.body.rule_ids).to.eql([]); + } + }); + it('should not return the rule id of a deleted rule', async () => { // Create 2 rules const rresponse1 = await supertest @@ -598,7 +670,7 @@ export default function getRuleIdsWithGapsTests({ getService }: FtrProviderConte } }); - it('should return null latest gap timestamp when no gaps exist', async () => { + it('should not return latest gap timestamp when no gaps exist', async () => { // Create a rule without gaps const ruleResponse = await supertest .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) @@ -631,7 +703,7 @@ export default function getRuleIdsWithGapsTests({ getService }: FtrProviderConte expect(response.statusCode).to.eql(200); expect(response.body.total).to.eql(0); expect(response.body.rule_ids).to.eql([]); - expect(response.body.latest_gap_timestamp).to.eql(null); + expect(response.body.latest_gap_timestamp).to.eql(undefined); break; default: diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts index 14e546ede0cb8..d2e002c58c0b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts @@ -764,3 +764,8 @@ export const AlertSuppressionCamel = z.object({ duration: AlertSuppressionDuration.optional(), missingFieldsStrategy: AlertSuppressionMissingFieldsStrategy.optional(), }); + +export type GapFillStatus = z.infer; +export const GapFillStatus = z.enum(['unfilled', 'in_progress', 'filled']); +export type GapFillStatusEnum = typeof GapFillStatus.enum; +export const GapFillStatusEnum = GapFillStatus.enum; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml index 4a1a8f258f465..adbc4cdbcfeb3 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml @@ -786,3 +786,11 @@ components: $ref: '#/components/schemas/AlertSuppressionMissingFieldsStrategy' required: - groupBy + + # Gap fill status used in aggregated rule gap summaries + GapFillStatus: + type: string + enum: + - unfilled + - in_progress + - filled diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts index 168c45e8f8e1a..29c06c85dd99a 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts @@ -19,6 +19,7 @@ import { BooleanFromString } from '@kbn/zod-helpers'; import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; import { + GapFillStatus, RuleActionGroup, RuleActionId, RuleActionParams, @@ -131,6 +132,10 @@ Only valid when query property is undefined. * Gaps range end, valid only when query is provided */ gaps_range_end: z.string().optional(), + /** + * Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + */ + gap_fill_statuses: z.array(GapFillStatus).optional(), }); export type BulkDeleteRules = z.infer; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml index d6ff151cbd06c..0ec60c1394a4c 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml @@ -1217,6 +1217,11 @@ components: gaps_range_end: description: Gaps range end, valid only when query is provided type: string + gap_fill_statuses: + description: Gap fill statuses to filter rules with gaps by status (used together with gaps_range_*). + type: array + items: + $ref: '../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/GapFillStatus' BulkDeleteRules: allOf: diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.gen.ts index 6f2a42bb8a434..bce253efa77f3 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.gen.ts @@ -18,6 +18,7 @@ import { z } from '@kbn/zod'; import { ArrayFromString } from '@kbn/zod-helpers'; import { SortOrder } from '../../model/sorting.gen'; +import { GapFillStatus } from '../../model/rule_schema/common_attributes.gen'; import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen'; export type FindRulesSortField = z.infer; @@ -82,6 +83,10 @@ Filters the returned results according to the value of the specified field, usin * Gaps range end */ gaps_range_end: z.string().optional(), + /** + * Gap fill statuses + */ + gap_fill_statuses: ArrayFromString(GapFillStatus).optional(), }); export type FindRulesRequestQueryInput = z.input; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.schema.yaml index da74c81284be5..dfb63144bf7a4 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/find_rules_route.schema.yaml @@ -77,6 +77,14 @@ paths: required: false schema: type: string + - name: gap_fill_statuses + in: query + description: Gap fill statuses + required: false + schema: + type: array + items: + $ref: '../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/GapFillStatus' x-codeSamples: - lang: cURL source: | diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.test.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.test.ts index c9fb9ce9a3524..81aaa01e73eb8 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.test.ts @@ -4,12 +4,84 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { gapFillStatus } from '@kbn/alerting-plugin/common'; import type { FindRulesRequestQueryInput } from './find_rules_route.gen'; import { validateFindRulesRequestQuery } from './request_schema_validation'; +const GAP_FILTERS_COUPLING_ERROR = + 'Query fields "gap_fill_statuses", "gaps_range_start" and "gaps_range_end" has to be specified together'; + describe('Find rules request schema, additional validation', () => { describe('validateFindRulesRequestQuery', () => { + describe('gap filters coupling', () => { + test('Valid when gap_fill_statuses with both gaps_range_start and gaps_range_end', () => { + const schema: FindRulesRequestQueryInput = { + gap_fill_statuses: [gapFillStatus.UNFILLED], + gaps_range_start: '2024-01-01T00:00:00.000Z', + gaps_range_end: '2024-01-02T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([]); + }); + + test('Error when gap_fill_statuses present without gaps_range_start and gaps_range_end', () => { + const schema: FindRulesRequestQueryInput = { + gap_fill_statuses: [gapFillStatus.IN_PROGRESS], + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('Error when gap_fill_statuses present with only gaps_range_start', () => { + const schema: FindRulesRequestQueryInput = { + gap_fill_statuses: [gapFillStatus.FILLED], + gaps_range_start: '2024-01-01T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('Error when gap_fill_statuses present with only gaps_range_end', () => { + const schema: FindRulesRequestQueryInput = { + gap_fill_statuses: [gapFillStatus.FILLED], + gaps_range_end: '2024-01-02T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('Error when gaps_range_start and gaps_range_end present without gap_fill_statuses', () => { + const schema: FindRulesRequestQueryInput = { + gaps_range_start: '2024-01-01T00:00:00.000Z', + gaps_range_end: '2024-01-02T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('Error when only gaps_range_start present without gap_fill_statuses', () => { + const schema: FindRulesRequestQueryInput = { + gaps_range_start: '2024-01-01T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('Error when only gaps_range_end present without gap_fill_statuses', () => { + const schema: FindRulesRequestQueryInput = { + gaps_range_end: '2024-01-02T00:00:00.000Z', + }; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([GAP_FILTERS_COUPLING_ERROR]); + }); + + test('No error when none of gap filters are provided', () => { + const schema: FindRulesRequestQueryInput = {}; + const errors = validateFindRulesRequestQuery(schema); + expect(errors).toEqual([]); + }); + }); + test('You can have an empty sort_field and empty sort_order', () => { const schema: FindRulesRequestQueryInput = {}; const errors = validateFindRulesRequestQuery(schema); diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.ts index 69d94be334e3f..204bcb9956917 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/find_rules/request_schema_validation.ts @@ -16,5 +16,18 @@ export const validateFindRulesRequestQuery = (query: FindRulesRequestQueryInput) return ['when "sort_order" and "sort_field" must exist together or not at all']; } } + const ruleExecutionGapQueryParamsSet = new Set([ + Array.isArray(query.gap_fill_statuses) && query.gap_fill_statuses.length > 0, + Boolean(query.gaps_range_start), + Boolean(query.gaps_range_end), + ]); + + // All rule execution gap query params should be specified or omitted (set.size == 1) + // return an error otherwise + if (ruleExecutionGapQueryParamsSet.size > 1) { + return [ + 'Query fields "gap_fill_statuses", "gaps_range_start" and "gaps_range_end" has to be specified together', + ]; + } return []; }; diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml index 56007cb0c2371..d302886ebaa74 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -3033,6 +3033,14 @@ paths: required: false schema: type: string + - description: Gap fill statuses + in: query + name: gap_fill_statuses + required: false + schema: + items: + $ref: '#/components/schemas/GapFillStatus' + type: array responses: '200': content: @@ -4775,6 +4783,13 @@ components: enum: - delete type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4803,6 +4818,13 @@ components: enum: - disable type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4844,6 +4866,13 @@ components: required: - include_exceptions - include_expired_exceptions + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4952,6 +4981,13 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4985,6 +5021,13 @@ components: enum: - enable type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -5015,6 +5058,13 @@ components: enum: - export type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -5062,6 +5112,13 @@ components: required: - start_date - end_date + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -5091,6 +5148,13 @@ components: enum: - run type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -6345,6 +6409,12 @@ components: - updated_at - updatedAt type: string + GapFillStatus: + enum: + - unfilled + - in_progress + - filled + type: string HistoryWindowStart: description: >- Start date to use when checking if a term has been seen before. Supports diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml index 6aae1305322a0..83424e43b3604 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -2897,6 +2897,14 @@ paths: required: false schema: type: string + - description: Gap fill statuses + in: query + name: gap_fill_statuses + required: false + schema: + items: + $ref: '#/components/schemas/GapFillStatus' + type: array responses: '200': content: @@ -4105,6 +4113,13 @@ components: enum: - delete type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4133,6 +4148,13 @@ components: enum: - disable type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4174,6 +4196,13 @@ components: required: - include_exceptions - include_expired_exceptions + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4282,6 +4311,13 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4315,6 +4351,13 @@ components: enum: - enable type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4345,6 +4388,13 @@ components: enum: - export type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4392,6 +4442,13 @@ components: required: - start_date - end_date + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -4421,6 +4478,13 @@ components: enum: - run type: string + gap_fill_statuses: + description: >- + Gap fill statuses to filter rules with gaps by status (used together + with gaps_range_*). + items: + $ref: '#/components/schemas/GapFillStatus' + type: array gaps_range_end: description: Gaps range end, valid only when query is provided type: string @@ -5675,6 +5739,12 @@ components: - updated_at - updatedAt type: string + GapFillStatus: + enum: + - unfilled + - in_progress + - filled + type: string HistoryWindowStart: description: >- Start date to use when checking if a term has been seen before. Supports diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts index 6327fb92a7b8f..bf5f8d9616aaf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/common/translations.ts @@ -970,6 +970,34 @@ export const RULE_EXECUTION_STATUS_FILTER = i18n.translate( } ); +export const GAP_FILL_STATUS_FILTER_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.filters.gapStatus.label', + { + defaultMessage: 'Gap fill status', + } +); + +export const GAP_FILL_STATUS_IN_PROGRESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.filters.gapStatus.inProgress', + { + defaultMessage: 'In progress', + } +); + +export const GAP_FILL_STATUS_UNFILLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.filters.gapStatus.unfilled', + { + defaultMessage: 'Unfilled', + } +); + +export const GAP_FILL_STATUS_FILLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.filters.gapStatus.filled', + { + defaultMessage: 'Filled', + } +); + export const SOLVABLE_CONFLICT_LABEL = i18n.translate( 'xpack.securitySolution.detectionEngine.upgradeRules.solvableConflictLabel', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts index ba15ed5d1ff71..4d633a4060322 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/api.ts @@ -13,7 +13,7 @@ import { INTERNAL_ALERTING_GAPS_GET_SUMMARY_BY_RULE_IDS_API_PATH, INTERNAL_ALERTING_GAPS_FILL_BY_ID_API_PATH, INTERNAL_ALERTING_GAPS_FIND_API_PATH, - gapStatus, + gapFillStatus, } from '@kbn/alerting-plugin/common'; import type { FindBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/find'; import type { ScheduleBackfillResponseBody } from '@kbn/alerting-plugin/common/routes/backfill/apis/schedule'; @@ -118,14 +118,14 @@ export const getRuleIdsWithGaps = async ({ signal, start, end, - statuses = [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + gapFillStatuses = [gapFillStatus.UNFILLED], hasUnfilledIntervals, hasInProgressIntervals, hasFilledIntervals, }: { start: string; end: string; - statuses: string[]; + gapFillStatuses: string[]; hasUnfilledIntervals?: boolean; hasInProgressIntervals?: boolean; hasFilledIntervals?: boolean; @@ -138,7 +138,7 @@ export const getRuleIdsWithGaps = async ({ body: JSON.stringify({ start, end, - statuses, + highest_priority_gap_fill_statuses: gapFillStatuses, ...(hasUnfilledIntervals !== undefined && { has_unfilled_intervals: hasUnfilledIntervals, }), diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_get_rule_ids_with_gaps.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_get_rule_ids_with_gaps.tsx index b0dfd4638a8f4..c9d0cb0998bd7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_get_rule_ids_with_gaps.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/use_get_rule_ids_with_gaps.tsx @@ -27,13 +27,13 @@ export const useInvalidateGetRuleIdsWithGapsQuery = () => { export const useGetRuleIdsWithGaps = ( { gapRange, - statuses, + gapFillStatuses, hasUnfilledIntervals, hasInProgressIntervals, hasFilledIntervals, }: { gapRange: GapRangeValue; - statuses: string[]; + gapFillStatuses: string[]; hasUnfilledIntervals?: boolean; hasInProgressIntervals?: boolean; hasFilledIntervals?: boolean; @@ -44,7 +44,7 @@ export const useGetRuleIdsWithGaps = ( [ GET_RULE_IDS_WITH_GAPS, gapRange, - ...statuses, + ...gapFillStatuses, hasUnfilledIntervals, hasInProgressIntervals, hasFilledIntervals, @@ -55,7 +55,7 @@ export const useGetRuleIdsWithGaps = ( signal, start, end, - statuses, + gapFillStatuses, hasUnfilledIntervals, hasInProgressIntervals, hasFilledIntervals, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/utils.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/utils.ts index 5ac58b405678c..bf4329532a433 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/api/hooks/utils.ts @@ -22,6 +22,9 @@ export const getGapRange = (gapRange: GapRangeValue) => { case GapRangeValue.LAST_7_D: duration = moment.duration(7, 'days'); break; + case GapRangeValue.LAST_90_D: + duration = moment.duration(90, 'days'); + break; } const start = now.clone().subtract(duration).toISOString(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_gaps_callout/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_gaps_callout/index.tsx index 4c26eae4e148d..b19f8a8203f91 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_gaps_callout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rule_gaps_callout/index.tsx @@ -13,7 +13,7 @@ import { EuiButtonEmpty, EuiLink, } from '@elastic/eui'; -import { gapStatus } from '@kbn/alerting-plugin/common'; +import { gapFillStatus } from '@kbn/alerting-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; @@ -47,8 +47,7 @@ export const RuleGapsCallout = () => { const { data } = useGetRuleIdsWithGaps({ gapRange: GapRangeValue.LAST_24_H, - statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], - hasUnfilledIntervals: true, + gapFillStatuses: [gapFillStatus.UNFILLED], }); useEffect(() => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx index 95ffa60e66f49..8861020209dd8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/index.tsx @@ -4,61 +4,32 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; -import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiText, - EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItem, - EuiBadge, - EuiFilterButton, - EuiFilterGroup, - EuiToolTip, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiToolTip, EuiBadge } from '@elastic/eui'; -import { gapStatus } from '@kbn/alerting-plugin/common'; +import { gapFillStatus } from '@kbn/alerting-plugin/common'; import { useRulesTableContext } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; import * as i18n from './translations'; import { useGetRuleIdsWithGaps } from '../../api/hooks/use_get_rule_ids_with_gaps'; -import { defaultRangeValue, GapRangeValue } from '../../constants'; -import { ManualRuleRunEventTypes } from '../../../../common/lib/telemetry/events/manual_rule_run/types'; -import { useKibana } from '../../../../common/lib/kibana'; +import { defaultRangeValue } from '../../constants'; export const RulesWithGapsOverviewPanel = () => { const { - state: { - filterOptions: { gapSearchRange, showRulesWithGaps }, - lastUpdated: rulesTableLastUpdatedAt, - }, - actions: { setFilterOptions }, + state: { lastUpdated: rulesTableLastUpdatedAt }, } = useRulesTableContext(); + // Total rules with gaps: unfilled or in progress const { data: totalRulesWithGaps, refetch: refetchGetRuleIdsWithGaps } = useGetRuleIdsWithGaps({ - gapRange: gapSearchRange ?? defaultRangeValue, - statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + gapRange: defaultRangeValue, + gapFillStatuses: [gapFillStatus.UNFILLED, gapFillStatus.IN_PROGRESS], }); + + // Rules with in-progress gaps const { data: inProgressRulesWithGaps, refetch: refetchGetRuleIdsWithGapsInProgressIntervals } = useGetRuleIdsWithGaps({ - gapRange: gapSearchRange ?? defaultRangeValue, - statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], - hasInProgressIntervals: true, + gapRange: defaultRangeValue, + gapFillStatuses: [gapFillStatus.IN_PROGRESS], }); - const [isPopoverOpen, setPopover] = useState(false); - const telemetry = useKibana().services.telemetry; - - useEffect(() => { - return () => { - // reset filter options when unmounting - setFilterOptions({ - gapSearchRange: defaultRangeValue, - showRulesWithGaps: false, - }); - }; - }, [setFilterOptions]); useEffect(() => { refetchGetRuleIdsWithGaps(); @@ -68,43 +39,6 @@ export const RulesWithGapsOverviewPanel = () => { refetchGetRuleIdsWithGaps, refetchGetRuleIdsWithGapsInProgressIntervals, ]); - - const rangeValueToLabel = { - [GapRangeValue.LAST_24_H]: i18n.RULE_GAPS_OVERVIEW_PANEL_LAST_24_HOURS_LABEL, - [GapRangeValue.LAST_3_D]: i18n.RULE_GAPS_OVERVIEW_PANEL_LAST_3_DAYS_LABEL, - [GapRangeValue.LAST_7_D]: i18n.RULE_GAPS_OVERVIEW_PANEL_LAST_7_DAYS_LABEL, - }; - - const onButtonClick = () => { - setPopover(!isPopoverOpen); - }; - - const closePopover = () => { - setPopover(false); - }; - - const items = Object.values(GapRangeValue).map((value) => ({ - value, - label: rangeValueToLabel[value], - })); - - const button = ( - - {rangeValueToLabel[gapSearchRange ?? defaultRangeValue]} - - ); - - const handleShowRulesWithGapsFilterButtonClick = () => { - if (!showRulesWithGaps) { - telemetry.reportEvent(ManualRuleRunEventTypes.ShowOnlyRulesWithGaps, { - dateRange: gapSearchRange ?? defaultRangeValue, - }); - } - setFilterOptions({ - showRulesWithGaps: !showRulesWithGaps, - }); - }; - return ( { gutterSize="m" data-test-subj="rule-with-gaps_overview-panel" > - - - ( - { - setFilterOptions({ - gapSearchRange: item.value, - }); - closePopover(); - }} - > - {item.label} - - ))} - /> - - @@ -148,32 +55,17 @@ export const RulesWithGapsOverviewPanel = () => { - {inProgressRulesWithGaps && totalRulesWithGaps && ( - - - {totalRulesWithGaps?.total} {'/'} {inProgressRulesWithGaps?.total} - - - )} + + + {totalRulesWithGaps?.total ?? 0} {'/'} {inProgressRulesWithGaps?.total ?? 0} + + - - - - {i18n.RULE_GAPS_OVERVIEW_PANEL_SHOW_RULES_WITH_GAPS_LABEL} - - - ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx index 622e01d111747..a4063da22a8c9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/components/rules_with_gaps_overview_panel/translations.tsx @@ -8,42 +8,15 @@ import { i18n } from '@kbn/i18n'; export const RULE_GAPS_OVERVIEW_PANEL_LABEL = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.label', + 'xpack.securitySolution.detectionEngine.ruleGaps.overviewPanel.label', { - defaultMessage: 'Total rules with gaps:', - } -); -export const RULE_GAPS_OVERVIEW_PANEL_SHOW_RULES_WITH_GAPS_LABEL = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.showRulesWithGapsLabel', - { - defaultMessage: 'Only rules with unfilled gaps', - } -); - -export const RULE_GAPS_OVERVIEW_PANEL_LAST_24_HOURS_LABEL = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.last24HoursLabel', - { - defaultMessage: 'Last 24 hours', - } -); - -export const RULE_GAPS_OVERVIEW_PANEL_LAST_3_DAYS_LABEL = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.last3DaysLabel', - { - defaultMessage: 'Last 3 days', - } -); - -export const RULE_GAPS_OVERVIEW_PANEL_LAST_7_DAYS_LABEL = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.last7DaysLabel', - { - defaultMessage: 'Last 7 days', + defaultMessage: 'Rules with gaps', } ); export const RULE_GAPS_OVERVIEW_PANEL_TOOLTIP_TEXT = i18n.translate( - 'xpack.securitySolution.ruleGapsOverviewPanel.tooltipText', + 'xpack.securitySolution.detectionEngine.ruleGaps.overviewPanel.tooltip', { - defaultMessage: 'Rules with unfilled gaps / Rules with gaps being filled now', + defaultMessage: 'Total rules with gaps / rules that currently have in-progress gaps', } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts index 597ccbd5cbcf1..ff0f43d7dcf0a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_gaps/constants.ts @@ -9,6 +9,7 @@ export enum GapRangeValue { LAST_24_H = 'last_24_h', LAST_3_D = 'last_3_d', LAST_7_D = 'last_7_d', + LAST_90_D = 'last_90_d', } -export const defaultRangeValue = GapRangeValue.LAST_24_H; +export const defaultRangeValue = GapRangeValue.LAST_90_D; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 56146a3ee1849..55ccb65a862e0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -13,6 +13,7 @@ import { INTERNAL_ALERTING_API_FIND_RULES_PATH } from '@kbn/alerting-plugin/comm import type { ActionType, AsApiContract } from '@kbn/actions-plugin/common'; import { BASE_ACTION_API_PATH } from '@kbn/actions-plugin/common'; import type { ActionResult } from '@kbn/actions-plugin/server'; +import type { GapFillStatus } from '@kbn/alerting-plugin/common/constants/gap_status'; import { convertRulesFilterToKQL } from '../../../../common/detection_engine/rule_management/rule_filtering'; import type { GetPrebuiltRuleBaseVersionRequest, @@ -70,6 +71,7 @@ import type { RulePreviewResponse, RuleResponse } from '../../../../common/api/d import { KibanaServices } from '../../../common/lib/kibana'; import * as i18n from '../../common/translations'; +import { getGapRange } from '../../rule_gaps/api/hooks/utils'; import type { CreateRulesProps, ExportDocumentsProps, @@ -88,6 +90,7 @@ import type { UpdateRulesProps, } from '../logic/types'; import type { BootstrapPrebuiltRulesResponse } from '../../../../common/api/detection_engine/prebuilt_rules/bootstrap_prebuilt_rules/bootstrap_prebuilt_rules.gen'; +import { defaultRangeValue } from '../../rule_gaps/constants'; /** * Create provided Rule @@ -193,17 +196,24 @@ export const fetchRules = async ({ page: 1, perPage: 20, }, - gapsRange, signal, }: FetchRulesProps): Promise => { const kql = convertRulesFilterToKQL(filterOptions); + const shouldApplyDefaultGapsRange = Boolean(filterOptions?.gapFillStatuses?.length); + const defaultGapsRange = shouldApplyDefaultGapsRange ? getGapRange(defaultRangeValue) : undefined; + const query = { page: pagination.page, per_page: pagination.perPage, sort_field: sortingOptions.field, sort_order: sortingOptions.order, - ...(gapsRange ? { gaps_range_start: gapsRange.start, gaps_range_end: gapsRange.end } : {}), + ...(filterOptions?.gapFillStatuses?.length + ? { gap_fill_statuses: filterOptions.gapFillStatuses } + : {}), + ...(defaultGapsRange + ? { gaps_range_start: defaultGapsRange.start, gaps_range_end: defaultGapsRange.end } + : {}), ...(kql !== '' ? { filter: kql } : {}), }; @@ -349,7 +359,12 @@ export interface BulkActionErrorResponse { } export type QueryOrIds = - | { query: string; ids?: undefined; gapRange?: { start: string; end: string } } + | { + query: string; + ids?: undefined; + gapRange?: { start: string; end: string }; + gapFillStatuses?: GapFillStatus[]; + } | { query?: undefined; ids: string[] }; type PlainBulkAction = { @@ -417,6 +432,10 @@ export async function performBulkAction({ run: bulkAction.type === BulkActionTypeEnum.run ? bulkAction.runPayload : undefined, gaps_range_start: 'gapRange' in bulkAction ? bulkAction.gapRange?.start : undefined, gaps_range_end: 'gapRange' in bulkAction ? bulkAction.gapRange?.end : undefined, + gap_fill_statuses: + 'gapFillStatuses' in bulkAction && bulkAction.gapFillStatuses?.length + ? bulkAction.gapFillStatuses + : undefined, fill_gaps: bulkAction.type === BulkActionTypeEnum.fill_gaps ? bulkAction.fillGapsPayload : undefined, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 8407707d76a87..8879b0f0a2b90 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -7,7 +7,7 @@ import * as z from '@kbn/zod'; -import type { RuleSnooze } from '@kbn/alerting-plugin/common'; +import type { RuleSnooze, GapFillStatus } from '@kbn/alerting-plugin/common'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import type { RuleSnoozeSettings } from '@kbn/triggers-actions-ui-plugin/public/types'; @@ -28,7 +28,6 @@ import type { PatchRuleRequestBody, } from '../../../../common/api/detection_engine/rule_management'; import { FindRulesSortField } from '../../../../common/api/detection_engine/rule_management'; -import type { GapRangeValue } from '../../rule_gaps/constants'; export interface CreateRulesProps { rule: RuleCreateProps; signal?: AbortSignal; @@ -66,10 +65,6 @@ export interface FetchRulesProps { pagination?: Pick; filterOptions?: FilterOptions; sortingOptions?: SortingOptions; - gapsRange?: { - start: string; - end: string; - }; signal?: AbortSignal; } @@ -107,8 +102,7 @@ export interface FilterOptions { enabled?: boolean; // undefined is to display all the rules ruleExecutionStatus?: RuleExecutionStatus; // undefined means "all" ruleSource?: RuleCustomizationStatus[]; // undefined is to display all the rules - showRulesWithGaps?: boolean; - gapSearchRange?: GapRangeValue; + gapFillStatuses?: GapFillStatus[]; includeRuleTypes?: Type[]; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index ed6cefe60b7f0..b7fb80c25d1b5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -106,13 +106,16 @@ export const useBulkActions = ({ actions: { clearRulesSelection, setIsPreflightInProgress }, } = rulesTableContext; const globalQuery = useMemo(() => { - const gapRange = filterOptions?.showRulesWithGaps - ? getGapRange(filterOptions.gapSearchRange ?? defaultRangeValue) + const gapRange = filterOptions?.gapFillStatuses?.length + ? getGapRange(defaultRangeValue) : undefined; return { query: kql, ...(gapRange && { gapRange }), + ...(filterOptions?.gapFillStatuses?.length && { + gapFillStatuses: filterOptions.gapFillStatuses, + }), }; }, [kql, filterOptions]); @@ -475,7 +478,11 @@ export const useBulkActions = ({ type: BulkActionTypeEnum.edit, ...prepareSearchParams({ ...(isAllSelected - ? { filterOptions, gapRange: globalQuery.gapRange } + ? { + filterOptions, + gapRange: globalQuery.gapRange, + gapFillStatuses: filterOptions.gapFillStatuses, + } : { selectedRuleIds }), dryRunResult, }), diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts index 9a14b717ccfac..72e419169ee08 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts @@ -174,6 +174,29 @@ describe('prepareSearchParams', () => { expect(result).toEqual({ query: expect.any(String), gapRange }); }); + test('should include gapFillStatuses in the query when provided', () => { + const filterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + gapFillStatuses: ['unfilled', 'filled'], + }; + const dryRunResult: DryRunResult = { + ruleErrors: [], + }; + const result = prepareSearchParams({ + filterOptions, + dryRunResult, + gapFillStatuses: filterOptions.gapFillStatuses, + }); + + expect(result).toEqual({ + query: expect.any(String), + gapFillStatuses: ['unfilled', 'filled'], + }); + }); + test('should return only query when neither selectedRuleIds nor gapRange are provided', () => { const dryRunResult: DryRunResult = { ruleErrors: [] }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts index 525dcccccc935..9f2ac3ac47ace 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { GapFillStatus } from '@kbn/alerting-plugin/common'; import { convertRulesFilterToKQL } from '../../../../../../../common/detection_engine/rule_management/rule_filtering'; import type { QueryOrIds } from '../../../../../rule_management/logic'; import type { DryRunResult } from '../types'; @@ -17,6 +18,7 @@ type PrepareSearchFilterProps = | { filterOptions: FilterOptions; gapRange?: { start: string; end: string }; + gapFillStatuses?: GapFillStatus[]; dryRunResult?: DryRunResult; }; @@ -78,5 +80,6 @@ export const prepareSearchParams = ({ return { query: convertRulesFilterToKQL(modifiedFilterOptions), ...(props.gapRange ? { gapRange: props.gapRange } : {}), + ...(props.gapFillStatuses?.length ? { gapFillStatuses: props.gapFillStatuses } : {}), }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index a8cf19564f97d..d428070b8aca0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -119,8 +119,6 @@ describe('RulesTableContextProvider', () => { showCustomRules: true, showElasticRules: false, enabled: true, - gapSearchRange: 'last_24_h', - showRulesWithGaps: false, }); expect(state.sortingOptions).toEqual({ field: 'name', @@ -142,8 +140,6 @@ describe('RulesTableContextProvider', () => { tags: DEFAULT_FILTER_OPTIONS.tags, showCustomRules: DEFAULT_FILTER_OPTIONS.showCustomRules, showElasticRules: DEFAULT_FILTER_OPTIONS.showElasticRules, - gapSearchRange: 'last_24_h', - showRulesWithGaps: false, }); expect(state.sortingOptions).toEqual({ field: DEFAULT_SORTING_OPTIONS.field, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx index 8bf883b02f60a..89866e089b1da 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx @@ -15,7 +15,6 @@ import React, { useRef, useState, } from 'react'; -import { getGapRange } from '../../../../rule_gaps/api/hooks/utils'; import { useFetchRulesSnoozeSettingsQuery } from '../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings_query'; import { useGetGapsSummaryByRuleIds } from '../../../../rule_gaps/api/hooks/use_get_gaps_summary_by_rule_id'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; @@ -215,8 +214,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide enabled: savedFilter?.enabled, ruleExecutionStatus: savedFilter?.ruleExecutionStatus ?? DEFAULT_FILTER_OPTIONS.ruleExecutionStatus, - gapSearchRange: DEFAULT_FILTER_OPTIONS.gapSearchRange, - showRulesWithGaps: false, + gapFillStatuses: DEFAULT_FILTER_OPTIONS.gapFillStatuses, }); const [sortingOptions, setSortingOptions] = useState({ @@ -234,10 +232,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide const [page, setPage] = useState(savedPagination?.page ?? DEFAULT_PAGE); const [perPage, setPerPage] = useState(savedPagination?.perPage ?? DEFAULT_RULES_PER_PAGE); const [selectedRuleIds, setSelectedRuleIds] = useState([]); - const [gapRangeForSearch, setGapRangeForSearch] = useState<{ - start: string; - end: string; - }>(); const autoRefreshBeforePause = useRef(null); const isActionInProgress = loadingRules.ids.length > 0; @@ -265,6 +259,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide tags: DEFAULT_FILTER_OPTIONS.tags, enabled: undefined, ruleExecutionStatus: DEFAULT_FILTER_OPTIONS.ruleExecutionStatus, + gapFillStatuses: undefined, }); setSortingOptions({ field: DEFAULT_SORTING_OPTIONS.field, @@ -292,14 +287,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide } }, [selectedRuleIds, isRefreshOn]); - useEffect(() => { - if (filterOptions.showRulesWithGaps) { - setGapRangeForSearch(getGapRange(filterOptions.gapSearchRange ?? defaultRangeValue)); - } else { - setGapRangeForSearch(undefined); - } - }, [filterOptions.showRulesWithGaps, filterOptions.gapSearchRange]); - // Fetch rules const { data: { rules, total } = { rules: [], total: 0 }, @@ -314,7 +301,6 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide filterOptions, sortingOptions, pagination, - ...(gapRangeForSearch ? { gapsRange: gapRangeForSearch } : {}), }, { // We don't need refreshes on windows focus and reconnects if auto-refresh if off @@ -340,7 +326,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide const { data: rulesGapInfoByRuleIds, refetch: refetchGapInfo } = useGetGapsSummaryByRuleIds( { ruleIds: rules.map((x) => x.id), - gapRange: filterOptions.gapSearchRange ?? defaultRangeValue, + gapRange: defaultRangeValue, }, { enabled: rules.length > 0, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts index 9709f52259e90..2dedf3ed38c2d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_defaults.ts @@ -6,7 +6,6 @@ */ import type { FilterOptions, SortingOptions } from '../../../../rule_management/logic/types'; -import { defaultRangeValue } from '../../../../rule_gaps/constants'; export const DEFAULT_FILTER_OPTIONS: FilterOptions = { filter: '', @@ -15,8 +14,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { showElasticRules: false, enabled: undefined, ruleExecutionStatus: undefined, - gapSearchRange: defaultRangeValue, - showRulesWithGaps: false, + gapFillStatuses: undefined, }; export const DEFAULT_SORTING_OPTIONS: SortingOptions = { field: 'enabled', diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/gap_fill_status_selector.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/gap_fill_status_selector.tsx new file mode 100644 index 0000000000000..20b0c550b21e6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/gap_fill_status_selector.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { EuiFilterButton, EuiPopover, EuiSelectable } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { GapFillStatus } from '@kbn/alerting-plugin/common'; +import { gapFillStatus } from '@kbn/alerting-plugin/common'; +import { useBoolean } from '@kbn/react-hooks'; +import { + GAP_FILL_STATUS_FILTER_LABEL, + GAP_FILL_STATUS_FILLED, + GAP_FILL_STATUS_IN_PROGRESS, + GAP_FILL_STATUS_UNFILLED, +} from '../../../../common/translations'; + +interface GapFillStatusSelectorProps { + selectedStatuses: GapFillStatus[]; + onSelectedStatusesChanged: (newStatuses: GapFillStatus[]) => void; +} + +export const GapFillStatusSelector = ({ + selectedStatuses, + onSelectedStatusesChanged, +}: GapFillStatusSelectorProps) => { + const [isOpen, { off: close, toggle }] = useBoolean(false); + + const options: EuiSelectableOption[] = useMemo( + () => [ + { + label: GAP_FILL_STATUS_IN_PROGRESS, + data: { status: gapFillStatus.IN_PROGRESS as GapFillStatus }, + checked: selectedStatuses.includes(gapFillStatus.IN_PROGRESS) ? 'on' : undefined, + }, + { + label: GAP_FILL_STATUS_UNFILLED, + data: { status: gapFillStatus.UNFILLED as GapFillStatus }, + checked: selectedStatuses.includes(gapFillStatus.UNFILLED) ? 'on' : undefined, + }, + { + label: GAP_FILL_STATUS_FILLED, + data: { status: gapFillStatus.FILLED as GapFillStatus }, + checked: selectedStatuses.includes(gapFillStatus.FILLED) ? 'on' : undefined, + }, + ], + [selectedStatuses] + ); + + const onChange = (newOptions: EuiSelectableOption[]) => { + const statuses = newOptions + .filter((option) => option.checked === 'on') + .map((option) => (option.data as { status?: GapFillStatus } | undefined)?.status) + .filter((status): status is GapFillStatus => status != null); + onSelectedStatusesChanged(statuses); + }; + + const trigger = ( + 0} + numActiveFilters={selectedStatuses.length} + data-test-subj="gapFillStatusFilterButton" + > + {GAP_FILL_STATUS_FILTER_LABEL} + + ); + + return ( + + + {(list) => ( +
+ {list} +
+ )} +
+
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx index f83fc4141d1e7..98b96c1dded86 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table_filters/rules_table_filters.tsx @@ -8,26 +8,33 @@ import { EuiFilterButton, EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; import React, { useCallback } from 'react'; +import type { GapFillStatus } from '@kbn/alerting-plugin/common'; import styled from 'styled-components'; import { useRuleManagementFilters } from '../../../../rule_management/logic/use_rule_management_filters'; import { RULES_TABLE_ACTIONS } from '../../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction'; import * as i18n from '../../../../common/translations'; import { useRulesTableContext } from '../rules_table/rules_table_context'; +import { AllRulesTabs } from '../rules_table_toolbar'; import { TagsFilterPopover } from './tags_filter_popover'; import { RuleExecutionStatusSelector } from './rule_execution_status_selector'; import { RuleSearchField } from './rule_search_field'; import type { RuleExecutionStatus } from '../../../../../../common/api/detection_engine'; +import { GapFillStatusSelector } from './gap_fill_status_selector'; const FilterWrapper = styled(EuiFlexGroup)` margin-bottom: ${({ theme }) => theme.eui.euiSizeXS}; `; +interface RulesTableFiltersProps { + selectedTab: AllRulesTabs; +} + /** * Collection of filters for filtering data within the RulesTable. Contains search bar, Elastic/Custom * Rules filter button toggle, and tag selection */ -const RulesTableFiltersComponent = () => { +const RulesTableFiltersComponent = ({ selectedTab }: RulesTableFiltersProps) => { const { startTransaction } = useStartTransaction(); const { state: { filterOptions }, @@ -44,6 +51,7 @@ const RulesTableFiltersComponent = () => { tags: selectedTags, enabled, ruleExecutionStatus: selectedRuleExecutionStatus, + gapFillStatuses, } = filterOptions; const handleOnSearch = useCallback( @@ -94,6 +102,17 @@ const RulesTableFiltersComponent = () => { [selectedRuleExecutionStatus, setFilterOptions, startTransaction] ); + const handleSelectedGapStatuses = useCallback( + (newStatuses: GapFillStatus[]) => { + const currentStatuses = gapFillStatuses ?? []; + if (!isEqual(newStatuses, currentStatuses)) { + startTransaction({ name: RULES_TABLE_ACTIONS.FILTER }); + setFilterOptions({ gapFillStatuses: newStatuses }); + } + }, + [gapFillStatuses, setFilterOptions, startTransaction] + ); + return ( @@ -108,6 +127,17 @@ const RulesTableFiltersComponent = () => { + {selectedTab === AllRulesTabs.monitoring && ( + + + + + + )} + (({ selectedTab }) => { selectedRuleIds, sortingOptions, }, - actions: { setIsAllSelected, setPage, setPerPage, setSelectedRuleIds, setSortingOptions }, + actions: { + setFilterOptions, + setIsAllSelected, + setPage, + setPerPage, + setSelectedRuleIds, + setSortingOptions, + }, } = rulesTableContext; const [isDeleteConfirmationVisible, showDeleteConfirmation, hideDeleteConfirmation] = @@ -241,15 +247,18 @@ export const RulesTables = React.memo(({ selectedTab }) => { setIsAllSelected(!isAllSelected); setSelectedRuleIds(!isAllSelected ? rules.map(({ id }) => id) : []); }, [rules, isAllSelected, setIsAllSelected, setSelectedRuleIds]); - - const storeGapsInEventLogEnabled = useIsExperimentalFeatureEnabled('storeGapsInEventLogEnabled'); - const isTableEmpty = ruleManagementFilters?.rules_summary.custom_count === 0 && ruleManagementFilters?.rules_summary.prebuilt_installed_count === 0; const shouldShowRulesTable = !isLoading && !isTableEmpty; + useEffect(() => { + if (selectedTab !== AllRulesTabs.monitoring && filterOptions.gapFillStatuses) { + setFilterOptions({ gapFillStatuses: undefined }); + } + }, [filterOptions.gapFillStatuses, selectedTab, setFilterOptions]); + let tableProps; switch (selectedTab) { case AllRulesTabs.management: @@ -367,13 +376,13 @@ export const RulesTables = React.memo(({ selectedTab }) => { {shouldShowRulesTable && ( <> - {selectedTab === AllRulesTabs.monitoring && storeGapsInEventLogEnabled && ( + {selectedTab === AllRulesTabs.monitoring && ( <> )} - + + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.columns.gapStatus.tooltip.inProgress', + { + defaultMessage: 'In progress: {duration}', + values: { duration }, + } + ); + +export const gapStatusTooltipUnfilled = (duration: string) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.columns.gapStatus.tooltip.unfilled', + { + defaultMessage: 'Unfilled: {duration}', + values: { duration }, + } + ); + +export const gapStatusTooltipFilled = (duration: string) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.columns.gapStatus.tooltip.filled', { + defaultMessage: 'Filled: {duration}', + values: { duration }, + }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index ee44732a59fbd..c6b5153b57ca3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -6,8 +6,19 @@ */ import type { EuiBasicTableColumn, EuiTableActionsColumnType } from '@elastic/eui'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import type { GapFillStatus } from '@kbn/alerting-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; +import { gapFillStatus } from '@kbn/alerting-plugin/common'; +import type { GetGapsSummaryByRuleIdsResponseBody } from '@kbn/alerting-plugin/common/routes/gaps/apis/get_gaps_summary_by_rule_ids'; import moment from 'moment'; import React, { useMemo } from 'react'; import { RulesTableEmptyColumnName } from './rules_table_empty_column_name'; @@ -46,9 +57,20 @@ import { useRulesTableActions } from './use_rules_table_actions'; import { MlRuleWarningPopover } from '../ml_rule_warning_popover/ml_rule_warning_popover'; import { getMachineLearningJobId } from '../../../common/helpers'; import type { TimeRange } from '../../../rule_gaps/types'; +import { + GAP_STATUS_HEADER, + GAP_STATUS_IN_PROGRESS_LABEL, + GAP_STATUS_UNFILLED_LABEL, + GAP_STATUS_FILLED_LABEL, + gapStatusTooltipInProgress, + gapStatusTooltipUnfilled, + gapStatusTooltipFilled, +} from './translations'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; +type GapSummaryEntry = GetGapsSummaryByRuleIdsResponseBody['data'][number]; + interface ColumnsProps { hasCRUDPermissions: boolean; isLoadingJobs: boolean; @@ -468,6 +490,95 @@ export const useGapDurationColumn = () => { }; }; +const GapFillStatusTooltip = ({ + totalInProgressDurationMs, + totalUnfilledDurationMs, + totalFilledDurationMs, +}: { + totalInProgressDurationMs: number; + totalUnfilledDurationMs: number; + totalFilledDurationMs: number; +}) => { + const formatDurationHumanized = (duration: number) => { + return duration === 0 ? '0 ms' : moment.duration(duration, 'ms').humanize(); + }; + return ( +
+ + {gapStatusTooltipInProgress(formatDurationHumanized(totalInProgressDurationMs ?? 0))} + + + {gapStatusTooltipUnfilled(formatDurationHumanized(totalUnfilledDurationMs ?? 0))} + + + {gapStatusTooltipFilled(formatDurationHumanized(totalFilledDurationMs ?? 0))} + +
+ ); +}; + +export const useGapStatusColumn = (): TableColumn => { + const { euiTheme } = useEuiTheme(); + + return useMemo( + () => ({ + field: 'gap_info', + name: GAP_STATUS_HEADER, + render: (gapInfo: GapSummaryEntry | undefined) => { + const status = (gapInfo?.gap_fill_status ?? undefined) as GapFillStatus | undefined; + if (!gapInfo || !status) return getEmptyTagValue(); + + const totalInProgressDurationMs = gapInfo.total_in_progress_duration_ms; + const totalUnfilledDurationMs = gapInfo.total_unfilled_duration_ms; + const totalFilledDurationMs = gapInfo.total_filled_duration_ms; + + const byStatus: Record = { + [gapFillStatus.IN_PROGRESS]: { + color: euiTheme.colors.backgroundBaseWarning, + label: GAP_STATUS_IN_PROGRESS_LABEL, + }, + [gapFillStatus.UNFILLED]: { + color: euiTheme.colors.backgroundBaseDanger, + label: GAP_STATUS_UNFILLED_LABEL, + }, + [gapFillStatus.FILLED]: { + color: euiTheme.colors.backgroundBaseSuccess, + label: GAP_STATUS_FILLED_LABEL, + }, + }; + + const color = byStatus[status]?.color; + const label = byStatus[status]?.label; + + return ( + + } + > + + {label} + + + ); + }, + sortable: false, + truncateText: true, + width: '120px', + }), + [ + euiTheme.colors.backgroundBaseWarning, + euiTheme.colors.backgroundBaseDanger, + euiTheme.colors.backgroundBaseSuccess, + ] + ); +}; + export const TOTAL_UNFILLED_DURATION_COLUMN = { field: 'gap_info.total_unfilled_duration_ms', name: ( @@ -515,6 +626,7 @@ export const useMonitoringColumns = ({ mlJobs, }); const gapDurationColumn = useGapDurationColumn(); + const gapStatusColumn = useGapStatusColumn(); return useMemo( () => [ @@ -527,6 +639,7 @@ export const useMonitoringColumns = ({ TAGS_COLUMN, INDEXING_DURATION_COLUMN, SEARCH_DURATION_COLUMN, + gapStatusColumn, gapDurationColumn, TOTAL_UNFILLED_DURATION_COLUMN, executionStatusColumn, @@ -541,6 +654,7 @@ export const useMonitoringColumns = ({ gapDurationColumn, hasCRUDPermissions, showRelatedIntegrations, + gapStatusColumn, ] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts index 5f213edcc1fe4..c21781c81e380 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts @@ -7,7 +7,7 @@ import type { RulesClient } from '@kbn/alerting-plugin/server'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; -import { gapStatus } from '@kbn/alerting-plugin/common'; +import type { GapFillStatus } from '@kbn/alerting-plugin/common/constants/gap_status'; import type { PromisePoolOutcome } from '../../../../../../utils/promise_pool'; import type { RuleAlertType } from '../../../../rule_schema'; import { findRules } from '../../../logic/search/find_rules'; @@ -18,12 +18,14 @@ export const fetchRulesByQueryOrIds = async ({ rulesClient, maxRules, gapRange, + gapFillStatuses, }: { query: string | undefined; ids: string[] | undefined; rulesClient: RulesClient; maxRules: number; gapRange?: { start: string; end: string }; + gapFillStatuses?: GapFillStatus[]; }): Promise> => { if (ids) { const fallbackErrorMessage = 'Error resolving the rule'; @@ -67,12 +69,11 @@ export const fetchRulesByQueryOrIds = async ({ let ruleIdsWithGaps: string[] | undefined; // If there is a gap range, we need to find the rules that have gaps in that range - if (gapRange) { + if (gapRange && gapFillStatuses && gapFillStatuses.length > 0) { const ruleIdsWithGapsResponse = await rulesClient.getRuleIdsWithGaps({ start: gapRange.start, end: gapRange.end, - statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], - hasUnfilledIntervals: true, + highestPriorityGapFillStatuses: gapFillStatuses, }); ruleIdsWithGaps = ruleIdsWithGapsResponse.ruleIds; if (ruleIdsWithGaps.length === 0) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts index 0a5dcf02940f1..9df8484d5e438 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { gapFillStatus } from '@kbn/alerting-plugin/common'; import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../../../common/constants'; import { mlServicesMock } from '../../../../../machine_learning/mocks'; import { buildMlAuthz } from '../../../../../machine_learning/authz'; @@ -688,7 +689,7 @@ describe('Perform bulk action route', () => { expect(response.status).toEqual(400); expect(response.body.message).toEqual( - 'Cannot use both ids and gaps_range_start/gaps_range_end in request payload.' + 'gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.' ); }); @@ -707,7 +708,7 @@ describe('Perform bulk action route', () => { expect(response.status).toEqual(400); expect(response.body.message).toEqual( - 'Both gaps_range_start and gaps_range_end must be provided together.' + 'gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.' ); }); @@ -726,13 +727,48 @@ describe('Perform bulk action route', () => { expect(response.status).toEqual(400); expect(response.body.message).toEqual( - 'Both gaps_range_start and gaps_range_end must be provided together.' + 'gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.' + ); + }); + + it('rejects payload if gaps range is provided without gap_fill_statuses', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }, + }); + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.' + ); + }); + + it('rejects payload if gap_fill_statuses are provided without gaps range', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gap_fill_statuses: [gapFillStatus.UNFILLED], + }, + }); + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.' ); }); }); describe('gap range functionality', () => { - it('passes gap range to rules find when provided with query', async () => { + it('passes gap range and status to rules find when provided with query', async () => { const gapStartDate = '2025-01-01T00:00:00.000Z'; const gapEndDate = '2025-01-02T00:00:00.000Z'; @@ -744,6 +780,7 @@ describe('Perform bulk action route', () => { query: '', gaps_range_start: gapStartDate, gaps_range_end: gapEndDate, + gap_fill_statuses: [gapFillStatus.UNFILLED], }, }); @@ -753,7 +790,7 @@ describe('Perform bulk action route', () => { expect.objectContaining({ start: gapStartDate, end: gapEndDate, - statuses: ['unfilled', 'partially_filled'], + highestPriorityGapFillStatuses: ['unfilled'], }) ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index e6e9070303bbd..3b62b4a6f9622 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -9,7 +9,7 @@ import type { IKibanaResponse } from '@kbn/core/server'; import { AbortError } from '@kbn/kibana-utils-plugin/common'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import type { BulkActionSkipResult } from '@kbn/alerting-plugin/common'; +import type { BulkActionSkipResult, GapFillStatus } from '@kbn/alerting-plugin/common'; import type { PerformRulesBulkActionResponse } from '../../../../../../../common/api/detection_engine/rule_management'; import { BulkActionTypeEnum, @@ -75,21 +75,23 @@ const validateBulkAction = ( }; } - // Validate that ids and gap range params are not used together - if (body?.ids && (body.gaps_range_start || body.gaps_range_end)) { + const ruleExecutionGapBodyParamsSet = new Set([ + Array.isArray(body.gap_fill_statuses) && body.gap_fill_statuses.length > 0, + Boolean(body.gaps_range_start), + Boolean(body.gaps_range_end), + ]); + + if (ruleExecutionGapBodyParamsSet.size > 1) { return { - body: `Cannot use both ids and gaps_range_start/gaps_range_end in request payload.`, + body: `gaps_range_start, gaps_range_end and gap_fill_statuses must be provided together.`, statusCode: 400, }; } - // Validate that both gap range params are provided if any is used - if ( - (body.gaps_range_start && !body.gaps_range_end) || - (!body.gaps_range_start && body.gaps_range_end) - ) { + // Validate that ids and gap range params are not used together + if (body?.ids && ruleExecutionGapBodyParamsSet.has(true)) { return { - body: `Both gaps_range_start and gaps_range_end must be provided together.`, + body: `Cannot use both ids and gaps_range_start/gaps_range_end in request payload.`, statusCode: 400, }; } @@ -97,6 +99,36 @@ const validateBulkAction = ( return undefined; }; +const prepareGapParams = ({ + gapFillStatuses, + gapsRangeStart, + gapsRangeEnd, +}: { + gapFillStatuses: GapFillStatus[] | undefined; + gapsRangeStart: string | undefined; + gapsRangeEnd: string | undefined; +}): { + gapRange: { start: string; end: string } | undefined; + gapFillStatuses: GapFillStatus[] | undefined; +} => { + const hasGapStatuses = Array.isArray(gapFillStatuses) && gapFillStatuses.length > 0; + + if (gapsRangeStart && gapsRangeEnd && hasGapStatuses) { + return { + gapRange: { + start: gapsRangeStart, + end: gapsRangeEnd, + }, + gapFillStatuses, + }; + } + + return { + gapRange: undefined, + gapFillStatuses: undefined, + }; +}; + export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], @@ -128,7 +160,6 @@ export const performBulkActionRoute = ( }, }, }, - async ( context, request, @@ -187,16 +218,11 @@ export const performBulkActionRoute = ( }); const query = body.query !== '' ? body.query : undefined; - let gapRange; - - // If gap range params are present, set up the gap range parameter - if (body.gaps_range_start && body.gaps_range_end) { - gapRange = { - start: body.gaps_range_start, - end: body.gaps_range_end, - }; - } - + const gapParams = prepareGapParams({ + gapFillStatuses: body.gap_fill_statuses, + gapsRangeStart: body.gaps_range_start, + gapsRangeEnd: body.gaps_range_end, + }); const fetchRulesOutcome = await fetchRulesByQueryOrIds({ rulesClient, query, @@ -205,7 +231,8 @@ export const performBulkActionRoute = ( body.action === BulkActionTypeEnum.edit ? MAX_RULES_TO_BULK_EDIT : MAX_RULES_TO_PROCESS_TOTAL, - gapRange, + gapRange: gapParams.gapRange, + gapFillStatuses: gapParams.gapFillStatuses, }); const rules = fetchRulesOutcome.results.map(({ result }) => result); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts index 26460605c08a3..1e54fd633b37f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/find_rules/route.ts @@ -8,7 +8,6 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; -import { gapStatus } from '@kbn/alerting-plugin/common'; import { DETECTION_ENGINE_RULES_URL_FIND } from '../../../../../../../common/constants'; import type { FindRulesResponse } from '../../../../../../../common/api/detection_engine/rule_management'; import { @@ -54,12 +53,12 @@ export const findRulesRoute = (router: SecuritySolutionPluginRouter, logger: Log const rulesClient = await ctx.alerting.getRulesClient(); let ruleIds: string[] | undefined; - if (query.gaps_range_start && query.gaps_range_end) { + const gapFillStatuses = query.gap_fill_statuses; + if (Boolean(gapFillStatuses?.length) && query.gaps_range_start && query.gaps_range_end) { const ruleIdsWithGaps = await rulesClient.getRuleIdsWithGaps({ + highestPriorityGapFillStatuses: gapFillStatuses, start: query.gaps_range_start, end: query.gaps_range_end, - statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], - hasUnfilledIntervals: true, }); ruleIds = ruleIdsWithGaps.ruleIds; if (ruleIds.length === 0) { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts index 823d76c38d8ed..e76e9919347d1 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_bulk_actions/trial_license_complete_tier/perform_bulk_action.ts @@ -24,6 +24,7 @@ import type { BaseDefaultableFields } from '@kbn/security-solution-plugin/common import moment from 'moment'; import { createRule, deleteAllRules } from '@kbn/detections-response-ftr-services'; import { getGapsByRuleId } from '@kbn/detections-response-ftr-services/rules/get_gaps_by_rule_id'; +import { gapFillStatus } from '@kbn/alerting-plugin/common'; import { binaryToString, getSimpleMlRule, @@ -3399,6 +3400,7 @@ export default ({ getService }: FtrProviderContext): void => { action: BulkActionTypeEnum.duplicate, gaps_range_start: '2025-01-01T00:00:00.000Z', gaps_range_end: '2025-01-02T00:00:00.000Z', + gap_fill_statuses: [gapFillStatus.UNFILLED], duplicate: { include_exceptions: false, include_expired_exceptions: false }, });