-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[EDR Workflows][Artifact transfer 4] UI fine tuning #257983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
11fcb94
922df3e
b6383db
108c06f
832d5c5
fc3315f
d2ae717
1f53c55
ee8d864
1918628
20b0f1d
90047a5
e4be5ce
200bdd0
365d6b9
2d116ed
393e008
43c007a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the Elastic License | ||
| * 2.0; you may not use this file except in compliance with the Elastic License | ||
| * 2.0. | ||
| */ | ||
|
|
||
| /** | ||
| * Helper function to extract list ids from an imported file. Does not check whether | ||
| * the file is valid, just tries to find list_id fields, so it can be used on UI side | ||
| * as a pre-check to ensure only the correct artifact type is being imported. | ||
| * | ||
| * @param file {File} file to extract list ids from | ||
| * @returns {Promise<Set<string>>} set of list ids found in the file | ||
| */ | ||
| export const parseListIdsFromImportedFile = async (file: File): Promise<Set<string>> => | ||
| (await file.text()) | ||
| .split('\n') | ||
| .filter((x) => x.trim() !== '') | ||
| .reduce((acc, line) => { | ||
| try { | ||
| const parsedItem = JSON.parse(line); | ||
|
|
||
| if (parsedItem.list_id) { | ||
| acc.add(parsedItem.list_id); | ||
| } | ||
| } catch (e) { | ||
| // ignore parsing errors, the API will handle them and return an error for the line | ||
| } | ||
|
|
||
| return acc; | ||
| }, new Set<string>()); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,10 +34,15 @@ import type { | |
| BulkErrorSchema, | ||
| ImportExceptionsResponseSchema, | ||
| } from '@kbn/securitysolution-io-ts-list-types'; | ||
| import { ENDPOINT_ARTIFACT_LISTS } from '@kbn/securitysolution-list-constants'; | ||
| import { | ||
| ENDPOINT_ARTIFACT_LIST_IDS, | ||
| ENDPOINT_ARTIFACT_LISTS, | ||
| } from '@kbn/securitysolution-list-constants'; | ||
| import type { HttpSetup } from '@kbn/core-http-browser'; | ||
| import type { ToastInput, Toast, ErrorToastOptions } from '@kbn/core-notifications-browser'; | ||
|
|
||
| import { parseListIdsFromImportedFile } from '../../../common/utils/exception_list_items'; | ||
| import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; | ||
| import { useImportExceptionList } from '../../hooks/use_import_exception_list'; | ||
|
|
||
| import * as i18n from '../../translations'; | ||
|
|
@@ -65,6 +70,9 @@ export const ImportExceptionListFlyout = React.memo( | |
| const [asNewList, setAsNewList] = useState(false); | ||
| const [alreadyExistingItem, setAlreadyExistingItem] = useState(false); | ||
| const [endpointListImporting, setEndpointListImporting] = useState(false); | ||
| const isEndpointExceptionsMovedFFEnabled = useIsExperimentalFeatureEnabled( | ||
| 'endpointExceptionsMovedUnderManagement' | ||
| ); | ||
|
|
||
| const resetForm = useCallback(() => { | ||
| if (filePickerRef.current?.fileInput) { | ||
|
|
@@ -80,8 +88,21 @@ export const ImportExceptionListFlyout = React.memo( | |
| const { start: importExceptionList, ...importExceptionListState } = useImportExceptionList(); | ||
| const ctrl = useRef(new AbortController()); | ||
|
|
||
| const handleImportExceptionList = useCallback(() => { | ||
| const handleImportExceptionList = useCallback(async () => { | ||
| if (!importExceptionListState.loading && files) { | ||
| if (isEndpointExceptionsMovedFFEnabled) { | ||
| for (const file of Array.from(files)) { | ||
| const listIds = await parseListIdsFromImportedFile(file); | ||
|
|
||
| if (ENDPOINT_ARTIFACT_LIST_IDS.some((id) => listIds.has(id))) { | ||
| addError(new Error(i18n.IMPORT_ENDPOINT_ARTIFACTS_ERROR_TEXT), { | ||
| title: i18n.UPLOAD_ERROR, | ||
| }); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| ctrl.current = new AbortController(); | ||
|
|
||
| Array.from(files).forEach((file) => | ||
|
|
@@ -95,7 +116,16 @@ export const ImportExceptionListFlyout = React.memo( | |
| }) | ||
| ); | ||
| } | ||
| }, [asNewList, files, http, importExceptionList, importExceptionListState.loading, overwrite]); | ||
| }, [ | ||
| importExceptionListState.loading, | ||
| files, | ||
| isEndpointExceptionsMovedFFEnabled, | ||
| addError, | ||
| importExceptionList, | ||
| http, | ||
| overwrite, | ||
| asNewList, | ||
| ]); | ||
|
|
||
| const handleImportSuccess = useCallback( | ||
| (response: ImportExceptionsResponseSchema) => { | ||
|
|
@@ -137,6 +167,7 @@ export const ImportExceptionListFlyout = React.memo( | |
| if (err.error.message.includes('already exists')) { | ||
| setAlreadyExistingItem(true); | ||
| if ( | ||
| !isEndpointExceptionsMovedFFEnabled && | ||
| err.error.message.includes( | ||
| `Found that list_id: "${ENDPOINT_ARTIFACT_LISTS.endpointExceptions.id}" already exists` | ||
| ) | ||
|
|
@@ -157,11 +188,23 @@ export const ImportExceptionListFlyout = React.memo( | |
| importExceptionListState.loading, | ||
| importExceptionListState?.result, | ||
| importExceptionListState?.result?.errors, | ||
| isEndpointExceptionsMovedFFEnabled, | ||
| ]); | ||
|
|
||
| const handleFileChange = useCallback((inputFiles: FileList | null) => { | ||
| setFiles(inputFiles ?? null); | ||
| }, []); | ||
|
|
||
| const handleNewListCheckboxChange = useCallback(() => { | ||
| setAsNewList((prev) => !prev); | ||
| setOverwrite(false); | ||
| }, []); | ||
|
|
||
| const handleOverwriteCheckboxChange = useCallback((): void => { | ||
| setOverwrite((prev) => !prev); | ||
| setAsNewList(false); | ||
| }, []); | ||
|
|
||
| const importExceptionListFlyoutTitleId = useGeneratedHtmlId({ | ||
| prefix: 'importExceptionListFlyoutTitle', | ||
| }); | ||
|
|
@@ -200,27 +243,31 @@ export const ImportExceptionListFlyout = React.memo( | |
| label={i18n.IMPORT_EXCEPTION_LIST_OVERWRITE} | ||
| checked={overwrite} | ||
| data-test-subj="importExceptionListOverwriteExistingCheckbox" | ||
| onChange={(e) => { | ||
| setOverwrite(!overwrite); | ||
| setAsNewList(false); | ||
| }} | ||
| onChange={handleOverwriteCheckboxChange} | ||
| /> | ||
| <EuiToolTip | ||
| position="bottom" | ||
| content={endpointListImporting ? i18n.IMPORT_EXCEPTION_ENDPOINT_LIST_WARNING : ''} | ||
| > | ||
| {isEndpointExceptionsMovedFFEnabled ? ( | ||
| <EuiCheckbox | ||
| id={'createNewListCheckbox'} | ||
| label={i18n.IMPORT_EXCEPTION_LIST_AS_NEW_LIST} | ||
| data-test-subj="importExceptionListCreateNewCheckbox" | ||
| checked={asNewList} | ||
| disabled={endpointListImporting} | ||
| onChange={(e) => { | ||
| setAsNewList(!asNewList); | ||
| setOverwrite(false); | ||
| }} | ||
| onChange={handleNewListCheckboxChange} | ||
| /> | ||
| </EuiToolTip> | ||
| ) : ( | ||
| <EuiToolTip | ||
| position="bottom" | ||
| content={endpointListImporting ? i18n.IMPORT_EXCEPTION_ENDPOINT_LIST_WARNING : ''} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI: I find it so confusing to use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i sort of agree, but this file (and a lot of others not under the 'management' folder) uses this approach 🤷 |
||
| > | ||
| <EuiCheckbox | ||
| id={'createNewListCheckbox'} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is ok here only because this component will only ever be displayed on the page once, but normally - you would want to assign HTML |
||
| label={i18n.IMPORT_EXCEPTION_LIST_AS_NEW_LIST} | ||
| data-test-subj="importExceptionListCreateNewCheckbox" | ||
| checked={asNewList} | ||
| disabled={endpointListImporting} | ||
| onChange={handleNewListCheckboxChange} | ||
| /> | ||
| </EuiToolTip> | ||
| )} | ||
| </> | ||
| )} | ||
| </EuiFlyoutBody> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the Elastic License | ||
| * 2.0; you may not use this file except in compliance with the Elastic License | ||
| * 2.0. | ||
| */ | ||
| import React from 'react'; | ||
| import { i18n } from '@kbn/i18n'; | ||
| import { | ||
| EuiButton, | ||
| EuiButtonEmpty, | ||
| EuiModal, | ||
| EuiModalBody, | ||
| EuiModalFooter, | ||
| EuiModalHeader, | ||
| EuiModalHeaderTitle, | ||
| EuiText, | ||
| useGeneratedHtmlId, | ||
| } from '@elastic/eui'; | ||
| import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; | ||
|
|
||
| interface ArtifactImportConfirmModalProps { | ||
| onCancel: () => void; | ||
| onConfirm: () => void; | ||
| isLoading: boolean; | ||
| 'data-test-subj'?: string; | ||
| } | ||
|
|
||
| export const ArtifactImportConfirmModal: React.FC<ArtifactImportConfirmModalProps> = ({ | ||
| onCancel, | ||
| onConfirm, | ||
| isLoading, | ||
| 'data-test-subj': dataTestSubj = 'artifactImportConfirmModal', | ||
| }) => { | ||
| const getTestId = useTestIdGenerator(dataTestSubj); | ||
| const modalTitleId = useGeneratedHtmlId(); | ||
|
|
||
| return ( | ||
| <EuiModal onClose={onCancel} aria-labelledby={modalTitleId} data-test-subj={getTestId()}> | ||
| <EuiModalHeader> | ||
| <EuiModalHeaderTitle id={modalTitleId}> | ||
| {i18n.translate('xpack.securitySolution.artifactListPage.importConfirmModal.title', { | ||
| defaultMessage: 'Import artifacts?', | ||
| })} | ||
| </EuiModalHeaderTitle> | ||
| </EuiModalHeader> | ||
| <EuiModalBody> | ||
| <EuiText> | ||
| <p> | ||
| {i18n.translate('xpack.securitySolution.artifactListPage.importConfirmModal.info', { | ||
| defaultMessage: | ||
| "This will add new artifacts to your list. If an artifact you're importing already exists, the existing version will be kept, and the import of that artifact will be skipped.", | ||
| })} | ||
| </p> | ||
| </EuiText> | ||
| </EuiModalBody> | ||
| <EuiModalFooter> | ||
| <EuiButtonEmpty onClick={onCancel} data-test-subj={getTestId('cancelButton')}> | ||
| {i18n.translate( | ||
| 'xpack.securitySolution.artifactListPage.importConfirmModal.cancelButtonTitle', | ||
| { defaultMessage: 'Cancel' } | ||
| )} | ||
| </EuiButtonEmpty> | ||
|
|
||
| <EuiButton | ||
| fill | ||
| color="primary" | ||
| onClick={onConfirm} | ||
| isLoading={isLoading} | ||
| data-test-subj={getTestId('confirmButton')} | ||
| > | ||
| {i18n.translate( | ||
| 'xpack.securitySolution.artifactListPage.importConfirmModal.confirmButtonTitle', | ||
| { defaultMessage: 'Import' } | ||
| )} | ||
| </EuiButton> | ||
| </EuiModalFooter> | ||
| </EuiModal> | ||
| ); | ||
| }; | ||
|
|
||
| ArtifactImportConfirmModal.displayName = 'ArtifactImportConfirmModal'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a good idea? To load the entire file into memory in the browser?
My suggestions if you really do need to go through the file in the browser:
File#stream()AbortController? so that if the user navigates away from the page while this is ongoing, you can efficiently abort?IMO - this type of check should happen on the server at the API level and not in the browser. Files could be large
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
on browser vs server: I agree, however, as we only have one API for both shared exception list pages and endpoint artifact pages, and that API now accepts shared exception lists and endpoint exceptions, blocking endpoint exceptions on it (e.g. by adding a query parameter) would be a breaking change. I added this quick check to avoid that.
on performance: as far as i understood, on modern browsers this shouldn't be an issue. import API allows a maximum of 10k items to be imported, one item is 600-800 byte, depending on the type, so calculating with 1kB per item is still 10MB, not much.
created a test file 13k items, sized 8.6MB, it took 30-45ms on my oldish macbook both in Chrome and Firefox, with the following result anyway:

so for now, I think we should be safe with this approach on this feature, that's probably not used extensively in daily routine of the users, without adding more complexity by using streams