/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import React from 'react'; import { dump, load } from 'js-yaml'; import { EuiAccordion, EuiToolTip, EuiButtonIcon, EuiTitle, EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldText, EuiComboBox, EuiPanel, EuiRadioGroup, EuiTextArea, EuiButton, EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, EuiFilePicker, EuiButtonEmpty, EuiCallOut, } from '@elastic/eui'; import _ from 'lodash'; import { validateCondition, validateDetectionFieldName } from '../../../../utils/validation'; import { SelectionExpField } from './components/SelectionExpField'; export interface DetectionVisualEditorProps { detectionYml: string; onChange: (value: string) => void; goToYamlEditor: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; mode?: string; isInvalid?: boolean; } interface Errors { fields: { [key: string]: string }; touched: { [key: string]: boolean }; } interface DetectionVisualEditorState { detectionObj: DetectionObject; fileUploadModalState?: { selectionIdx: number; dataIdx: number; }; errors: Errors; invalidFile: boolean; } interface SelectionData { field: string; modifier?: string; values: string[]; selectedRadioId?: string; } export interface Selection { name: string; data: SelectionData[]; } interface DetectionObject { condition: string; selections: Selection[]; } enum SelectionMapValueRadioId { VALUE = 'selection-map-value', LIST = 'selection-map-list', } const detectionModifierOptions = [ { value: 'contains', label: 'contains' }, { value: 'all', label: 'all' }, { value: 'base64', label: 'base64' }, { value: 'endswith', label: 'endswith' }, { value: 'startswith', label: 'startswith' }, ]; const defaultDetectionObj: DetectionObject = { condition: '', selections: [ { name: 'Selection_1', data: [ { field: '', values: [''], modifier: detectionModifierOptions[0].value, }, ], }, ], }; const ONE_MEGA_BYTE = 1048576; //Bytes export class DetectionVisualEditor extends React.Component< DetectionVisualEditorProps, DetectionVisualEditorState > { /** * Text area editor row height * @private */ private textareaRowHeight = 25; /** * Text area editor empty space to occupy before filling in the editor * @private */ private textareaEmptySpace = 40; constructor(props: DetectionVisualEditorProps) { super(props); this.state = { detectionObj: this.parseDetectionYml(), errors: { fields: {}, touched: {}, }, invalidFile: false, }; } public componentDidUpdate( prevProps: Readonly, prevState: Readonly, snapshot?: any ): void { if (prevState.detectionObj !== this.state.detectionObj) { this.props.onChange(this.createDetectionYml()); } const isValid = !!Object.keys(this.state.errors.fields).length || !this.validateValuesExist(); this.props.setIsDetectionInvalid(isValid); if (this.props.isInvalid != prevProps.isInvalid) { this.validateCondition(this.state.detectionObj.condition); this.validateData(this.state.detectionObj.selections); } } private validateValuesExist() { return !this.state.detectionObj.selections.some((selection) => { return selection.data.some((datum) => !datum.values[0]); }); } private parseDetectionYml = (): DetectionObject => { const detectionJSON: any = load(this.props.detectionYml); const detectionObj: DetectionObject = { ...defaultDetectionObj, }; if (!detectionJSON) { return detectionObj; } detectionObj.condition = detectionJSON.condition ?? detectionObj.condition; detectionObj.selections = []; delete detectionJSON.condition; Object.keys(detectionJSON).forEach((selectionKey, selectionIdx) => { const selectionMapJSON = detectionJSON[selectionKey]; const selectionDataEntries: SelectionData[] = []; Object.keys(selectionMapJSON).forEach((fieldKey, dataIdx) => { const [field, modifier] = fieldKey.split('|'); const val = selectionMapJSON[fieldKey]; const values: any[] = typeof val === 'string' ? [val] : val; selectionDataEntries.push({ field, modifier, values, selectedRadioId: `${ values.length <= 1 ? SelectionMapValueRadioId.VALUE : SelectionMapValueRadioId.LIST }-${selectionIdx}-${dataIdx}`, }); }); detectionObj.selections.push({ name: selectionKey, data: selectionDataEntries, }); }); return detectionObj; }; private createDetectionYml = (): string => { const { condition, selections } = this.state.detectionObj; const compiledDetection: any = { condition, }; selections.forEach((selection) => { const selectionMaps: any = {}; selection.data.forEach((datum) => { const key = `${datum.field}${datum.modifier ? `|${datum.modifier}` : ''}`; selectionMaps[key] = datum.values; }); compiledDetection[selection.name] = selectionMaps; }); return dump(compiledDetection); }; private validateData = (selections: Selection[]) => { const { errors } = this.state; selections.map((selection, selIdx) => { const fieldNames = new Set(); selection.data.map((data, idx) => { if ('field' in data) { const fieldName = `field_${selIdx}_${idx}`; delete errors.fields[fieldName]; if (!data.field) { errors.fields[fieldName] = 'Key name is required'; } else if (fieldNames.has(data.field)) { errors.fields[fieldName] = 'Key name already used'; } else { fieldNames.add(data.field); if (!validateDetectionFieldName(data.field)) { errors.fields[fieldName] = 'Invalid key name.'; } } errors.touched[fieldName] = true; } if ('values' in data) { const valueId = `value_${selIdx}_${idx}`; delete errors.fields[valueId]; if (data.values.length === 1 && !data.values[0]) { errors.fields[valueId] = 'Value is required'; } errors.touched[valueId] = true; } }); }); this.setState({ errors, }); }; private updateDatumInState = ( selectionIdx: number, dataIdx: number, newDatum: Partial ) => { const { condition, selections } = this.state.detectionObj; const selection = selections[selectionIdx]; const datum = selection.data[dataIdx]; const newSelections = [ ...selections.slice(0, selectionIdx), { ...selection, data: [ ...selection.data.slice(0, dataIdx), { ...datum, ...newDatum, }, ...selection.data.slice(dataIdx + 1), ], }, ...selections.slice(selectionIdx + 1), ]; this.setState( { detectionObj: { condition, selections: newSelections, }, }, () => { this.validateData(newSelections); } ); }; private updateSelection = (selectionIdx: number, newSelection: Partial) => { const { condition, selections } = this.state.detectionObj; const { errors } = this.state; const selection = selections[selectionIdx]; delete errors.fields['name']; if (!selection.name) { errors.fields['name'] = 'Selection name is required'; } else { if (!validateDetectionFieldName(selection.name)) { errors.fields['name'] = 'Invalid selection name.'; } else { selections.map((sel, selIdx) => { if (selIdx !== selectionIdx && sel.name === newSelection.name) { errors.fields['name'] = 'Selection name already exists.'; } }); } } errors.touched['name'] = true; this.setState( { detectionObj: { condition, selections: [ ...selections.slice(0, selectionIdx), { ...selection, ...newSelection, }, ...selections.slice(selectionIdx + 1), ], }, errors, }, () => { if (newSelection.name !== undefined) { this.updateCondition(condition); } } ); }; private validateCondition = (value: string) => { const { errors, detectionObj: { selections }, } = this.state; value = value.trim(); delete errors.fields['condition']; if (!value) { errors.fields['condition'] = 'Condition is required'; } else { if (!validateCondition(value)) { errors.fields['condition'] = 'Invalid condition.'; } else { const selectionNames = _.map(selections, 'name'); const conditions = _.pull(value.split(' '), ...['and', 'or', 'not']); conditions.map((selection) => { if (_.indexOf(selectionNames, selection) === -1) { errors.fields[ 'condition' ] = `Invalid selection name ${selection}. Allowed names: "${selectionNames.join( ', ' )}"`; } }); } } errors.touched['condition'] = true; this.setState({ errors, }); }; private updateCondition = (value: string) => { value = value.trim(); const detectionObj: DetectionObject = { ...this.state.detectionObj, condition: value }; this.setState( { detectionObj, }, () => { this.validateCondition(value); } ); }; private csvStringToArray = ( csvString: string, delimiter: string = ',', numOfColumnsToReturn: number = 1 ): string[] => { const rows = csvString.split('\n'); return rows .map((row) => (!_.isEmpty(row) ? row.split(delimiter, numOfColumnsToReturn) : [])) .flat(); }; private onFileUpload = (files: any, selectionIdx: number, dataIdx: number) => { if ( files[0]?.size <= ONE_MEGA_BYTE && (files[0]?.type === 'text/csv' || files[0]?.type === 'text/plain') ) { let reader = new FileReader(); reader.readAsText(files[0]); reader.onload = () => { try { const textContent = reader.result; if (typeof textContent === 'string') { const parsedContent = files[0]?.type === 'text/csv' ? this.csvStringToArray(textContent) : textContent.split('\n'); this.updateDatumInState(selectionIdx, dataIdx, { values: parsedContent, }); } this.setState({ invalidFile: false, }); } catch (error: any) { } finally { this.setState({ fileUploadModalState: undefined }); } }; } else { this.setState({ invalidFile: true, }); } }; private closeFileUploadModal = () => { this.setState({ fileUploadModalState: undefined, invalidFile: false, }); }; private createRadioGroupOptions = (selectionIdx: number, datumIdx: number) => { return [ { id: `${SelectionMapValueRadioId.VALUE}-${selectionIdx}-${datumIdx}`, label: 'Value', }, { id: `${SelectionMapValueRadioId.LIST}-${selectionIdx}-${datumIdx}`, label: 'List', }, ]; }; private getTextareaHeight = (rowNo: number = 0) => { return `${rowNo * this.textareaRowHeight + this.textareaEmptySpace}px`; }; render() { const { detectionObj: { condition, selections }, fileUploadModalState, errors = { touched: {}, fields: {}, }, } = this.state; return (
{selections.map((selection, selectionIdx) => { return (
this.updateSelection(selectionIdx, { name: e.target.value }) } onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} value={selection.name} />

Define the search identifier in your data the rule will be applied to.

{selections.length > 1 && ( { const newSelections = [...selections]; newSelections.splice(selectionIdx, 1); this.setState( { detectionObj: { condition, selections: newSelections, }, }, () => this.updateCondition(condition) ); }} /> )}
{selection.data.map((datum, idx) => { const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx); const fieldName = `field_${selectionIdx}_${idx}`; const valueId = `value_${selectionIdx}_${idx}`; return (
Map {idx + 1}} extraAction={ selection.data.length > 1 ? ( { const newData = [...selection.data]; newData.splice(idx, 1); this.updateSelection(selectionIdx, { data: newData }); }} /> ) : null } style={{ maxWidth: '70%' }} > Key} > this.updateDatumInState(selectionIdx, idx, { field: e.target.value, }) } onBlur={(e) => this.updateDatumInState(selectionIdx, idx, { field: e.target.value, }) } value={datum.field} /> Modifier}> { this.updateDatumInState(selectionIdx, idx, { modifier: e[0].value, }); }} onBlur={() => {}} selectedOptions={ datum.modifier ? [{ value: datum.modifier, label: datum.modifier }] : [detectionModifierOptions[0]] } /> { this.updateDatumInState(selectionIdx, idx, { selectedRadioId: id as SelectionMapValueRadioId, }); }} /> {datum.selectedRadioId?.includes('list') ? ( <> { this.setState({ fileUploadModalState: { selectionIdx, dataIdx: idx, }, }); }} > Upload file { this.updateDatumInState(selectionIdx, idx, { values: [], }); }} > Clear list { const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { values, }); }} onBlur={(e) => { const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { values, }); }} value={datum.values.join('\n')} compressed={true} isInvalid={errors.touched[valueId] && !!errors.fields[valueId]} /> ) : ( { this.updateDatumInState(selectionIdx, idx, { values: [e.target.value, ...datum.values.slice(1)], }); }} onBlur={(e) => { this.updateDatumInState(selectionIdx, idx, { values: [e.target.value, ...datum.values.slice(1)], }); }} value={datum.values[0]} /> )}
); })} { const newData = [ ...selection.data, { ...defaultDetectionObj.selections[0].data[0] }, ]; this.updateSelection(selectionIdx, { data: newData }); }} > Add map
{selections.length > 1 && selections.length !== selectionIdx ? ( ) : null}
); })} { this.setState({ detectionObj: { condition, selections: [ ...selections, { ...defaultDetectionObj.selections[0], name: `Selection_${selections.length + 1}`, }, ], }, }); }} > Add selection

Condition

Define how each selection should be included in the final query. For more options use{' '} this.props.goToYamlEditor('yaml')} > YAML editor . } >
{fileUploadModalState && (

Upload a file

{selections[fileUploadModalState.selectionIdx].data[fileUploadModalState.dataIdx] .values[0] && ( <> )} {this.state.invalidFile && (

Invalid file.

)} this.onFileUpload( files, fileUploadModalState.selectionIdx, fileUploadModalState.dataIdx ) } multiple={false} aria-label="file picker" isInvalid={this.state.invalidFile} />

Accepted formats: .csv, .txt. Maximum size: 1 MB.

Close
)}
); } }