Skip to content

Commit a4b1975

Browse files
[Security Solution][Alert details] - bring back last alert status change to flyout (#205224)
## Summary Over a year ago, [this PR](#171589) added some information to the alert details flyout, to show when an alert's status (`closed`, `open` or `aknowledged`) had been modified last and by which user. Shortly after, [this follow up PR](#172888) removed the UI from the alert details flyout, as the information wasn't extremely important and was taking some valuable vertical space, pushing down below the `Highlighted fields` section, that users were finding very important. A few months later, we added the ability to persist which of the top sections (`About`, `Investigation`, `Visualizations`, `Insights` and `Response`) were collapsed or expanded. That way the user wouldn't have to always collapse or expand sections they would often don't need. This PR brings back the alert's last status changes to the `About` section, as the vertical space is no longer a big issues, because users can now collapse the entire `About` section. #### If data is not present, the last change UI is not shown ![Screenshot 2024-12-27 at 3 46 14 PM](https://github.com/user-attachments/assets/24e033d7-fb15-496a-97be-ecf78996d243) #### If the correct data is shown: ![Screenshot 2024-12-27 at 3 50 12 PM](https://github.com/user-attachments/assets/a13f54d8-1804-4baf-a12b-5203beb4f92d) ### How to test - have a few alerts in the alerts table - open the alert details flyout for one alert and change the status (button in the header) - verify that the last status change section is shown in the `About` section ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
1 parent d4a3c96 commit a4b1975

File tree

7 files changed

+187
-0
lines changed

7 files changed

+187
-0
lines changed

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
REASON_TITLE_TEST_ID,
1717
MITRE_ATTACK_TITLE_TEST_ID,
1818
EVENT_RENDERER_TEST_ID,
19+
WORKFLOW_STATUS_TITLE_TEST_ID,
1920
} from './test_ids';
2021
import { TestProviders } from '../../../../common/mock';
2122
import { AboutSection } from './about_section';
@@ -106,6 +107,7 @@ describe('<AboutSection />', () => {
106107
expect(queryByTestId(ALERT_DESCRIPTION_TITLE_TEST_ID)).not.toBeInTheDocument();
107108
expect(queryByTestId(REASON_TITLE_TEST_ID)).not.toBeInTheDocument();
108109
expect(queryByTestId(MITRE_ATTACK_TITLE_TEST_ID)).not.toBeInTheDocument();
110+
expect(queryByTestId(WORKFLOW_STATUS_TITLE_TEST_ID)).not.toBeInTheDocument();
109111

110112
expect(getByTestId(EVENT_KIND_DESCRIPTION_TEST_ID)).toBeInTheDocument();
111113

@@ -135,6 +137,7 @@ describe('<AboutSection />', () => {
135137
expect(queryByTestId(ALERT_DESCRIPTION_TITLE_TEST_ID)).not.toBeInTheDocument();
136138
expect(queryByTestId(REASON_TITLE_TEST_ID)).not.toBeInTheDocument();
137139
expect(queryByTestId(MITRE_ATTACK_TITLE_TEST_ID)).not.toBeInTheDocument();
140+
expect(queryByTestId(WORKFLOW_STATUS_TITLE_TEST_ID)).not.toBeInTheDocument();
138141

139142
expect(queryByTestId(EVENT_KIND_DESCRIPTION_TEST_ID)).not.toBeInTheDocument();
140143

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/about_section.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { isEcsAllowedValue } from '../utils/event_utils';
2020
import { EventCategoryDescription } from './event_category_description';
2121
import { EventKindDescription } from './event_kind_description';
2222
import { EventRenderer } from './event_renderer';
23+
import { AlertStatus } from './alert_status';
2324

2425
const KEY = 'about';
2526

@@ -42,6 +43,7 @@ export const AboutSection = memo(() => {
4243
<AlertDescription />
4344
<Reason />
4445
<MitreAttack />
46+
<AlertStatus />
4547
</>
4648
) : (
4749
<>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { act, render } from '@testing-library/react';
10+
import { AlertStatus } from './alert_status';
11+
import { mockContextValue } from '../../shared/mocks/mock_context';
12+
import { DocumentDetailsContext } from '../../shared/context';
13+
import { WORKFLOW_STATUS_DETAILS_TEST_ID, WORKFLOW_STATUS_TITLE_TEST_ID } from './test_ids';
14+
import { TestProviders } from '../../../../common/mock';
15+
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
16+
17+
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');
18+
19+
const renderAlertStatus = (contextValue: DocumentDetailsContext) =>
20+
render(
21+
<TestProviders>
22+
<DocumentDetailsContext.Provider value={contextValue}>
23+
<AlertStatus />
24+
</DocumentDetailsContext.Provider>
25+
</TestProviders>
26+
);
27+
28+
const mockUserProfiles = [
29+
{ uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} },
30+
];
31+
32+
describe('<AlertStatus />', () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it('should render alert status history information', async () => {
38+
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
39+
isLoading: false,
40+
data: mockUserProfiles,
41+
});
42+
const contextValue = {
43+
...mockContextValue,
44+
getFieldsData: jest.fn().mockImplementation((field: string) => {
45+
if (field === 'kibana.alert.workflow_user') return ['user-id-1'];
46+
if (field === 'kibana.alert.workflow_status_updated_at')
47+
return ['2023-11-01T22:33:26.893Z'];
48+
}),
49+
};
50+
51+
const { getByTestId } = renderAlertStatus(contextValue);
52+
53+
await act(async () => {
54+
expect(getByTestId(WORKFLOW_STATUS_TITLE_TEST_ID)).toBeInTheDocument();
55+
expect(getByTestId(WORKFLOW_STATUS_DETAILS_TEST_ID)).toBeInTheDocument();
56+
});
57+
});
58+
59+
it('should render empty component if missing workflow_user value', async () => {
60+
const { container } = renderAlertStatus(mockContextValue);
61+
62+
await act(async () => {
63+
expect(container).toBeEmptyDOMElement();
64+
});
65+
});
66+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
9+
import { getUserDisplayName } from '@kbn/user-profile-components';
10+
import { FormattedMessage } from '@kbn/i18n-react';
11+
import React, { memo, useMemo } from 'react';
12+
import { WORKFLOW_STATUS_DETAILS_TEST_ID, WORKFLOW_STATUS_TITLE_TEST_ID } from './test_ids';
13+
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
14+
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
15+
import { useDocumentDetailsContext } from '../../shared/context';
16+
import { getField } from '../../shared/utils';
17+
18+
/**
19+
* Displays info about who last updated the alert's workflow status and when.
20+
*/
21+
export const AlertStatus = memo(() => {
22+
const { getFieldsData } = useDocumentDetailsContext();
23+
const statusUpdatedBy = getFieldsData('kibana.alert.workflow_user');
24+
const statusUpdatedAt = getField(getFieldsData('kibana.alert.workflow_status_updated_at'));
25+
26+
const result = useBulkGetUserProfiles({ uids: new Set(statusUpdatedBy) });
27+
const user = result.data?.[0]?.user;
28+
29+
const lastStatusChange = useMemo(
30+
() => (
31+
<>
32+
{user && statusUpdatedAt && (
33+
<FormattedMessage
34+
id="xpack.securitySolution.flyout.right.about.status.statusHistoryDetails"
35+
defaultMessage="Alert status updated by {user} on {date}"
36+
values={{
37+
user: getUserDisplayName(user),
38+
date: <PreferenceFormattedDate value={new Date(statusUpdatedAt)} />,
39+
}}
40+
/>
41+
)}
42+
</>
43+
),
44+
[statusUpdatedAt, user]
45+
);
46+
47+
if (!statusUpdatedBy || !statusUpdatedAt || result.isLoading || user == null) {
48+
return null;
49+
}
50+
51+
return (
52+
<EuiFlexGroup direction="column" gutterSize="s">
53+
<EuiSpacer size="xs" />
54+
<EuiFlexItem data-test-subj={WORKFLOW_STATUS_TITLE_TEST_ID}>
55+
<EuiTitle size="xxs">
56+
<h5>
57+
<FormattedMessage
58+
id="xpack.securitySolution.flyout.right.about.status.statusHistoryTitle"
59+
defaultMessage="Last alert status change"
60+
/>
61+
</h5>
62+
</EuiTitle>
63+
</EuiFlexItem>
64+
<EuiFlexItem data-test-subj={WORKFLOW_STATUS_DETAILS_TEST_ID}>{lastStatusChange}</EuiFlexItem>
65+
</EuiFlexGroup>
66+
);
67+
});
68+
69+
AlertStatus.displayName = 'AlertStatus';

x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export const MITRE_ATTACK_DETAILS_TEST_ID = `${MITRE_ATTACK_TEST_ID}Details` as
7575

7676
export const EVENT_RENDERER_TEST_ID = `${PREFIX}EventRenderer` as const;
7777

78+
export const WORKFLOW_STATUS_TEST_ID = `${PREFIX}WorkflowStatus` as const;
79+
export const WORKFLOW_STATUS_TITLE_TEST_ID = `${WORKFLOW_STATUS_TEST_ID}Title` as const;
80+
export const WORKFLOW_STATUS_DETAILS_TEST_ID = `${WORKFLOW_STATUS_TEST_ID}Details` as const;
81+
7882
/* Investigation section */
7983

8084
export const INVESTIGATION_SECTION_TEST_ID = `${PREFIX}InvestigationSection` as const;

x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel.cy.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ import { ALERTS_URL } from '../../../../urls/navigation';
6565
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
6666
import { TOASTER } from '../../../../screens/alerts_detection_rules';
6767
import { ELASTICSEARCH_USERNAME, IS_SERVERLESS } from '../../../../env_var_names_constants';
68+
import {
69+
goToAcknowledgedAlerts,
70+
goToClosedAlerts,
71+
toggleKPICharts,
72+
} from '../../../../tasks/alerts';
73+
import {
74+
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_DETAILS,
75+
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_TITLE,
76+
} from '../../../../screens/expandable_flyout/alert_details_right_panel_overview_tab';
6877

6978
// We need to use the 'soc_manager' role in order to have the 'Respond' action displayed in serverless
7079
const isServerless = Cypress.env(IS_SERVERLESS);
@@ -171,6 +180,21 @@ describe('Alert details expandable flyout right panel', { tags: ['@ess', '@serve
171180

172181
cy.get(TOASTER).should('have.text', 'Successfully marked 1 alert as acknowledged.');
173182
cy.get(EMPTY_ALERT_TABLE).should('exist');
183+
184+
// collapsing the KPI section prevents the test from being flaky, as when the KPI is expanded, the view
185+
// scrolls to the bottom of the page (for some unknown reason) and the test can't select the page filter...
186+
toggleKPICharts();
187+
goToAcknowledgedAlerts();
188+
expandAlertAtIndexExpandableFlyout();
189+
190+
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_TITLE).should(
191+
'have.text',
192+
'Last alert status change'
193+
);
194+
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_DETAILS).should(
195+
'contain.text',
196+
'Alert status updated'
197+
);
174198
});
175199

176200
it('should mark as closed', () => {
@@ -181,6 +205,21 @@ describe('Alert details expandable flyout right panel', { tags: ['@ess', '@serve
181205

182206
cy.get(TOASTER).should('have.text', 'Successfully closed 1 alert.');
183207
cy.get(EMPTY_ALERT_TABLE).should('exist');
208+
209+
// collapsing the KPI section prevents the test from being flaky, as when the KPI is expanded, the view
210+
// scrolls to the bottom of the page (for some unknown reason) and the test can't select the page filter...
211+
toggleKPICharts();
212+
goToClosedAlerts();
213+
expandAlertAtIndexExpandableFlyout();
214+
215+
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_TITLE).should(
216+
'have.text',
217+
'Last alert status change'
218+
);
219+
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_DETAILS).should(
220+
'contain.text',
221+
'Alert status updated'
222+
);
184223
});
185224

186225
// these actions are now grouped together as we're not really testing their functionality but just the existence of the option in the dropdown

x-pack/test/security_solution_cypress/cypress/screens/expandable_flyout/alert_details_right_panel_overview_tab.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_MITRE_ATTACK_TITLE = getDataTe
3636
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_MITRE_ATTACK_DETAILS = getDataTestSubjectSelector(
3737
'securitySolutionFlyoutMitreAttackDetails'
3838
);
39+
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_TITLE =
40+
getDataTestSubjectSelector('securitySolutionFlyoutWorkflowStatusTitle');
41+
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_WORKFLOW_STATUS_DETAILS =
42+
getDataTestSubjectSelector('securitySolutionFlyoutWorkflowStatusDetails');
3943

4044
/* Investigation section */
4145

0 commit comments

Comments
 (0)