diff --git a/CHANGELOG.md b/CHANGELOG.md index b09a7c01f5..02971f0fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Support for Wazuh 4.9.0 - Added AngularJS dependencies [#6145](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6145) +- Remove embedded discover [#6120](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6120) ## Wazuh v4.8.1 - OpenSearch Dashboards 2.10.0 - Revision 00 diff --git a/plugins/main/public/components/common/data-grid/data-grid-service.ts b/plugins/main/public/components/common/data-grid/data-grid-service.ts new file mode 100644 index 0000000000..4f9315037a --- /dev/null +++ b/plugins/main/public/components/common/data-grid/data-grid-service.ts @@ -0,0 +1,163 @@ +import { SearchResponse } from "../../../../../../src/core/server"; +import * as FileSaver from '../../../services/file-saver'; +import { beautifyDate } from "../../agents/vuls/inventory/lib"; +import { SearchParams, search } from "../search-bar/search-bar-service"; +import { IFieldType, IndexPattern } from "../../../../../../src/plugins/data/common"; +export const MAX_ENTRIES_PER_QUERY = 10000; +import { EuiDataGridColumn } from '@elastic/eui'; + +export const parseData = (resultsHits: SearchResponse['hits']['hits']): any[] => { + const data = resultsHits.map((hit) => { + if (!hit) { + return {} + } + const source = hit._source as object; + const data = { + ...source, + _id: hit._id, + _index: hit._index, + _type: hit._type, + _score: hit._score, + }; + return data; + }); + return data; +} + + +export const getFieldFormatted = (rowIndex, columnId, indexPattern, rowsParsed) => { + const field = indexPattern.fields.find((field) => field.name === columnId); + let fieldValue = null; + if (columnId.includes('.')) { + // when the column is a nested field. The column could have 2 to n levels + // get dinamically the value of the nested field + const nestedFields = columnId.split('.'); + fieldValue = rowsParsed[rowIndex]; + nestedFields.forEach((field) => { + if (fieldValue) { + fieldValue = fieldValue[field]; + } + }); + } else { + const rowValue = rowsParsed[rowIndex]; + // when not exist the column in the row value then the value is null + if(!rowValue.hasOwnProperty(columnId)){ + fieldValue = null; + }else{ + fieldValue = rowValue[columnId]?.formatted || rowValue[columnId]; + } + } + // when fieldValue is null or undefined then return a empty string + if (fieldValue === null || fieldValue === undefined) { + return ''; + } + // if is date field + if (field?.type === 'date') { + // @ts-ignore + fieldValue = beautifyDate(fieldValue); + } + return fieldValue; +} + +// receive search params +export const exportSearchToCSV = async (params: SearchParams): Promise => { + const DEFAULT_MAX_SIZE_PER_CALL = 1000; + const { indexPattern, filters = [], query, sorting, fields, pagination } = params; + // when the pageSize is greater than the default max size per call (10000) + // then we need to paginate the search + const mustPaginateSearch = pagination?.pageSize && pagination?.pageSize > DEFAULT_MAX_SIZE_PER_CALL; + const pageSize = mustPaginateSearch ? DEFAULT_MAX_SIZE_PER_CALL : pagination?.pageSize; + const totalHits = pagination?.pageSize || DEFAULT_MAX_SIZE_PER_CALL; + let pageIndex = params.pagination?.pageIndex || 0; + let hitsCount = 0; + let allHits = []; + let searchResults; + if (mustPaginateSearch) { + // paginate the search + while (hitsCount < totalHits &&  hitsCount < MAX_ENTRIES_PER_QUERY) { + const searchParams = { + indexPattern, + filters, + query, + pagination: { + pageIndex, + pageSize, + }, + sorting, + fields, + }; + searchResults = await search(searchParams); + allHits = allHits.concat(searchResults.hits.hits); + hitsCount = allHits.length; + pageIndex++; + } + } else { + searchResults = await search(params); + allHits = searchResults.hits.hits; + } + + const resultsFields = fields; + const data = allHits.map((hit) => { + // check if the field type is a date + const dateFields = indexPattern.fields.getByType('date'); + const dateFieldsNames = dateFields.map((field) => field.name); + const flattenHit = indexPattern.flattenHit(hit); + // replace the date fields with the formatted date + dateFieldsNames.forEach((field) => { + if (flattenHit[field]) { + flattenHit[field] = beautifyDate(flattenHit[field]); + } + }); + return flattenHit; + }); + + if (!resultsFields || resultsFields.length === 0){ + return; + } + + if (!data || data.length === 0) + return; + + const parsedData = data.map((row) => { + const parsedRow = resultsFields?.map((field) => { + const value = row[field]; + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return `"${value}"`; + }); + return parsedRow?.join(','); + }).join('\n'); + + // create a csv file using blob + const blobData = new Blob( + [ + `${resultsFields?.join(',')}\n${parsedData}` + ], + { type: 'text/csv' } + ); + + if (blobData) { + // @ts-ignore + FileSaver?.saveAs(blobData, `events-${new Date().toISOString()}.csv`); + } +} + +export const parseColumns = (fields: IFieldType[]): EuiDataGridColumn[] => { + // remove _source field becuase is a object field and is not supported + fields = fields.filter((field) => field.name !== '_source'); + return fields.map((field) => { + return { + ...field, + id: field.name, + display: field.name, + schema: field.type, + actions: { + showHide: true, + }, + }; + }) || []; +} diff --git a/plugins/main/public/components/common/data-grid/index.ts b/plugins/main/public/components/common/data-grid/index.ts new file mode 100644 index 0000000000..172d61d1f0 --- /dev/null +++ b/plugins/main/public/components/common/data-grid/index.ts @@ -0,0 +1,2 @@ +export * from './data-grid-service'; +export * from './use-data-grid'; \ No newline at end of file diff --git a/plugins/main/public/components/common/data-grid/use-data-grid.ts b/plugins/main/public/components/common/data-grid/use-data-grid.ts new file mode 100644 index 0000000000..a27cb3d2d0 --- /dev/null +++ b/plugins/main/public/components/common/data-grid/use-data-grid.ts @@ -0,0 +1,103 @@ +import { EuiDataGridCellValueElementProps, EuiDataGridColumn, EuiDataGridProps, EuiDataGridSorting } from "@elastic/eui" +import React, { useEffect, useMemo, useState, Fragment } from "react"; +import { SearchResponse } from "@opensearch-project/opensearch/api/types"; +// ToDo: check how create this methods +import { parseData, getFieldFormatted, parseColumns } from './data-grid-service'; +import { IndexPattern } from '../../../../../../src/plugins/data/common'; + +const MAX_ENTRIES_PER_QUERY = 10000; + +export type tDataGridColumn = { + render?: (value: any) => string | React.ReactNode; +} & EuiDataGridColumn; + +type tDataGridProps = { + indexPattern: IndexPattern; + results: SearchResponse; + defaultColumns: tDataGridColumn[]; + DocViewInspectButton: ({ rowIndex }: EuiDataGridCellValueElementProps) => React.JSX.Element + ariaLabelledBy: string; +}; + + +export const useDataGrid = (props: tDataGridProps): EuiDataGridProps => { + const { indexPattern, DocViewInspectButton, results, defaultColumns } = props; + /** Columns **/ + const [columns, setColumns] = useState(defaultColumns); + const [columnVisibility, setVisibility] = useState(() => + columns.map(({ id }) => id) + ); + /** Rows */ + const [rows, setRows] = useState([]); + const rowCount = results ? results?.hits?.total as number : 0; + /** Sorting **/ + // get default sorting from default columns + const getDefaultSorting = () => { + const defaultSort = columns.find((column) => column.isSortable || column.defaultSortDirection); + return defaultSort ? [{ id: defaultSort.id, direction: defaultSort.defaultSortDirection || 'desc' }] : []; + } + const defaultSorting: EuiDataGridSorting['columns'] = getDefaultSorting(); + const [sortingColumns, setSortingColumns] = useState(defaultSorting); + const onSort = (sortingColumns) => { setSortingColumns(sortingColumns) }; + /** Pagination **/ + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 }); + const onChangeItemsPerPage = useMemo(() => (pageSize) => + setPagination((pagination) => ({ + ...pagination, + pageSize, + pageIndex: 0, + })), [rows, rowCount]); + const onChangePage = (pageIndex) => setPagination((pagination) => ({ ...pagination, pageIndex })) + + useEffect(() => { + setRows(results?.hits?.hits || []) + }, [results, results?.hits, results?.hits?.total]) + + useEffect(() => { + setPagination((pagination) => ({ ...pagination, pageIndex: 0 })); + }, [rowCount]) + + const renderCellValue = ({ rowIndex, columnId, setCellProps }) => { + const rowsParsed = parseData(rows); + // On the context data always is stored the current page data (pagination) + // then the rowIndex is relative to the current page + const relativeRowIndex = rowIndex % pagination.pageSize; + if(rowsParsed.hasOwnProperty(relativeRowIndex)){ + const fieldFormatted = getFieldFormatted(relativeRowIndex, columnId, indexPattern, rowsParsed); + // check if column have render method initialized + const column = columns.find((column) => column.id === columnId); + if (column && column.render) { + return column.render(fieldFormatted); + } + return fieldFormatted; + } + return null + }; + + const leadingControlColumns = useMemo(() => { + return [ + { + id: 'inspectCollapseColumn', + headerCellRender: () => null, + rowCellRender: (props) => DocViewInspectButton({ ...props, rowIndex: props.rowIndex % pagination.pageSize }), + width: 40, + }, + ]; + }, [results]); + + return { + "aria-labelledby": props.ariaLabelledBy, + columns: parseColumns(indexPattern?.fields || []), + columnVisibility: { visibleColumns: columnVisibility, setVisibleColumns: setVisibility }, + renderCellValue: renderCellValue, + leadingControlColumns: leadingControlColumns, + rowCount: rowCount < MAX_ENTRIES_PER_QUERY ? rowCount : MAX_ENTRIES_PER_QUERY, + sorting: { columns: sortingColumns, onSort }, + pagination: { + ...pagination, + pageSizeOptions: [20, 50, 100], + onChangeItemsPerPage: onChangeItemsPerPage, + onChangePage: onChangePage, + } + } +} \ No newline at end of file diff --git a/plugins/main/public/components/common/doc-viewer/doc-viewer.tsx b/plugins/main/public/components/common/doc-viewer/doc-viewer.tsx new file mode 100644 index 0000000000..79ef91eda1 --- /dev/null +++ b/plugins/main/public/components/common/doc-viewer/doc-viewer.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { escapeRegExp } from 'lodash'; +import { i18n } from '@osd/i18n'; +import { FieldIcon } from '../../../../../../src/plugins/opensearch_dashboards_react/public'; +import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; + +const COLLAPSE_LINE_LENGTH = 350; +const DOT_PREFIX_RE = /(.).+?\./g; + +export type tDocViewerProps = { + flattened: any; + formatted: any; + mapping: any; + indexPattern: any; +} + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); + +export const getFieldTypeName = (type: string) => { + switch (type) { + case 'boolean': + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { + defaultMessage: 'Boolean field', + }); + case 'conflict': + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { + defaultMessage: 'Conflicting field', + }); + case 'date': + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { + defaultMessage: 'Date field', + }); + case 'geo_point': + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { + defaultMessage: 'Geo point field', + }); + case 'geo_shape': + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { + defaultMessage: 'Geo shape field', + }); + case 'ip': + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { + defaultMessage: 'IP address field', + }); + case 'murmur3': + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { + defaultMessage: 'Murmur3 field', + }); + case 'number': + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { + defaultMessage: 'Number field', + }); + case 'source': + // Note that this type is currently not provided, type for _source is undefined + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { + defaultMessage: 'Source field', + }); + case 'string': + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { + defaultMessage: 'String field', + }); + case 'nested': + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { + defaultMessage: 'Nested field', + }); + default: + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { + defaultMessage: 'Unknown field', + }); + } +} + +const DocViewer = (props: tDocViewerProps) => { + const [fieldRowOpen, setFieldRowOpen] = useState({} as Record); + const { flattened, formatted, mapping, indexPattern } = props; + + return (<> + {flattened && ( + + + {Object.keys(flattened) + .sort() + .map((field, index) => { + const value = String(formatted[field]); + const fieldMapping = mapping(field); + const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; + const isCollapsed = isCollapsible && !fieldRowOpen[field]; + const valueClassName = classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + osdDocViewer__value: true, + 'truncate-by-height': isCollapsible && isCollapsed, + }); + const isNestedField = + !indexPattern.fields.getByName(field) && + !!indexPattern.fields.getAll().find((patternField) => { + // We only want to match a full path segment + const nestedRootRegex = new RegExp(escapeRegExp(field) + '(\\.|$)'); + return nestedRootRegex.test(patternField.subType?.nested?.path ?? ''); + }); + const fieldType = isNestedField ? 'nested' : indexPattern.fields.getByName(field)?.type; + const typeName = getFieldTypeName(String(fieldType)); + const displayName = field; + const fieldIconProps = { fill: 'none', color: 'gray' } + const scripted = Boolean(fieldMapping?.scripted) + + return ( + + + + + ); + })} + +
+ + + + + + + {displayName} + + + + +
+
+ )}) +}; + +export default DocViewer; \ No newline at end of file diff --git a/plugins/main/public/components/common/doc-viewer/index.ts b/plugins/main/public/components/common/doc-viewer/index.ts new file mode 100644 index 0000000000..a8231e35e6 --- /dev/null +++ b/plugins/main/public/components/common/doc-viewer/index.ts @@ -0,0 +1,2 @@ +export * from './use-doc-viewer'; +export * from './doc-viewer'; \ No newline at end of file diff --git a/plugins/main/public/components/common/doc-viewer/use-doc-viewer.ts b/plugins/main/public/components/common/doc-viewer/use-doc-viewer.ts new file mode 100644 index 0000000000..61a0b3af2d --- /dev/null +++ b/plugins/main/public/components/common/doc-viewer/use-doc-viewer.ts @@ -0,0 +1,28 @@ +import { tDocViewerProps } from "./doc-viewer" +import { IndexPattern } from "../../../../../../src/plugins/data/common"; + +type tUseDocViewerInputs = { + indexPattern: IndexPattern; + doc: any; +} + +export const useDocViewer = (props: tUseDocViewerInputs): tDocViewerProps => { + const { indexPattern, doc } = props; + + if (!indexPattern || !doc) { + return { + flattened: {}, + formatted: {}, + indexPattern: undefined, + mapping: undefined + } + } + + const mapping = indexPattern?.fields.getByName; + return { + flattened: indexPattern?.flattenHit(doc), + formatted: indexPattern?.formatHit(doc, 'html'), + indexPattern, + mapping + } +} \ No newline at end of file diff --git a/plugins/main/public/components/common/search-bar/index.ts b/plugins/main/public/components/common/search-bar/index.ts new file mode 100644 index 0000000000..104abb8280 --- /dev/null +++ b/plugins/main/public/components/common/search-bar/index.ts @@ -0,0 +1,2 @@ +export * from './search-bar-service'; +export * from './use-search-bar'; \ No newline at end of file diff --git a/plugins/main/public/components/common/search-bar/search-bar-service.ts b/plugins/main/public/components/common/search-bar/search-bar-service.ts new file mode 100644 index 0000000000..d7c546ccff --- /dev/null +++ b/plugins/main/public/components/common/search-bar/search-bar-service.ts @@ -0,0 +1,56 @@ +import { getPlugins } from '../../../kibana-services'; +import { IndexPattern, Filter, OpenSearchQuerySortValue } from "../../../../../../src/plugins/data/public"; +import { SearchResponse } from "../../../../../../src/core/server"; + +export interface SearchParams { + indexPattern: IndexPattern; + filters?: Filter[]; + query?: any; + pagination?: { + pageIndex?: number; + pageSize?: number; + }; + fields?: string[], + sorting?: { + columns: { + id: string; + direction: 'asc' | 'desc'; + }[]; + }; +} + +export const search = async (params: SearchParams): Promise => { + const { indexPattern, filters = [], query, pagination, sorting, fields } = params; + if(!indexPattern){ + return; + } + const data = getPlugins().data; + const searchSource = await data.search.searchSource.create(); + const fromField = (pagination?.pageIndex || 0) * (pagination?.pageSize || 100); + const sortOrder: OpenSearchQuerySortValue[] = sorting?.columns.map((column) => { + const sortDirection = column.direction === 'asc' ? 'asc' : 'desc'; + return { [column?.id || '']: sortDirection } as OpenSearchQuerySortValue; + }) || []; + + const searchParams = searchSource + .setParent(undefined) + .setField('filter', filters) + .setField('query', query) + .setField('sort', sortOrder) + .setField('size', pagination?.pageSize) + .setField('from', fromField) + .setField('index', indexPattern) + + // add fields + if (fields && Array.isArray(fields) && fields.length > 0){ + searchParams.setField('fields', fields); + } + try{ + return await searchParams.fetch(); + }catch(error){ + if(error.body){ + throw error.body; + } + throw error; + } +}; \ No newline at end of file diff --git a/plugins/main/public/components/common/search-bar/use-search-bar.test.ts b/plugins/main/public/components/common/search-bar/use-search-bar.test.ts new file mode 100644 index 0000000000..54b740dcd7 --- /dev/null +++ b/plugins/main/public/components/common/search-bar/use-search-bar.test.ts @@ -0,0 +1,219 @@ +import { renderHook } from '@testing-library/react-hooks'; +import '@testing-library/jest-dom/extend-expect'; +// osd dependencies +import { Start, dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { + Filter, + IndexPattern, + Query, + TimeRange, +} from '../../../../../../src/plugins/data/public'; +// wazuh plugin dependencies +import useSearchBar from './use-search-bar'; +import { getDataPlugin } from '../../../kibana-services'; +import * as timeFilterHook from '../hooks/use-time-filter'; +import * as queryManagerHook from '../hooks/use-query'; + +/** + * Mocking Data Plugin + **/ +jest.mock('../../../kibana-services', () => { + return { + getDataPlugin: jest.fn(), + }; +}); +/* using osd mock utils */ +const mockDataPlugin = dataPluginMock.createStartContract(); +const mockedGetDataPlugin = getDataPlugin as jest.Mock; +mockedGetDataPlugin.mockImplementation( + () => + ({ + ...mockDataPlugin, + ...{ + query: { + ...mockDataPlugin.query, + queryString: { + ...mockDataPlugin.query.queryString, + getUpdates$: jest.fn(() => ({ + subscribe: jest.fn(), + unsubscribe: jest.fn(), + })), + }, + }, + }, + } as Start) +); +/////////////////////////////////////////////////////////// + +const mockedDefaultIndexPatternData: Partial = { + // used partial not avoid fill all the interface, it's only for testing purpose + id: 'default-index-pattern', + title: '', +}; + +describe('[hook] useSearchBarConfiguration', () => { + beforeAll(() => { + /***** mock use-time-filter hook *****/ + const spyUseTimeFilter = jest.spyOn(timeFilterHook, 'useTimeFilter'); + const mockTimeFilterResult: TimeRange = { + from: 'now/d', + to: 'now/d', + }; + spyUseTimeFilter.mockImplementation(() => ({ + timeFilter: mockTimeFilterResult, + setTimeFilter: jest.fn(), + timeHistory: [], + })); + /***** mock use-time-filter hook *****/ + const spyUseQueryManager = jest.spyOn(queryManagerHook, 'useQueryManager'); + const mockQueryResult: Query = { + language: 'kuery', + query: '', + }; + spyUseQueryManager.mockImplementation(() => [mockQueryResult, jest.fn()]); + }); + + it.only('should return default app index pattern when not receiving a default index pattern', async () => { + jest + .spyOn(mockDataPlugin.indexPatterns, 'getDefault') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest.spyOn(mockDataPlugin.query.filterManager, 'getFilters').mockReturnValue([]); + const { result, waitForNextUpdate } = renderHook(() => useSearchBar({})); + await waitForNextUpdate(); + expect(mockDataPlugin.indexPatterns.getDefault).toBeCalled(); + expect(result.current.searchBarProps.indexPatterns).toMatchObject([ + mockedDefaultIndexPatternData, + ]); + }); + + it('should return the same index pattern when receiving a default index pattern', async () => { + const exampleIndexPatternId = 'wazuh-index-pattern'; + const mockedIndexPatternData: Partial = { + // used partial not avoid fill all the interface, it's only for testing purpose + id: exampleIndexPatternId, + title: '', + }; + jest.spyOn(mockDataPlugin.indexPatterns, 'get').mockResolvedValue(mockedIndexPatternData); + const { result, waitForNextUpdate } = renderHook(() => + useSearchBar({ + defaultIndexPatternID: 'wazuh-index-pattern', + }) + ); + await waitForNextUpdate(); + expect(mockDataPlugin.indexPatterns.get).toBeCalledWith(exampleIndexPatternId); + expect(result.current.searchBarProps.indexPatterns).toMatchObject([mockedIndexPatternData]); + }); + + it('should show an ERROR message and get the default app index pattern when not found the index pattern data by the ID received', async () => { + const INDEX_NOT_FOUND_ERROR = new Error('Index Pattern not found'); + jest.spyOn(mockDataPlugin.indexPatterns, 'get').mockImplementation(() => { + throw INDEX_NOT_FOUND_ERROR; + }); + jest + .spyOn(mockDataPlugin.indexPatterns, 'getDefault') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest.spyOn(mockDataPlugin.query.filterManager, 'getFilters').mockReturnValue([]); + + // mocking console error to avoid logs in test and check if is called + const mockedConsoleError = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const { result, waitForNextUpdate } = renderHook(() => + useSearchBar({ + defaultIndexPatternID: 'invalid-index-pattern-id', + }) + ); + + await waitForNextUpdate(); + expect(mockDataPlugin.indexPatterns.getDefault).toBeCalled(); + expect(mockDataPlugin.indexPatterns.get).toBeCalledWith('invalid-index-pattern-id'); + expect(result.current.searchBarProps.indexPatterns).toMatchObject([ + mockedDefaultIndexPatternData, + ]); + expect(mockedConsoleError).toBeCalledWith(INDEX_NOT_FOUND_ERROR); + }); + + it('should return the same filters and apply them to the filter manager when are received by props', async () => { + const defaultFilters: Filter[] = [ + { + query: 'something to filter', + meta: { + alias: 'filter-mocked', + disabled: false, + negate: true, + }, + }, + ]; + jest + .spyOn(mockDataPlugin.indexPatterns, 'getDefault') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest.spyOn(mockDataPlugin.query.filterManager, 'getFilters').mockReturnValue(defaultFilters); + const { result, waitForNextUpdate } = renderHook(() => + useSearchBar({ + filters: defaultFilters, + }) + ); + + await waitForNextUpdate(); + + expect(result.current.searchBarProps.filters).toMatchObject(defaultFilters); + expect(mockDataPlugin.query.filterManager.setFilters).toBeCalledWith(defaultFilters); + expect(mockDataPlugin.query.filterManager.getFilters).toBeCalled(); + }); + + it('should return and preserve filters when the index pattern received is equal to the index pattern already selected in the app', async () => { + const defaultIndexFilters: Filter[] = [ + { + query: 'something to filter', + meta: { + alias: 'filter-mocked', + disabled: false, + negate: true, + }, + }, + ]; + jest + .spyOn(mockDataPlugin.indexPatterns, 'getDefault') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest + .spyOn(mockDataPlugin.indexPatterns, 'get') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest + .spyOn(mockDataPlugin.query.filterManager, 'getFilters') + .mockReturnValue(defaultIndexFilters); + const { result, waitForNextUpdate } = renderHook(() => + useSearchBar({ + defaultIndexPatternID: mockedDefaultIndexPatternData.id, + }) + ); + await waitForNextUpdate(); + expect(result.current.searchBarProps.indexPatterns).toMatchObject([ + mockedDefaultIndexPatternData, + ]); + expect(result.current.searchBarProps.filters).toMatchObject(defaultIndexFilters); + }); + + it('should return empty filters when the index pattern is NOT equal to the default app index pattern', async () => { + const exampleIndexPatternId = 'wazuh-index-pattern'; + const mockedExampleIndexPatternData: Partial = { + // used partial not avoid fill all the interface, it's only for testing purpose + id: exampleIndexPatternId, + title: '', + }; + jest + .spyOn(mockDataPlugin.indexPatterns, 'get') + .mockResolvedValue(mockedExampleIndexPatternData); + jest + .spyOn(mockDataPlugin.indexPatterns, 'getDefault') + .mockResolvedValue(mockedDefaultIndexPatternData); + jest.spyOn(mockDataPlugin.query.filterManager, 'getFilters').mockReturnValue([]); + const { result, waitForNextUpdate } = renderHook(() => + useSearchBar({ + defaultIndexPatternID: exampleIndexPatternId, + }) + ); + await waitForNextUpdate(); + expect(result.current.searchBarProps.indexPatterns).toMatchObject([ + mockedExampleIndexPatternData, + ]); + expect(result.current.searchBarProps.filters).toStrictEqual([]); + }); +}); diff --git a/plugins/main/public/components/common/search-bar/use-search-bar.ts b/plugins/main/public/components/common/search-bar/use-search-bar.ts new file mode 100644 index 0000000000..30025f9676 --- /dev/null +++ b/plugins/main/public/components/common/search-bar/use-search-bar.ts @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react'; +import { + SearchBarProps, + FilterManager, + TimeRange, + Query, + Filter, + IIndexPattern, + IndexPatternsContract, +} from '../../../../../../src/plugins/data/public'; +import { getDataPlugin } from '../../../kibana-services'; + +import { useFilterManager, useQueryManager, useTimeFilter } from '../hooks'; +import { AUTHORIZED_AGENTS } from '../../../../common/constants'; + +type tUseSearchBarCustomInputs = { + defaultIndexPatternID: IIndexPattern['id']; + onFiltersUpdated?: (filters: Filter[]) => void; + onQuerySubmitted?: ( + payload: { dateRange: TimeRange; query?: Query }, + isUpdate?: boolean, + ) => void; +}; +type tUseSearchBarProps = Partial & tUseSearchBarCustomInputs; + +// Output types +type tUserSearchBarResponse = { + searchBarProps: Partial; +}; + +/** + * Hook used to compose the searchbar configuration props + * @param props + * @returns + */ +const useSearchBar = ( + props?: tUseSearchBarProps, +): tUserSearchBarResponse => { + // dependencies + const filterManager = useFilterManager().filterManager as FilterManager; + const { filters } = useFilterManager(); + const [query, setQuery] = props?.query + ? useState(props?.query) + : useQueryManager(); + const { timeFilter, timeHistory, setTimeFilter } = useTimeFilter(); + // states + const [isLoading, setIsLoading] = useState(false); + const [indexPatternSelected, setIndexPatternSelected] = + useState(); + + useEffect(() => { + initSearchBar(); + }, []); + + useEffect(() => { + const defaultIndex = props?.defaultIndexPatternID; + /* Filters that do not belong to the default index are filtered */ + const cleanedFilters = filters.filter( + filter => filter.meta.index === defaultIndex, + ); + if (cleanedFilters.length !== filters.length) { + filterManager.setFilters(cleanedFilters); + } + }, [filters]); + + /** + * Initialize the searchbar props with the corresponding index pattern and filters + */ + const initSearchBar = async () => { + setIsLoading(true); + const indexPattern = await getIndexPattern(props?.defaultIndexPatternID); + setIndexPatternSelected(indexPattern); + const filters = await getInitialFilters(indexPattern); + filterManager.setFilters(filters); + setIsLoading(false); + }; + + /** + * Return the index pattern data by ID. + * If not receive a ID return the default index from the index pattern service + * @returns + */ + const getIndexPattern = async (indexPatternID?: string) => { + const indexPatternService = getDataPlugin() + .indexPatterns as IndexPatternsContract; + if (indexPatternID) { + try { + return await indexPatternService.get(indexPatternID); + } catch (error) { + // when the index pattern id not exists will get the default defined in the index pattern service + console.error(error); + return await indexPatternService.getDefault(); + } + } else { + return await indexPatternService.getDefault(); + } + }; + + /** + * Return the initial filters considering if hook receives initial filters + * When the default index pattern is the same like the received preserve the filters + * @param indexPattern + * @returns + */ + const getInitialFilters = async (indexPattern: IIndexPattern) => { + const indexPatternService = getDataPlugin() + .indexPatterns as IndexPatternsContract; + let initialFilters: Filter[] = []; + if (props?.filters) { + return props?.filters; + } + if (indexPattern) { + // get filtermanager and filters + // if the index is the same, get filters stored + // else clear filters + const defaultIndexPattern = + (await indexPatternService.getDefault()) as IIndexPattern; + initialFilters = + defaultIndexPattern.id === indexPattern.id + ? filterManager.getFilters() + : []; + } else { + initialFilters = []; + } + return initialFilters; + }; + + /** + * Return filters from filters manager. + * Additionally solve the known issue with the auto loaded agent.id filters from the searchbar + * @returns + */ + const getFilters = () => { + const filters = filterManager ? filterManager.getFilters() : []; + return filters.filter( + filter => filter.meta.controlledBy !== AUTHORIZED_AGENTS, + ); // remove auto loaded agent.id filters + }; + + /** + * Search bar properties necessary to render and initialize the osd search bar component + */ + const searchBarProps: Partial = { + isLoading, + ...(indexPatternSelected && { indexPatterns: [indexPatternSelected] }), // indexPattern cannot be empty or empty [] + filters: getFilters(), + query, + timeHistory, + dateRangeFrom: timeFilter.from, + dateRangeTo: timeFilter.to, + onFiltersUpdated: (filters: Filter[]) => { + // its necessary execute setter to apply filters + filterManager.setFilters(filters); + props?.onFiltersUpdated && props?.onFiltersUpdated(filters); + }, + onQuerySubmit: ( + payload: { dateRange: TimeRange; query?: Query }, + _isUpdate?: boolean, + ): void => { + const { dateRange, query } = payload; + // its necessary execute setter to apply query filters + setTimeFilter(dateRange); + setQuery(query); + props?.onQuerySubmitted && props?.onQuerySubmitted(payload); + }, + }; + + return { + searchBarProps, + }; +}; + +export default useSearchBar; diff --git a/plugins/main/public/components/common/wazuh-discover/config/chart.ts b/plugins/main/public/components/common/wazuh-discover/config/chart.ts new file mode 100644 index 0000000000..8e667141b0 --- /dev/null +++ b/plugins/main/public/components/common/wazuh-discover/config/chart.ts @@ -0,0 +1,130 @@ +import { DashboardPanelState } from "../../../../../../../src/plugins/dashboard/public/application"; +import { EmbeddableInput } from "../../../../../../../src/plugins/embeddable/public"; + +const hitsHistogram = (indexPatternId: string) => { + return { + id: 'events_histogram', + title: 'Events histogram', + type: 'horizontal_bar', + params: { + type: 'histogram', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'bottom', + show: true, + style: {}, + scale: { type: 'linear' }, + labels: { show: true, filter: true, truncate: 100 }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + style: {}, + scale: { type: 'linear', mode: 'normal' }, + labels: { show: true, rotate: 0, filter: false, truncate: 100 }, + title: { text: 'Count' }, + }, + ], + seriesParams: [ + { + show: true, + type: 'histogram', + mode: 'stacked', + data: { + label: 'Count', + id: '1', + }, + valueAxis: 'ValueAxis-1', + drawLinesBetweenPoints: true, + lineWidth: 2, + showCircles: true, + }, + ], + addTooltip: true, + addLegend: false, + legendPosition: 'right', + times: [], + addTimeMarker: false, + labels: {}, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { id: '1', enabled: true, type: 'count', schema: 'metric', params: {} }, + { + id: '2', + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: 'timestamp', + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: 'auto', + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }, + ], + }, + }; +}; + + +export const getDiscoverPanels = ( + indexPatternId: string, +): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + '1': { + gridData: { + w: 100, + h: 10, + x: 0, + y: 0, + i: '1', + }, + type: 'visualization', + explicitInput: { + id: '1', + savedVis: hitsHistogram(indexPatternId), + }, + } + }; +} diff --git a/plugins/main/public/components/common/wazuh-discover/wz-discover.tsx b/plugins/main/public/components/common/wazuh-discover/wz-discover.tsx new file mode 100644 index 0000000000..b33ac2ed87 --- /dev/null +++ b/plugins/main/public/components/common/wazuh-discover/wz-discover.tsx @@ -0,0 +1,245 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { + EuiDataGrid, + EuiPageTemplate, + EuiToolTip, + EuiButtonIcon, + EuiDataGridCellValueElementProps, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiButtonEmpty, + EuiSpacer, + EuiPanel, +} from '@elastic/eui'; +import { IntlProvider } from 'react-intl'; +import { IndexPattern } from '../../../../../../src/plugins/data/common'; +import { SearchResponse } from '../../../../../../src/core/server'; +import { useDocViewer } from '../doc-viewer'; +import DocViewer from '../doc-viewer/doc-viewer'; +import { DiscoverNoResults } from '../../overview/vulnerabilities/common/components/no_results'; +import { LoadingSpinner } from '../../overview/vulnerabilities/common/components/loading_spinner'; +import { useDataGrid, tDataGridColumn, exportSearchToCSV } from '../data-grid'; +import { ErrorHandler, ErrorFactory, HttpError } from '../../../react-services/error-management'; +import { withErrorBoundary } from '../hocs'; +import { HitsCounter } from '../../../kibana-integrations/discover/application/components/hits_counter'; +import { formatNumWithCommas } from '../../../kibana-integrations/discover/application/helpers'; +import useSearchBar from '../search-bar/use-search-bar'; +import { search } from '../search-bar'; +import { getPlugins } from '../../../kibana-services'; +import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; +import { getDiscoverPanels } from './config/chart'; +const DashboardByRenderer = getPlugins().dashboard.DashboardContainerByValueRenderer; + +/** + * ToDo: + * - add possibility to customize column render + * - add save query feature + */ + +export const MAX_ENTRIES_PER_QUERY = 10000; + +type WazuhDiscoverProps = { + indexPatternName: string; + tableColumns: tDataGridColumn[]; +} + +const WazuhDiscover = (props: WazuhDiscoverProps) => { + const { indexPatternName, tableColumns: defaultTableColumns } = props + const { searchBarProps } = useSearchBar({ + defaultIndexPatternID: indexPatternName, + }) + const { isLoading, filters, query, indexPatterns } = searchBarProps; + const SearchBar = getPlugins().data.ui.SearchBar; + const [results, setResults] = useState({} as SearchResponse); + const [inspectedHit, setInspectedHit] = useState(undefined); + const [indexPattern, setIndexPattern] = useState(undefined); + const [isSearching, setIsSearching] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + const onClickInspectDoc = useMemo(() => (index: number) => { + const rowClicked = results.hits.hits[index]; + setInspectedHit(rowClicked); + }, [results]); + + const DocViewInspectButton = ({ rowIndex }: EuiDataGridCellValueElementProps) => { + const inspectHintMsg = 'Inspect document details'; + return ( + + onClickInspectDoc(rowIndex)} + iconType='inspect' + aria-label={inspectHintMsg} + /> + + ); + }; + + const dataGridProps = useDataGrid({ + ariaLabelledBy: 'Discover events table', + defaultColumns: defaultTableColumns, + results, + indexPattern: indexPattern as IndexPattern, + DocViewInspectButton + }) + + const { pagination, sorting, columnVisibility } = dataGridProps; + + const docViewerProps = useDocViewer({ + doc: inspectedHit, + indexPattern: indexPattern as IndexPattern, + }) + + useEffect(() => { + if (!isLoading) { + setIndexPattern(indexPatterns?.[0] as IndexPattern); + search({ + indexPattern: indexPatterns?.[0] as IndexPattern, + filters, + query, + pagination, + sorting + }).then((results) => { + setResults(results); + setIsSearching(false); + }).catch((error) => { + const searchError = ErrorFactory.create(HttpError, { error, message: 'Error fetching vulnerabilities' }) + ErrorHandler.handleError(searchError); + setIsSearching(false); + }) + } + }, [JSON.stringify(searchBarProps), JSON.stringify(pagination), JSON.stringify(sorting)]); + + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; + + const onClickExportResults = async () => { + const params = { + indexPattern: indexPatterns?.[0] as IndexPattern, + filters, + query, + fields: columnVisibility.visibleColumns, + pagination: { + pageIndex: 0, + pageSize: results.hits.total + }, + sorting + } + try { + setIsExporting(true); + await exportSearchToCSV(params); + } catch (error) { + const searchError = ErrorFactory.create(HttpError, { error, message: 'Error downloading csv report' }) + ErrorHandler.handleError(searchError); + } finally { + setIsExporting(false); + } + } + + return ( + + + <> + {isLoading ? + : + } + {isSearching ? + : null} + {!isLoading && !isSearching && results?.hits?.total === 0 ? + : null} + {!isLoading && !isSearching && results?.hits?.total > 0 ? ( + <> + + + + + + + + + + { }} + tooltip={results?.hits?.total && results?.hits?.total > MAX_ENTRIES_PER_QUERY ? { + ariaLabel: 'Warning', + content: `The query results has exceeded the limit of 10,000 hits. To provide a better experience the table only shows the first ${formatNumWithCommas(MAX_ENTRIES_PER_QUERY)} hits.`, + iconType: 'alert', + position: 'top' + } : undefined} + /> + + Export Formated + + + ) + }} + /> + ) : null} + {inspectedHit && ( + setInspectedHit(undefined)} size="m"> + + +

Document Details

+
+
+ + + + + + + +
+ )} + +
+
+ ); +} + +export default WazuhDiscover; \ No newline at end of file