From 833e13faff59b99f24cd793b932081d6cfc9d879 Mon Sep 17 00:00:00 2001
From: Candace Park <56409205+parkiino@users.noreply.github.com>
Date: Thu, 13 May 2021 09:37:56 -0400
Subject: [PATCH 01/27] [Security Solution][Endpoint][Host Isolation] Send case
ids from UI to isolate api (#99484)
---
x-pack/plugins/cases/common/api/helpers.ts | 5 ++
.../components/host_isolation/index.tsx | 58 ++++++++++++++-----
.../components/host_isolation/translations.ts | 14 +++--
.../detection_engine/alerts/__mocks__/api.ts | 17 +++++-
.../detection_engine/alerts/api.test.ts | 31 ++++++++++
.../containers/detection_engine/alerts/api.ts | 20 +++++++
.../detection_engine/alerts/mock.ts | 12 +++-
.../detection_engine/alerts/translations.ts | 5 ++
.../detection_engine/alerts/types.ts | 2 +
.../alerts/use_cases_from_alerts.test.tsx | 41 +++++++++++++
.../alerts/use_cases_from_alerts.tsx | 51 ++++++++++++++++
.../alerts/use_host_isolation.tsx | 6 +-
12 files changed, 235 insertions(+), 27 deletions(-)
create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx
diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts
index 43e292b91db4b..7ac686ce5c8dd 100644
--- a/x-pack/plugins/cases/common/api/helpers.ts
+++ b/x-pack/plugins/cases/common/api/helpers.ts
@@ -14,6 +14,7 @@ import {
SUB_CASES_URL,
CASE_PUSH_URL,
SUB_CASE_USER_ACTIONS_URL,
+ CASE_ALERTS_URL,
} from '../constants';
export const getCaseDetailsUrl = (id: string): string => {
@@ -47,3 +48,7 @@ export const getSubCaseUserActionUrl = (caseID: string, subCaseId: string): stri
export const getCasePushUrl = (caseId: string, connectorId: string): string => {
return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId);
};
+
+export const getCasesFromAlertsUrl = (alertId: string): string => {
+ return CASE_ALERTS_URL.replace('{alert_id}', alertId);
+};
diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx
index 30ee7e77f3a7d..3897458e8459c 100644
--- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx
@@ -21,7 +21,6 @@ import {
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useHostIsolation } from '../../containers/detection_engine/alerts/use_host_isolation';
-import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline';
import {
CANCEL,
CASES_ASSOCIATED_WITH_ALERT,
@@ -31,6 +30,9 @@ import {
RETURN_TO_ALERT_DETAILS,
} from './translations';
import { Maybe } from '../../../../../observability/common/typings';
+import { useCasesFromAlerts } from '../../containers/detection_engine/alerts/use_cases_from_alerts';
+import { CaseDetailsLink } from '../../../common/components/links';
+import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
export const HostIsolationPanel = React.memo(
({
@@ -59,7 +61,13 @@ export const HostIsolationPanel = React.memo(
return findAlertRule ? findAlertRule[0] : '';
}, [details]);
- const { loading, isolateHost } = useHostIsolation({ agentId, comment });
+ const alertId = useMemo(() => {
+ const findAlertId = find({ category: '_id', field: '_id' }, details)?.values;
+ return findAlertId ? findAlertId[0] : '';
+ }, [details]);
+
+ const { caseIds } = useCasesFromAlerts({ alertId });
+ const { loading, isolateHost } = useHostIsolation({ agentId, comment, caseIds });
const confirmHostIsolation = useCallback(async () => {
const hostIsolated = await isolateHost();
@@ -68,8 +76,25 @@ export const HostIsolationPanel = React.memo(
const backToAlertDetails = useCallback(() => cancelCallback(), [cancelCallback]);
- // a placeholder until we get the case count returned from a new case route in a future pr
- const caseCount: number = 0;
+ const casesList = useMemo(
+ () =>
+ caseIds.map((id, index) => {
+ return (
+
+
+
+
+
+ );
+ }),
+ [caseIds]
+ );
+
+ const caseCount: number = useMemo(() => caseIds.length, [caseIds]);
const hostIsolated = useMemo(() => {
return (
@@ -92,20 +117,13 @@ export const HostIsolationPanel = React.memo(
-
+
>
)}
@@ -121,7 +139,7 @@ export const HostIsolationPanel = React.memo(
>
);
- }, [backToAlertDetails, hostName]);
+ }, [backToAlertDetails, hostName, caseCount, casesList]);
const hostNotIsolated = useMemo(() => {
return (
@@ -137,7 +155,7 @@ export const HostIsolationPanel = React.memo(
cases: (
{caseCount}
- {CASES_ASSOCIATED_WITH_ALERT}
+ {CASES_ASSOCIATED_WITH_ALERT(caseCount)}
{alertRule}
),
@@ -171,7 +189,15 @@ export const HostIsolationPanel = React.memo(
>
);
- }, [alertRule, backToAlertDetails, comment, confirmHostIsolation, hostName, loading]);
+ }, [
+ alertRule,
+ backToAlertDetails,
+ comment,
+ confirmHostIsolation,
+ hostName,
+ loading,
+ caseCount,
+ ]);
return isIsolated ? hostIsolated : hostNotIsolated;
}
diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts
index 97a1a278952a6..8d6334f6c340d 100644
--- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts
@@ -31,12 +31,14 @@ export const CONFIRM = i18n.translate('xpack.securitySolution.endpoint.hostIsola
defaultMessage: 'Confirm',
});
-export const CASES_ASSOCIATED_WITH_ALERT = i18n.translate(
- 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWihtAlert',
- {
- defaultMessage: ' cases associated with the rule ',
- }
-);
+export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string =>
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert',
+ {
+ defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ',
+ values: { caseCount },
+ }
+ );
export const RETURN_TO_ALERT_DETAILS = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.returnToAlertDetails',
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts
index e1f5b53e2f4c3..ea64f39226cd2 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts
@@ -5,8 +5,15 @@
* 2.0.
*/
-import { QueryAlerts, AlertSearchResponse, BasicSignals, AlertsIndex, Privilege } from '../types';
-import { alertsMock, mockSignalIndex, mockUserPrivilege } from '../mock';
+import {
+ QueryAlerts,
+ AlertSearchResponse,
+ BasicSignals,
+ AlertsIndex,
+ Privilege,
+ CasesFromAlertsResponse,
+} from '../types';
+import { alertsMock, mockSignalIndex, mockUserPrivilege, mockCaseIdsFromAlertId } from '../mock';
export const fetchQueryAlerts = async ({
query,
@@ -22,3 +29,9 @@ export const getUserPrivilege = async ({ signal }: BasicSignals): Promise =>
Promise.resolve(mockSignalIndex);
+
+export const getCaseIdsFromAlertId = async ({
+ alertId,
+}: {
+ alertId: string;
+}): Promise => Promise.resolve(mockCaseIdsFromAlertId);
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts
index 82f275f7dc9ba..9aa5cfd229292 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts
@@ -12,6 +12,7 @@ import {
mockStatusAlertQuery,
mockSignalIndex,
mockUserPrivilege,
+ mockHostIsolation,
} from './mock';
import {
fetchQueryAlerts,
@@ -19,6 +20,7 @@ import {
getSignalIndex,
getUserPrivilege,
createSignalIndex,
+ createHostIsolation,
} from './api';
const abortCtrl = new AbortController();
@@ -163,4 +165,33 @@ describe('Detections Alerts API', () => {
expect(alertsResp).toEqual(mockSignalIndex);
});
});
+
+ describe('createHostIsolation', () => {
+ beforeEach(() => {
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue(mockHostIsolation);
+ });
+
+ test('check parameter url', async () => {
+ await createHostIsolation({
+ agentId: 'fd8a122b-4c54-4c05-b295-e5f8381fc59d',
+ comment: 'commento',
+ caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'],
+ });
+ expect(fetchMock).toHaveBeenCalledWith('/api/endpoint/isolate', {
+ method: 'POST',
+ body:
+ '{"agent_ids":["fd8a122b-4c54-4c05-b295-e5f8381fc59d"],"comment":"commento","case_ids":["88c04a90-b19c-11eb-b838-bf3c7840b969"]}',
+ });
+ });
+
+ test('happy path', async () => {
+ const hostIsolationResponse = await createHostIsolation({
+ agentId: 'fd8a122b-4c54-4c05-b295-e5f8381fc59d',
+ comment: 'commento',
+ caseIds: ['88c04a90-b19c-11eb-b838-bf3c7840b969'],
+ });
+ expect(hostIsolationResponse).toEqual(mockHostIsolation);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts
index dbcb11383432f..300005b23caaa 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts
@@ -6,6 +6,7 @@
*/
import { UpdateDocumentByQueryResponse } from 'elasticsearch';
+import { getCasesFromAlertsUrl } from '../../../../../../cases/common';
import { HostIsolationResponse } from '../../../../../common/endpoint/types';
import {
DETECTION_ENGINE_QUERY_SIGNALS_URL,
@@ -22,6 +23,7 @@ import {
AlertSearchResponse,
AlertsIndex,
UpdateAlertStatusProps,
+ CasesFromAlertsResponse,
} from './types';
/**
@@ -109,20 +111,38 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise =>
KibanaServices.get().http.fetch(ISOLATE_HOST_ROUTE, {
method: 'POST',
body: JSON.stringify({
agent_ids: [agentId],
comment,
+ case_ids: caseIds,
}),
});
+
+/**
+ * Get list of associated case ids from alert id
+ *
+ * @param alert id
+ */
+export const getCaseIdsFromAlertId = async ({
+ alertId,
+}: {
+ alertId: string;
+}): Promise =>
+ KibanaServices.get().http.fetch(getCasesFromAlertsUrl(alertId), {
+ method: 'get',
+ });
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts
index 18651063df8ca..69358958a395c 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts
@@ -5,7 +5,8 @@
* 2.0.
*/
-import { AlertSearchResponse, AlertsIndex, Privilege } from './types';
+import { HostIsolationResponse } from '../../../../../common/endpoint/types/actions';
+import { AlertSearchResponse, AlertsIndex, Privilege, CasesFromAlertsResponse } from './types';
export const alertsMock: AlertSearchResponse = {
took: 7,
@@ -1039,3 +1040,12 @@ export const mockUserPrivilege: Privilege = {
is_authenticated: true,
has_encryption_key: true,
};
+
+export const mockHostIsolation: HostIsolationResponse = {
+ action: '713085d6-ab45-4e9e-b41d-96563cafdd97',
+};
+
+export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [
+ '818601a0-b26b-11eb-8759-6b318e8cf4bc',
+ '8a774850-b26b-11eb-8759-6b318e8cf4bc',
+];
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts
index 2998c97376c26..ed6a22375a776 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts
@@ -32,3 +32,8 @@ export const HOST_ISOLATION_FAILURE = i18n.translate(
'xpack.securitySolution.endpoint.hostIsolation.failedToIsolate.title',
{ defaultMessage: 'Failed to isolate host' }
);
+
+export const CASES_FROM_ALERTS_FAILURE = i18n.translate(
+ 'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title',
+ { defaultMessage: 'Failed to find associated cases' }
+);
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts
index 26108ca939a57..52b477d95076b 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts
@@ -48,6 +48,8 @@ export interface AlertsIndex {
index_mapping_outdated: boolean;
}
+export type CasesFromAlertsResponse = string[];
+
export interface Privilege {
username: string;
has_all_requested: boolean;
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx
new file mode 100644
index 0000000000000..0867fb001051a
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+import { useCasesFromAlerts } from './use_cases_from_alerts';
+import * as api from './api';
+import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
+import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
+import { mockCaseIdsFromAlertId } from './mock';
+
+jest.mock('./api');
+jest.mock('../../../../common/hooks/use_app_toasts');
+
+describe('useCasesFromAlerts hook', () => {
+ let appToastsMock: jest.Mocked>;
+ beforeEach(() => {
+ jest.resetAllMocks();
+ appToastsMock = useAppToastsMock.create();
+ (useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
+ });
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('returns an array of caseIds', async () => {
+ const spyOnCases = jest.spyOn(api, 'getCaseIdsFromAlertId');
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCasesFromAlerts({ alertId: 'anAlertId' })
+ );
+ await waitForNextUpdate();
+ expect(spyOnCases).toHaveBeenCalledTimes(1);
+ expect(result.current).toEqual({
+ loading: false,
+ caseIds: mockCaseIdsFromAlertId,
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx
new file mode 100644
index 0000000000000..fb130eb744700
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { isEmpty } from 'lodash';
+import { useEffect, useState } from 'react';
+import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
+import { getCaseIdsFromAlertId } from './api';
+import { CASES_FROM_ALERTS_FAILURE } from './translations';
+import { CasesFromAlertsResponse } from './types';
+
+interface CasesFromAlertsStatus {
+ loading: boolean;
+ caseIds: CasesFromAlertsResponse;
+}
+
+export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromAlertsStatus => {
+ const [loading, setLoading] = useState(false);
+ const [cases, setCases] = useState([]);
+ const { addError } = useAppToasts();
+
+ useEffect(() => {
+ // isMounted tracks if a component is mounted before changing state
+ let isMounted = true;
+ setLoading(true);
+ const fetchData = async () => {
+ try {
+ const casesResponse = await getCaseIdsFromAlertId({ alertId });
+ if (isMounted) {
+ setCases(casesResponse);
+ }
+ } catch (error) {
+ addError(error.message, { title: CASES_FROM_ALERTS_FAILURE });
+ }
+ if (isMounted) {
+ setLoading(false);
+ }
+ };
+ if (!isEmpty(alertId)) {
+ fetchData();
+ }
+ return () => {
+ // updates to show component is unmounted
+ isMounted = false;
+ };
+ }, [alertId, addError]);
+ return { loading, caseIds: cases };
+};
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx
index 684bc6af5d2c7..ad3c6e91c03fe 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation.tsx
@@ -18,11 +18,13 @@ interface HostIsolationStatus {
interface UseHostIsolationProps {
agentId: string;
comment: string;
+ caseIds?: string[];
}
export const useHostIsolation = ({
agentId,
comment,
+ caseIds,
}: UseHostIsolationProps): HostIsolationStatus => {
const [loading, setLoading] = useState(false);
const { addError } = useAppToasts();
@@ -30,7 +32,7 @@ export const useHostIsolation = ({
const isolateHost = useCallback(async () => {
try {
setLoading(true);
- const isolationStatus = await createHostIsolation({ agentId, comment });
+ const isolationStatus = await createHostIsolation({ agentId, comment, caseIds });
setLoading(false);
return isolationStatus.action ? true : false;
} catch (error) {
@@ -38,6 +40,6 @@ export const useHostIsolation = ({
addError(error.message, { title: HOST_ISOLATION_FAILURE });
return false;
}
- }, [agentId, comment, addError]);
+ }, [agentId, comment, caseIds, addError]);
return { loading, isolateHost };
};
From 6e66415b7e143c59a8400b51b2163d7411d7f981 Mon Sep 17 00:00:00 2001
From: Gloria Hornero
Date: Thu, 13 May 2021 16:04:59 +0200
Subject: [PATCH 02/27] [Security Solution] [Detections] Adds Preview results
for threshold rules (#99350)
* adds Preview results for threshold rules
* fixes typo
* adds pipe to remove flakyness
* fixes typecheck issue
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../detection_rules/threshold_rule.spec.ts | 27 ++++++++++++++---
.../cypress/screens/create_new_rule.ts | 2 ++
.../cypress/tasks/create_new_rule.ts | 30 ++++++++++++++++++-
3 files changed, 54 insertions(+), 5 deletions(-)
diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts
index f9971dfc0f791..7c09b311807be 100644
--- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts
+++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts
@@ -6,7 +6,7 @@
*/
import { formatMitreAttackDescription } from '../../helpers/rules';
-import { indexPatterns, newThresholdRule } from '../../objects/rule';
+import { indexPatterns, newRule, newThresholdRule } from '../../objects/rule';
import {
ALERT_RULE_METHOD,
@@ -26,6 +26,7 @@ import {
RULES_TABLE,
SEVERITY,
} from '../../screens/alerts_detection_rules';
+import { PREVIEW_HEADER_SUBTITLE } from '../../screens/create_new_rule';
import {
ABOUT_DETAILS,
ABOUT_INVESTIGATION_NOTES,
@@ -64,13 +65,16 @@ import {
goToRuleDetails,
waitForRulesTableToBeLoaded,
} from '../../tasks/alerts_detection_rules';
+import { createCustomRuleActivated } from '../../tasks/api_calls/rules';
import { createTimeline } from '../../tasks/api_calls/timelines';
import { cleanKibana } from '../../tasks/common';
import {
createAndActivateRule,
fillAboutRuleAndContinue,
fillDefineThresholdRuleAndContinue,
+ fillDefineThresholdRule,
fillScheduleRuleAndContinue,
+ previewResults,
selectThresholdRuleType,
waitForAlertsToPopulate,
waitForTheRuleToBeExecuted,
@@ -92,12 +96,12 @@ describe('Detection rules, threshold', () => {
createTimeline(newThresholdRule.timeline).then((response) => {
rule.timeline.id = response.body.data.persistTimeline.timeline.savedObjectId;
});
- });
-
- it('Creates and activates a new threshold rule', () => {
loginAndWaitForPageWithoutDateRange(DETECTIONS_URL);
waitForAlertsPanelToBeLoaded();
waitForAlertsIndexToBeCreated();
+ });
+
+ it('Creates and activates a new threshold rule', () => {
goToManageAlertsDetectionRules();
waitForRulesTableToBeLoaded();
goToCreateNewRule();
@@ -175,4 +179,19 @@ describe('Detection rules, threshold', () => {
cy.get(ALERT_RULE_SEVERITY).first().should('have.text', rule.severity.toLowerCase());
cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', rule.riskScore);
});
+
+ it('Preview results', () => {
+ const previewRule = { ...newThresholdRule };
+ previewRule.index!.push('.siem-signals*');
+
+ createCustomRuleActivated(newRule);
+ goToManageAlertsDetectionRules();
+ waitForRulesTableToBeLoaded();
+ goToCreateNewRule();
+ selectThresholdRuleType();
+ fillDefineThresholdRule(previewRule);
+ previewResults();
+
+ cy.get(PREVIEW_HEADER_SUBTITLE).should('have.text', '3 unique hits');
+ });
});
diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
index db8d93dfbbef9..a580068b636e4 100644
--- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts
@@ -127,6 +127,8 @@ export const MITRE_ATTACK_ADD_TECHNIQUE_BUTTON = '[data-test-subj="addMitreAttac
export const MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON = '[data-test-subj="addMitreAttackSubtechnique"]';
+export const PREVIEW_HEADER_SUBTITLE = '[data-test-subj="header-panel-subtitle"]';
+
export const QUERY_PREVIEW_BUTTON = '[data-test-subj="queryPreviewButton"]';
export const REFERENCE_URLS_INPUT =
diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
index cd342e9456906..9c15b1f03932d 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
@@ -260,12 +260,18 @@ export const fillScheduleRuleAndContinue = (rule: CustomRule | MachineLearningRu
cy.get(LOOK_BACK_TIME_TYPE).select(rule.lookBack.timeType);
};
-export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => {
+export const fillDefineThresholdRule = (rule: ThresholdRule) => {
const thresholdField = 0;
const threshold = 1;
cy.get(IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK).click();
cy.get(TIMELINE(rule.timeline.id!)).click();
+ cy.get(COMBO_BOX_CLEAR_BTN).click();
+
+ rule.index!.forEach((index) => {
+ cy.get(COMBO_BOX_INPUT).first().type(`${index}{enter}`);
+ });
+
cy.get(CUSTOM_QUERY_INPUT).should('have.value', rule.customQuery);
cy.get(THRESHOLD_INPUT_AREA)
.find(INPUT)
@@ -274,6 +280,24 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => {
cy.get(THRESHOLD_FIELD_SELECTION).click({ force: true });
cy.wrap(inputs[threshold]).clear().type(rule.threshold);
});
+};
+
+export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => {
+ const thresholdField = 0;
+ const threshold = 1;
+
+ const typeThresholdField = ($el: Cypress.ObjectLike) => cy.wrap($el).type(rule.thresholdField);
+
+ cy.get(IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK).click();
+ cy.get(TIMELINE(rule.timeline.id!)).click();
+ cy.get(CUSTOM_QUERY_INPUT).should('have.value', rule.customQuery);
+ cy.get(THRESHOLD_INPUT_AREA)
+ .find(INPUT)
+ .then((inputs) => {
+ cy.wrap(inputs[thresholdField]).pipe(typeThresholdField);
+ cy.get(THRESHOLD_FIELD_SELECTION).click({ force: true });
+ cy.wrap(inputs[threshold]).clear().type(rule.threshold);
+ });
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
cy.get(CUSTOM_QUERY_INPUT).should('not.exist');
@@ -478,6 +502,10 @@ export const selectThresholdRuleType = () => {
cy.get(THRESHOLD_TYPE).click({ force: true });
};
+export const previewResults = () => {
+ cy.get(QUERY_PREVIEW_BUTTON).click();
+};
+
export const waitForAlertsToPopulate = async (alertCountThreshold = 1) => {
cy.waitUntil(
() => {
From 49020d804475bdd61778357531fd82c5a8247452 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?=
Date: Thu, 13 May 2021 16:16:26 +0200
Subject: [PATCH 03/27] [SO Migrations v2] Log doc-count progress (#99865)
---
...kibana-plugin-core-public.doclinksstart.md | 2 -
.../migrationsv2/actions/index.ts | 16 +-
.../saved_objects/migrationsv2/model.test.ts | 98 +++++++++++-
.../saved_objects/migrationsv2/model.ts | 34 +++++
.../migrationsv2/progress.test.ts | 140 ++++++++++++++++++
.../saved_objects/migrationsv2/progress.ts | 74 +++++++++
.../saved_objects/migrationsv2/types.ts | 11 ++
7 files changed, 369 insertions(+), 6 deletions(-)
create mode 100644 src/core/server/saved_objects/migrationsv2/progress.test.ts
create mode 100644 src/core/server/saved_objects/migrationsv2/progress.ts
diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md
index ac625095da2a4..78d2d8daa3d45 100644
--- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md
+++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md
@@ -17,7 +17,5 @@ export interface DocLinksStart
| --- | --- | --- |
| [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string
| |
| [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string
| |
-
| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly canvas: {
readonly guide: string;
};
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly overview: string;
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly search: {
readonly sessions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly upgradeAssistant: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly kueryQuerySyntax: string;
readonly luceneQuerySyntax: string;
readonly percolate: string;
readonly queryDsl: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createRollupJobsRequest: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putIndexTemplateV1: string;
putWatch: string;
simulatePipeline: string;
timeUnits: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
}
| |
-
diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts
index d0623de51e4c3..c2e0476960c3b 100644
--- a/src/core/server/saved_objects/migrationsv2/actions/index.ts
+++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts
@@ -456,6 +456,7 @@ export const openPit = (
export interface ReadWithPit {
outdatedDocuments: SavedObjectsRawDoc[];
readonly lastHitSortValue: number[] | undefined;
+ readonly totalHits: number | undefined;
}
/*
@@ -481,13 +482,20 @@ export const readWithPit = (
pit: { id: pitId, keep_alive: pitKeepAlive },
size: batchSize,
search_after: searchAfter,
- // Improve performance by not calculating the total number of hits
- // matching the query.
- track_total_hits: false,
+ /**
+ * We want to know how many documents we need to process so we can log the progress.
+ * But we also want to increase the performance of these requests,
+ * so we ask ES to report the total count only on the first request (when searchAfter does not exist)
+ */
+ track_total_hits: typeof searchAfter === 'undefined',
query,
},
})
.then((response) => {
+ const totalHits =
+ typeof response.body.hits.total === 'number'
+ ? response.body.hits.total // This format is to be removed in 8.0
+ : response.body.hits.total?.value;
const hits = response.body.hits.hits;
if (hits.length > 0) {
@@ -495,12 +503,14 @@ export const readWithPit = (
// @ts-expect-error @elastic/elasticsearch _source is optional
outdatedDocuments: hits as SavedObjectsRawDoc[],
lastHitSortValue: hits[hits.length - 1].sort as number[],
+ totalHits,
});
}
return Either.right({
outdatedDocuments: [],
lastHitSortValue: undefined,
+ totalHits,
});
})
.catch(catchRetryableEsClientErrors);
diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts
index bdaedba9c9ea3..adeb78e568af3 100644
--- a/src/core/server/saved_objects/migrationsv2/model.test.ts
+++ b/src/core/server/saved_objects/migrationsv2/model.test.ts
@@ -45,6 +45,7 @@ import { createInitialState, model } from './model';
import { ResponseType } from './next';
import { SavedObjectsMigrationConfigType } from '../saved_objects_config';
import { TransformErrorObjects, TransformSavedObjectDocumentError } from '../migrations/core';
+import { createInitialProgress } from './progress';
describe('migrations v2 model', () => {
const baseState: BaseState = {
@@ -768,6 +769,8 @@ describe('migrations v2 model', () => {
expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_READ');
expect(newState.sourceIndexPitId).toBe('pit_id');
expect(newState.lastHitSortValue).toBe(undefined);
+ expect(newState.progress.processed).toBe(undefined);
+ expect(newState.progress.total).toBe(undefined);
});
});
@@ -783,6 +786,7 @@ describe('migrations v2 model', () => {
lastHitSortValue: undefined,
corruptDocumentIds: [],
transformErrors: [],
+ progress: createInitialProgress(),
};
it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_INDEX if the index has outdated documents to reindex', () => {
@@ -791,21 +795,34 @@ describe('migrations v2 model', () => {
const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({
outdatedDocuments,
lastHitSortValue,
+ totalHits: 1,
});
const newState = model(state, res) as ReindexSourceToTempIndex;
expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_INDEX');
expect(newState.outdatedDocuments).toBe(outdatedDocuments);
expect(newState.lastHitSortValue).toBe(lastHitSortValue);
+ expect(newState.progress.processed).toBe(undefined);
+ expect(newState.progress.total).toBe(1);
+ expect(newState.logs).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "level": "info",
+ "message": "Starting to process 1 documents.",
+ },
+ ]
+ `);
});
it('REINDEX_SOURCE_TO_TEMP_READ -> REINDEX_SOURCE_TO_TEMP_CLOSE_PIT if no outdated documents to reindex', () => {
const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({
outdatedDocuments: [],
lastHitSortValue: undefined,
+ totalHits: undefined,
});
const newState = model(state, res) as ReindexSourceToTempClosePit;
expect(newState.controlState).toBe('REINDEX_SOURCE_TO_TEMP_CLOSE_PIT');
expect(newState.sourceIndexPitId).toBe('pit_id');
+ expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
it('REINDEX_SOURCE_TO_TEMP_READ -> FATAL if no outdated documents to reindex and transform failures seen with previous outdated documents', () => {
@@ -817,12 +834,14 @@ describe('migrations v2 model', () => {
const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_READ'> = Either.right({
outdatedDocuments: [],
lastHitSortValue: undefined,
+ totalHits: undefined,
});
const newState = model(testState, res) as FatalState;
expect(newState.controlState).toBe('FATAL');
expect(newState.reason).toMatchInlineSnapshot(
`"Migrations failed. Reason: Corrupt saved object documents: a:b. To allow migrations to proceed, please delete these documents."`
);
+ expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
});
@@ -857,6 +876,7 @@ describe('migrations v2 model', () => {
lastHitSortValue: undefined,
corruptDocumentIds: [],
transformErrors: [],
+ progress: { processed: undefined, total: 1 },
};
const processedDocs = [
{
@@ -869,8 +889,28 @@ describe('migrations v2 model', () => {
const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right({
processedDocs,
});
- const newState = model(state, res);
+ const newState = model(state, res) as ReindexSourceToTempIndexBulk;
+ expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_INDEX_BULK');
+ expect(newState.progress.processed).toBe(0); // Result of `(undefined ?? 0) + corruptDocumentsId.length`
+ });
+
+ it('increments the progress.processed counter', () => {
+ const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX'> = Either.right({
+ processedDocs,
+ });
+
+ const testState = {
+ ...state,
+ outdatedDocuments: [{ _id: '1', _source: { type: 'vis' } }],
+ progress: {
+ processed: 1,
+ total: 1,
+ },
+ };
+
+ const newState = model(testState, res) as ReindexSourceToTempIndexBulk;
expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_INDEX_BULK');
+ expect(newState.progress.processed).toBe(2);
});
it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded but we have carried through previous failures', () => {
@@ -886,6 +926,7 @@ describe('migrations v2 model', () => {
expect(newState.controlState).toEqual('REINDEX_SOURCE_TO_TEMP_READ');
expect(newState.corruptDocumentIds.length).toEqual(1);
expect(newState.transformErrors.length).toEqual(0);
+ expect(newState.progress.processed).toBe(0);
});
it('REINDEX_SOURCE_TO_TEMP_INDEX -> REINDEX_SOURCE_TO_TEMP_READ when response is left documents_transform_failed', () => {
@@ -918,6 +959,7 @@ describe('migrations v2 model', () => {
sourceIndexPitId: 'pit_id',
targetIndex: '.kibana_7.11.0_001',
lastHitSortValue: undefined,
+ progress: createInitialProgress(),
};
test('REINDEX_SOURCE_TO_TEMP_INDEX_BULK -> REINDEX_SOURCE_TO_TEMP_READ if action succeeded', () => {
const res: ResponseType<'REINDEX_SOURCE_TO_TEMP_INDEX_BULK'> = Either.right(
@@ -1018,6 +1060,7 @@ describe('migrations v2 model', () => {
hasTransformedDocs: false,
corruptDocumentIds: [],
transformErrors: [],
+ progress: createInitialProgress(),
};
it('OUTDATED_DOCUMENTS_SEARCH_READ -> OUTDATED_DOCUMENTS_TRANSFORM if found documents to transform', () => {
@@ -1026,21 +1069,65 @@ describe('migrations v2 model', () => {
const res: ResponseType<'OUTDATED_DOCUMENTS_SEARCH_READ'> = Either.right({
outdatedDocuments,
lastHitSortValue,
+ totalHits: 10,
});
const newState = model(state, res) as OutdatedDocumentsTransform;
expect(newState.controlState).toBe('OUTDATED_DOCUMENTS_TRANSFORM');
expect(newState.outdatedDocuments).toBe(outdatedDocuments);
expect(newState.lastHitSortValue).toBe(lastHitSortValue);
+ expect(newState.progress.processed).toBe(undefined);
+ expect(newState.progress.total).toBe(10);
+ expect(newState.logs).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "level": "info",
+ "message": "Starting to process 10 documents.",
+ },
+ ]
+ `);
+ });
+
+ it('keeps the previous progress.total if not obtained in the result', () => {
+ const outdatedDocuments = [{ _id: '1', _source: { type: 'vis' } }];
+ const lastHitSortValue = [123456];
+ const res: ResponseType<'OUTDATED_DOCUMENTS_SEARCH_READ'> = Either.right({
+ outdatedDocuments,
+ lastHitSortValue,
+ totalHits: undefined,
+ });
+ const testState = {
+ ...state,
+ progress: {
+ processed: 5,
+ total: 10,
+ },
+ };
+ const newState = model(testState, res) as OutdatedDocumentsTransform;
+ expect(newState.controlState).toBe('OUTDATED_DOCUMENTS_TRANSFORM');
+ expect(newState.outdatedDocuments).toBe(outdatedDocuments);
+ expect(newState.lastHitSortValue).toBe(lastHitSortValue);
+ expect(newState.progress.processed).toBe(5);
+ expect(newState.progress.total).toBe(10);
+ expect(newState.logs).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "level": "info",
+ "message": "Processed 5 documents out of 10.",
+ },
+ ]
+ `);
});
it('OUTDATED_DOCUMENTS_SEARCH_READ -> OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT if no outdated documents to transform', () => {
const res: ResponseType<'OUTDATED_DOCUMENTS_SEARCH_READ'> = Either.right({
outdatedDocuments: [],
lastHitSortValue: undefined,
+ totalHits: undefined,
});
const newState = model(state, res) as OutdatedDocumentsSearchClosePit;
expect(newState.controlState).toBe('OUTDATED_DOCUMENTS_SEARCH_CLOSE_PIT');
expect(newState.pitId).toBe('pit_id');
+ expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
it('OUTDATED_DOCUMENTS_SEARCH_READ -> FATAL if no outdated documents to transform and we have failed document migrations', () => {
@@ -1060,6 +1147,7 @@ describe('migrations v2 model', () => {
const res: ResponseType<'OUTDATED_DOCUMENTS_SEARCH_READ'> = Either.right({
outdatedDocuments: [],
lastHitSortValue: undefined,
+ totalHits: undefined,
});
const transformErrorsState: OutdatedDocumentsSearchRead = {
...state,
@@ -1072,6 +1160,7 @@ describe('migrations v2 model', () => {
expect(newState.reason.includes('Corrupt saved object documents: ')).toBe(true);
expect(newState.reason.includes('Transformation errors: ')).toBe(true);
expect(newState.reason.includes('randomvis: 7.12.0')).toBe(true);
+ expect(newState.logs).toStrictEqual([]); // No logs because no hits
});
});
@@ -1138,6 +1227,7 @@ describe('migrations v2 model', () => {
pitId: 'pit_id',
lastHitSortValue: [3, 4],
hasTransformedDocs: false,
+ progress: createInitialProgress(),
};
describe('OUTDATED_DOCUMENTS_TRANSFORM if action succeeds', () => {
const processedDocs = [
@@ -1156,6 +1246,7 @@ describe('migrations v2 model', () => {
expect(newState.transformedDocs).toEqual(processedDocs);
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
+ expect(newState.progress.processed).toBe(outdatedDocuments.length);
});
test('OUTDATED_DOCUMENTS_TRANSFORM -> OUTDATED_DOCUMENTS_SEARCH_READ if there are are existing documents that failed transformation', () => {
const outdatedDocumentsTransformStateWithFailedDocuments: OutdatedDocumentsTransform = {
@@ -1172,6 +1263,7 @@ describe('migrations v2 model', () => {
expect(newState.corruptDocumentIds).toEqual(corruptDocumentIds);
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
+ expect(newState.progress.processed).toBe(outdatedDocuments.length);
});
test('OUTDATED_DOCUMENTS_TRANSFORM -> OUTDATED_DOCUMENTS_SEARCH_READ if there are are existing documents that failed transformation because of transform errors', () => {
const outdatedDocumentsTransformStateWithFailedDocuments: OutdatedDocumentsTransform = {
@@ -1189,6 +1281,7 @@ describe('migrations v2 model', () => {
expect(newState.transformErrors.length).toEqual(1);
expect(newState.retryCount).toEqual(0);
expect(newState.retryDelay).toEqual(0);
+ expect(newState.progress.processed).toBe(outdatedDocuments.length);
});
});
describe('OUTDATED_DOCUMENTS_TRANSFORM if action fails', () => {
@@ -1204,6 +1297,7 @@ describe('migrations v2 model', () => {
) as OutdatedDocumentsSearchRead;
expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH_READ');
expect(newState.corruptDocumentIds).toEqual(corruptDocumentIds);
+ expect(newState.progress.processed).toBe(outdatedDocuments.length);
});
test('OUTDATED_DOCUMENTS_TRANSFORM -> OUTDATED_DOCUMENTS_SEARCH_READ combines newly failed documents with those already on state if documents failed the transform', () => {
const newFailedTransformDocumentIds = ['b:other', 'c:__'];
@@ -1226,6 +1320,7 @@ describe('migrations v2 model', () => {
...corruptDocumentIds,
...newFailedTransformDocumentIds,
]);
+ expect(newState.progress.processed).toBe(outdatedDocuments.length);
});
});
});
@@ -1246,6 +1341,7 @@ describe('migrations v2 model', () => {
pitId: 'pit_id',
lastHitSortValue: [3, 4],
hasTransformedDocs: false,
+ progress: createInitialProgress(),
};
test('TRANSFORMED_DOCUMENTS_BULK_INDEX should throw a throwBadResponse error if action failed', () => {
const res: ResponseType<'TRANSFORMED_DOCUMENTS_BULK_INDEX'> = Either.left({
diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts
index cf9d6aec6b5b0..3ef3cb4f83b6f 100644
--- a/src/core/server/saved_objects/migrationsv2/model.ts
+++ b/src/core/server/saved_objects/migrationsv2/model.ts
@@ -18,6 +18,12 @@ import { SavedObjectsMigrationVersion } from '../types';
import { disableUnknownTypeMappingFields } from '../migrations/core/migration_context';
import { excludeUnusedTypesQuery, TransformErrorObjects } from '../migrations/core';
import { SavedObjectsMigrationConfigType } from '../saved_objects_config';
+import {
+ createInitialProgress,
+ incrementProcessedProgress,
+ logProgress,
+ setProgressTotal,
+} from './progress';
/**
* A helper function/type for ensuring that all control state's are handled.
@@ -509,6 +515,7 @@ export const model = (currentState: State, resW: ResponseType):
// placeholders to collect document transform problems
corruptDocumentIds: [],
transformErrors: [],
+ progress: createInitialProgress(),
};
} else {
throwBadResponse(stateP, res);
@@ -517,12 +524,16 @@ export const model = (currentState: State, resW: ResponseType):
// we carry through any failures we've seen with transforming documents on state
const res = resW as ExcludeRetryableEsError>;
if (Either.isRight(res)) {
+ const progress = setProgressTotal(stateP.progress, res.right.totalHits);
+ const logs = logProgress(stateP.logs, progress);
if (res.right.outdatedDocuments.length > 0) {
return {
...stateP,
controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX',
outdatedDocuments: res.right.outdatedDocuments,
lastHitSortValue: res.right.lastHitSortValue,
+ progress,
+ logs,
};
} else {
// we don't have any more outdated documents and need to either fail or move on to updating the target mappings.
@@ -542,6 +553,7 @@ export const model = (currentState: State, resW: ResponseType):
return {
...stateP,
controlState: 'REINDEX_SOURCE_TO_TEMP_CLOSE_PIT',
+ logs,
};
}
}
@@ -566,12 +578,18 @@ export const model = (currentState: State, resW: ResponseType):
// collecting issues along the way rather than failing
// REINDEX_SOURCE_TO_TEMP_INDEX handles the document transforms
const res = resW as ExcludeRetryableEsError>;
+
+ // Increment the processed documents, no matter what the results are.
+ // Otherwise the progress might look off when there are errors.
+ const progress = incrementProcessedProgress(stateP.progress, stateP.outdatedDocuments.length);
+
if (Either.isRight(res)) {
if (stateP.corruptDocumentIds.length === 0 && stateP.transformErrors.length === 0) {
return {
...stateP,
controlState: 'REINDEX_SOURCE_TO_TEMP_INDEX_BULK', // handles the actual bulk indexing into temp index
transformedDocs: [...res.right.processedDocs],
+ progress,
};
} else {
// we don't have any transform issues with the current batch of outdated docs but
@@ -581,6 +599,7 @@ export const model = (currentState: State, resW: ResponseType):
return {
...stateP,
controlState: 'REINDEX_SOURCE_TO_TEMP_READ',
+ progress,
};
}
} else {
@@ -592,6 +611,7 @@ export const model = (currentState: State, resW: ResponseType):
controlState: 'REINDEX_SOURCE_TO_TEMP_READ',
corruptDocumentIds: [...stateP.corruptDocumentIds, ...left.corruptDocumentIds],
transformErrors: [...stateP.transformErrors, ...left.transformErrors],
+ progress,
};
} else {
// should never happen
@@ -676,6 +696,7 @@ export const model = (currentState: State, resW: ResponseType):
controlState: 'OUTDATED_DOCUMENTS_SEARCH_READ',
pitId: res.right.pitId,
lastHitSortValue: undefined,
+ progress: createInitialProgress(),
hasTransformedDocs: false,
corruptDocumentIds: [],
transformErrors: [],
@@ -687,11 +708,16 @@ export const model = (currentState: State, resW: ResponseType):
const res = resW as ExcludeRetryableEsError>;
if (Either.isRight(res)) {
if (res.right.outdatedDocuments.length > 0) {
+ const progress = setProgressTotal(stateP.progress, res.right.totalHits);
+ const logs = logProgress(stateP.logs, progress);
+
return {
...stateP,
controlState: 'OUTDATED_DOCUMENTS_TRANSFORM',
outdatedDocuments: res.right.outdatedDocuments,
lastHitSortValue: res.right.lastHitSortValue,
+ progress,
+ logs,
};
} else {
// we don't have any more outdated documents and need to either fail or move on to updating the target mappings.
@@ -720,6 +746,11 @@ export const model = (currentState: State, resW: ResponseType):
}
} else if (stateP.controlState === 'OUTDATED_DOCUMENTS_TRANSFORM') {
const res = resW as ExcludeRetryableEsError>;
+
+ // Increment the processed documents, no matter what the results are.
+ // Otherwise the progress might look off when there are errors.
+ const progress = incrementProcessedProgress(stateP.progress, stateP.outdatedDocuments.length);
+
if (Either.isRight(res)) {
// we haven't seen corrupt documents or any transformation errors thus far in the migration
// index the migrated docs
@@ -729,6 +760,7 @@ export const model = (currentState: State, resW: ResponseType):
controlState: 'TRANSFORMED_DOCUMENTS_BULK_INDEX',
transformedDocs: [...res.right.processedDocs],
hasTransformedDocs: true,
+ progress,
};
} else {
// We have seen corrupt documents and/or transformation errors
@@ -736,6 +768,7 @@ export const model = (currentState: State, resW: ResponseType):
return {
...stateP,
controlState: 'OUTDATED_DOCUMENTS_SEARCH_READ',
+ progress,
};
}
} else {
@@ -747,6 +780,7 @@ export const model = (currentState: State, resW: ResponseType):
corruptDocumentIds: [...stateP.corruptDocumentIds, ...res.left.corruptDocumentIds],
transformErrors: [...stateP.transformErrors, ...res.left.transformErrors],
hasTransformedDocs: false,
+ progress,
};
} else {
throwBadResponse(stateP, res as never);
diff --git a/src/core/server/saved_objects/migrationsv2/progress.test.ts b/src/core/server/saved_objects/migrationsv2/progress.test.ts
new file mode 100644
index 0000000000000..a0d89c2c63300
--- /dev/null
+++ b/src/core/server/saved_objects/migrationsv2/progress.test.ts
@@ -0,0 +1,140 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { MigrationLog } from './types';
+import {
+ createInitialProgress,
+ incrementProcessedProgress,
+ logProgress,
+ setProgressTotal,
+} from './progress';
+
+describe('createInitialProgress', () => {
+ test('create initial progress', () => {
+ expect(createInitialProgress()).toStrictEqual({
+ processed: undefined,
+ total: undefined,
+ });
+ });
+});
+
+describe('setProgressTotal', () => {
+ const previousProgress = {
+ processed: undefined,
+ total: 10,
+ };
+ test('should keep the previous total if not provided', () => {
+ expect(setProgressTotal(previousProgress)).toStrictEqual(previousProgress);
+ });
+
+ test('should keep the previous total is undefined', () => {
+ expect(setProgressTotal(previousProgress, undefined)).toStrictEqual(previousProgress);
+ });
+
+ test('should overwrite if the previous total is provided', () => {
+ expect(setProgressTotal(previousProgress, 20)).toStrictEqual({
+ processed: undefined,
+ total: 20,
+ });
+ });
+});
+
+describe('logProgress', () => {
+ const previousLogs: MigrationLog[] = [];
+
+ test('should not log anything if there is no total', () => {
+ const progress = {
+ processed: undefined,
+ total: undefined,
+ };
+ expect(logProgress(previousLogs, progress)).toStrictEqual([]);
+ });
+
+ test('should not log anything if total is 0', () => {
+ const progress = {
+ processed: undefined,
+ total: 0,
+ };
+ expect(logProgress(previousLogs, progress)).toStrictEqual([]);
+ });
+
+ test('should log the "Starting..." log', () => {
+ const progress = {
+ processed: undefined,
+ total: 10,
+ };
+ expect(logProgress(previousLogs, progress)).toStrictEqual([
+ {
+ level: 'info',
+ message: 'Starting to process 10 documents.',
+ },
+ ]);
+ });
+
+ test('should log the "Processed..." log', () => {
+ const progress = {
+ processed: 5,
+ total: 10,
+ };
+ expect(logProgress(previousLogs, progress)).toStrictEqual([
+ {
+ level: 'info',
+ message: 'Processed 5 documents out of 10.',
+ },
+ ]);
+ });
+});
+
+describe('incrementProcessedProgress', () => {
+ const previousProgress = {
+ processed: undefined,
+ total: 10,
+ };
+ test('should not increment if the incrementValue is not defined', () => {
+ expect(incrementProcessedProgress(previousProgress)).toStrictEqual({
+ processed: 0,
+ total: 10,
+ });
+ });
+
+ test('should not increment if the incrementValue is undefined', () => {
+ expect(incrementProcessedProgress(previousProgress, undefined)).toStrictEqual({
+ processed: 0,
+ total: 10,
+ });
+ });
+
+ test('should not increment if the incrementValue is not defined (with some processed values)', () => {
+ const testPreviousProgress = {
+ ...previousProgress,
+ processed: 1,
+ };
+ expect(incrementProcessedProgress(testPreviousProgress, undefined)).toStrictEqual({
+ processed: 1,
+ total: 10,
+ });
+ });
+
+ test('should increment if the incrementValue is defined', () => {
+ expect(incrementProcessedProgress(previousProgress, 5)).toStrictEqual({
+ processed: 5,
+ total: 10,
+ });
+ });
+
+ test('should increment if the incrementValue is defined (with some processed values)', () => {
+ const testPreviousProgress = {
+ ...previousProgress,
+ processed: 5,
+ };
+ expect(incrementProcessedProgress(testPreviousProgress, 5)).toStrictEqual({
+ processed: 10,
+ total: 10,
+ });
+ });
+});
diff --git a/src/core/server/saved_objects/migrationsv2/progress.ts b/src/core/server/saved_objects/migrationsv2/progress.ts
new file mode 100644
index 0000000000000..d626cd6528902
--- /dev/null
+++ b/src/core/server/saved_objects/migrationsv2/progress.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { MigrationLog, Progress } from './types';
+
+/**
+ * Returns an initial state of the progress object (everything undefined)
+ */
+export function createInitialProgress(): Progress {
+ return {
+ processed: undefined,
+ total: undefined,
+ };
+}
+
+/**
+ * Overwrites the total of the progress if anything provided
+ * @param previousProgress
+ * @param total
+ */
+export function setProgressTotal(
+ previousProgress: Progress,
+ total = previousProgress.total
+): Progress {
+ return {
+ ...previousProgress,
+ total,
+ };
+}
+
+/**
+ * Returns a new list of MigrationLogs with the info entry about the progress
+ * @param previousLogs
+ * @param progress
+ */
+export function logProgress(previousLogs: MigrationLog[], progress: Progress): MigrationLog[] {
+ const logs = [...previousLogs];
+
+ if (progress.total) {
+ if (typeof progress.processed === 'undefined') {
+ logs.push({
+ level: 'info',
+ message: `Starting to process ${progress.total} documents.`,
+ });
+ } else {
+ logs.push({
+ level: 'info',
+ message: `Processed ${progress.processed} documents out of ${progress.total}.`,
+ });
+ }
+ }
+
+ return logs;
+}
+
+/**
+ * Increments the processed count and returns a new Progress
+ * @param previousProgress Previous state of the progress
+ * @param incrementProcessedBy Amount to increase the processed count by
+ */
+export function incrementProcessedProgress(
+ previousProgress: Progress,
+ incrementProcessedBy = 0
+): Progress {
+ return {
+ ...previousProgress,
+ processed: (previousProgress.processed ?? 0) + incrementProcessedBy,
+ };
+}
diff --git a/src/core/server/saved_objects/migrationsv2/types.ts b/src/core/server/saved_objects/migrationsv2/types.ts
index f5800a3cd9570..e3e52212d56cb 100644
--- a/src/core/server/saved_objects/migrationsv2/types.ts
+++ b/src/core/server/saved_objects/migrationsv2/types.ts
@@ -26,6 +26,11 @@ export interface MigrationLog {
message: string;
}
+export interface Progress {
+ processed: number | undefined;
+ total: number | undefined;
+}
+
export interface BaseState extends ControlState {
/** The first part of the index name such as `.kibana` or `.kibana_task_manager` */
readonly indexPrefix: string;
@@ -183,6 +188,7 @@ export interface ReindexSourceToTempRead extends PostInitState {
readonly lastHitSortValue: number[] | undefined;
readonly corruptDocumentIds: string[];
readonly transformErrors: TransformErrorObjects[];
+ readonly progress: Progress;
}
export interface ReindexSourceToTempClosePit extends PostInitState {
@@ -197,6 +203,7 @@ export interface ReindexSourceToTempIndex extends PostInitState {
readonly lastHitSortValue: number[] | undefined;
readonly corruptDocumentIds: string[];
readonly transformErrors: TransformErrorObjects[];
+ readonly progress: Progress;
}
export interface ReindexSourceToTempIndexBulk extends PostInitState {
@@ -204,6 +211,7 @@ export interface ReindexSourceToTempIndexBulk extends PostInitState {
readonly transformedDocs: SavedObjectsRawDoc[];
readonly sourceIndexPitId: string;
readonly lastHitSortValue: number[] | undefined;
+ readonly progress: Progress;
}
export type SetTempWriteBlock = PostInitState & {
@@ -252,6 +260,7 @@ export interface OutdatedDocumentsSearchRead extends PostInitState {
readonly hasTransformedDocs: boolean;
readonly corruptDocumentIds: string[];
readonly transformErrors: TransformErrorObjects[];
+ readonly progress: Progress;
}
export interface OutdatedDocumentsSearchClosePit extends PostInitState {
@@ -276,6 +285,7 @@ export interface OutdatedDocumentsTransform extends PostInitState {
readonly hasTransformedDocs: boolean;
readonly corruptDocumentIds: string[];
readonly transformErrors: TransformErrorObjects[];
+ readonly progress: Progress;
}
export interface TransformedDocumentsBulkIndex extends PostInitState {
/**
@@ -286,6 +296,7 @@ export interface TransformedDocumentsBulkIndex extends PostInitState {
readonly lastHitSortValue: number[] | undefined;
readonly hasTransformedDocs: boolean;
readonly pitId: string;
+ readonly progress: Progress;
}
export interface MarkVersionIndexReady extends PostInitState {
From ee9d469295d80598ec1ce8142869987812fd9597 Mon Sep 17 00:00:00 2001
From: Thomas Neirynck
Date: Thu, 13 May 2021 10:39:37 -0400
Subject: [PATCH 04/27] [RFC][Maps] Adding a timeslider to Maps (#98355)
Accepted. Additional notes in the github issue.
---
rfcs/images/timeslider/toolbar.png | Bin 0 -> 9372 bytes
rfcs/images/timeslider/v1.png | Bin 0 -> 935400 bytes
rfcs/images/timeslider/v2.png | Bin 0 -> 147212 bytes
rfcs/text/0018_timeslider.md | 217 +++++++++++++++++++++++++++++
4 files changed, 217 insertions(+)
create mode 100644 rfcs/images/timeslider/toolbar.png
create mode 100644 rfcs/images/timeslider/v1.png
create mode 100644 rfcs/images/timeslider/v2.png
create mode 100644 rfcs/text/0018_timeslider.md
diff --git a/rfcs/images/timeslider/toolbar.png b/rfcs/images/timeslider/toolbar.png
new file mode 100644
index 0000000000000000000000000000000000000000..3dae2af0d77f72ad4e9912bdfbbf184a39dd557f
GIT binary patch
literal 9372
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
q?=JhHua2BqK*Dd7bQYu#iK#lZ=Ii
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