diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb71211e4..5e8349576e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Support for Wazuh 4.8.0 - Added remember server address check [#5791](https://github.com/wazuh/wazuh-dashboard-plugins/pull/5791) - Added the ssl_agent_ca configuration to the SSL Settings form [#6083](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6083) +- Added global vulnerabilities dashboards [#5896](https://github.com/wazuh/wazuh-dashboard-plugins/pull/5896) ## Wazuh v4.7.1 - OpenSearch Dashboards 2.8.0 - Revision 00 diff --git a/plugins/main/opensearch_dashboards.json b/plugins/main/opensearch_dashboards.json index e09922bdf6..89a9f9de9e 100644 --- a/plugins/main/opensearch_dashboards.json +++ b/plugins/main/opensearch_dashboards.json @@ -8,6 +8,8 @@ "requiredPlugins": [ "navigation", "data", + "dashboard", + "embeddable", "discover", "inspector", "visualizations", diff --git a/plugins/main/public/components/common/hooks/use-filter-manager.ts b/plugins/main/public/components/common/hooks/use-filter-manager.ts index c9bad4d019..5d02f42b16 100644 --- a/plugins/main/public/components/common/hooks/use-filter-manager.ts +++ b/plugins/main/public/components/common/hooks/use-filter-manager.ts @@ -11,23 +11,36 @@ */ import { getDataPlugin } from '../../../kibana-services'; import { useState, useEffect, useMemo } from 'react'; -import { Filter } from 'src/plugins/data/public'; +import { Filter } from '../../../../../../src/plugins/data/public'; import _ from 'lodash'; +import { FilterManager } from '../../../../../../src/plugins/data/public'; +import { Subscription } from 'rxjs'; -export const useFilterManager = () => { - const filterManager = useMemo(() => getDataPlugin().query.filterManager, []); +type tUseFilterManagerReturn = { + filterManager: FilterManager; + filters: Filter[]; +}; + +export const useFilterManager = (): tUseFilterManagerReturn => { + const filterManager = getDataPlugin().query.filterManager; const [filters, setFilters] = useState(filterManager.getFilters()); useEffect(() => { - const subscription = filterManager.getUpdates$().subscribe(() => { - const newFilters = filterManager.getFilters(); - if (!_.isEqual(filters, newFilters)) { - setFilters(newFilters); - } - }); + const subscriptions = new Subscription(); + + subscriptions.add( + filterManager.getUpdates$().subscribe({ + next: () => { + const newFilters = filterManager.getFilters(); + setFilters(newFilters); + }, + }) + ); + return () => { - subscription.unsubscribe(); + subscriptions.unsubscribe(); }; - }, []); + }, [filterManager]); + return { filterManager, filters }; }; diff --git a/plugins/main/public/components/common/modules/modules-defaults.js b/plugins/main/public/components/common/modules/modules-defaults.js index 0b74074259..517eeb84d6 100644 --- a/plugins/main/public/components/common/modules/modules-defaults.js +++ b/plugins/main/public/components/common/modules/modules-defaults.js @@ -21,7 +21,8 @@ import ButtonModuleExploreAgent from '../../../controllers/overview/components/o import { ButtonModuleGenerateReport } from '../modules/buttons'; import { OfficePanel } from '../../overview/office-panel'; import { GitHubPanel } from '../../overview/github-panel'; -import { withModuleNotForAgent } from '../hocs'; +import { DashboardVuls, InventoryVuls } from '../../overview/vulnerabilities' +import { withModuleNotForAgent, withModuleTabLoader } from '../hocs'; const DashboardTab = { id: 'dashboard', @@ -130,18 +131,22 @@ export const ModulesDefaults = { availableFor: ['manager', 'agent'], }, vuls: { - init: 'inventory', + init: 'dashboard', tabs: [ + { + id: 'dashboard', + name: 'Dashboard', + component: withModuleNotForAgent(DashboardVuls), + }, { id: 'inventory', name: 'Inventory', - buttons: [ButtonModuleExploreAgent], - component: MainVuls, + component: withModuleNotForAgent(InventoryVuls), }, EventsTab, ], buttons: ['settings'], - availableFor: ['manager', 'agent'], + availableFor: ['manager'], }, mitre: { init: 'dashboard', diff --git a/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.scss b/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.scss new file mode 100644 index 0000000000..051ab642c1 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.scss @@ -0,0 +1,4 @@ +.discoverNoResults { + display: flex; + align-items: center; +} diff --git a/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.tsx b/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.tsx new file mode 100644 index 0000000000..7f505e6167 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/common/components/loading_spinner.tsx @@ -0,0 +1,21 @@ +import './loading_spinner.scss'; +import React from 'react'; +import { EuiTitle, EuiPanel, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; + +export function LoadingSpinner() { + return ( + + } + title={ + +

+ +

+
+ } + /> +
+ ); +} diff --git a/plugins/main/public/components/overview/vulnerabilities/common/components/no_results.tsx b/plugins/main/public/components/overview/vulnerabilities/common/components/no_results.tsx new file mode 100644 index 0000000000..3d592c867d --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/common/components/no_results.tsx @@ -0,0 +1,198 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { + EuiCallOut, + EuiCode, + EuiDescriptionList, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +interface Props { + timeFieldName?: string; + queryLanguage?: string; +} + +export const DiscoverNoResults = ({ timeFieldName, queryLanguage }: Props) => { + let timeFieldMessage; + + if (timeFieldName) { + timeFieldMessage = ( + + + + +

+ +

+ +

+ +

+
+
+ ); + } + + let luceneQueryMessage; + + if (queryLanguage === 'lucene') { + const searchExamples = [ + { + description: 200, + title: ( + + + + + + ), + }, + { + description: status:200, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499], + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND extension:PHP, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND (extension:php OR extension:html), + title: ( + + + + + + ), + }, + ]; + + luceneQueryMessage = ( + + + + +

+ +

+ +

+ +

+
+ + + + + + +
+ ); + } + + return ( + + + + } + color="warning" + iconType="help" + data-test-subj="discoverNoResults" + /> + {timeFieldMessage} + {luceneQueryMessage} + + + ); +}; diff --git a/plugins/main/public/components/overview/vulnerabilities/common/constants.ts b/plugins/main/public/components/overview/vulnerabilities/common/constants.ts new file mode 100644 index 0000000000..29536bb7f2 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/common/constants.ts @@ -0,0 +1 @@ +export const VULNERABILITIES_INDEX_PATTERN_ID = 'wazuh-states-vulnerabilities'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/index.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/index.ts new file mode 100644 index 0000000000..234af15583 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/index.ts @@ -0,0 +1,2 @@ +export * from './overview'; +export * from './inventory'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/config/index.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/config/index.ts new file mode 100644 index 0000000000..eb2d9d2c3a --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/config/index.ts @@ -0,0 +1,28 @@ +import { EuiDataGridColumn } from "@elastic/eui"; + +export const inventoryTableDefaultColumns: EuiDataGridColumn[] = [ + { + id: 'package.name', + }, + { + id: 'package.version', + }, + { + id: 'package.architecture', + }, + { + id: 'vulnerability.severity', + }, + { + id: 'vulnerability.id', + }, + { + id: 'vulnerability.score.version', + }, + { + id: 'vulnerability.score.base', + }, + { + id: 'event.created', + } + ] \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/index.tsx b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/index.tsx new file mode 100644 index 0000000000..ddb0742f5e --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/index.tsx @@ -0,0 +1 @@ +export * from './inventory'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.scss b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.scss new file mode 100644 index 0000000000..e3329465a8 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.scss @@ -0,0 +1,14 @@ +.vulsInventoryContainer { + height: calc(100vh - 104px); +} + + +.headerIsExpanded .vulsInventoryContainer { + height: calc(100vh - 153px); +} + +.vulsInventoryContainer .euiDataGrid--fullScreen { + height: calc(100vh - 49px); + bottom: 0; + top: auto; +} diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.tsx b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.tsx new file mode 100644 index 0000000000..0e2e16fee2 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { getPlugins } from '../../../../../kibana-services'; +import useSearchBarConfiguration from '../../search_bar/use_search_bar_configuration' +import { IntlProvider } from 'react-intl'; +import { + EuiDataGrid, + EuiPageTemplate, + EuiToolTip, + EuiButtonIcon, + EuiDataGridCellValueElementProps, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiButtonEmpty, +} from '@elastic/eui'; +import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { SearchResponse } from '../../../../../../../../src/core/server'; +import DocViewer from '../../doc_viewer/doc_viewer'; +import { DiscoverNoResults } from '../../common/components/no_results'; +import { LoadingSpinner } from '../../common/components/loading_spinner'; +import { useDataGrid } from '../../data_grid/use_data_grid'; +import { inventoryTableDefaultColumns } from './config'; +import { useDocViewer } from '../../doc_viewer/use_doc_viewer'; +import './inventory.scss'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../../common/constants'; +import { search, exportSearchToCSV } from './inventory_service'; +import { ErrorHandler, ErrorFactory, HttpError } from '../../../../../react-services/error-management'; +import { withErrorBoundary } from '../../../../common/hocs'; + +const InventoryVulsComponent = () => { + const { searchBarProps } = useSearchBarConfiguration({ + defaultIndexPatternID: VULNERABILITIES_INDEX_PATTERN_ID, + }) + 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 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: 'Vulnerabilities Inventory Table', + defaultColumns: inventoryTableDefaultColumns, + 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); + try { + 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 searching 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 { + await exportSearchToCSV(params); + }catch(error){ + const searchError = ErrorFactory.create(HttpError, { error, message: 'Error downloading csv report' }) + ErrorHandler.handleError(searchError); + } + } + + return ( + + + <> + {isLoading ? + : + } + {isSearching ? + : null} + {!isLoading && !isSearching && results?.hits?.total === 0 ? + : null} + {!isLoading && !isSearching && results?.hits?.total > 0 ? + + Export Formated + + ), + }} + /> : null} + {inspectedHit && ( + setInspectedHit(undefined)} size="m"> + + +

Document Details

+
+
+ + + + + + + +
+ )} + +
+
+ ); +} + +export const InventoryVuls = withErrorBoundary(InventoryVulsComponent); \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory_service.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory_service.ts new file mode 100644 index 0000000000..3aac10736e --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/inventory/inventory_service.ts @@ -0,0 +1,183 @@ +import { SearchResponse } from "../../../../../../../../src/core/server"; +import { getPlugins } from '../../../../../kibana-services'; +import { IndexPattern, Filter, OpenSearchQuerySortValue } from "../../../../../../../../src/plugins/data/public"; +import * as FileSaver from '../../../../../services/file-saver'; +import { beautifyDate } from "../../../../agents/vuls/inventory/lib"; + + +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; + 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); + + + return await searchParams.fetch(); +}; + + +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 { + fieldValue = rowsParsed[rowIndex][columnId].formatted + ? rowsParsed[rowIndex][columnId].formatted + : rowsParsed[rowIndex][columnId]; + } + + // if is date field + if (field?.type === 'date') { + // @ts-ignore + fieldValue = beautifyDate(fieldValue); + } + return fieldValue; +} + +export const exportSearchToCSV = async (params: SearchParams): Promise => { + const DEFAULT_MAX_SIZE_PER_CALL = 10000; + 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) { + 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); + } + + const resultsFields = fields; + const data = searchResults.hits.hits.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) { + FileSaver?.saveAs(blobData, 'vulnerabilities_inventory.csv'); + } +} \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard.tsx b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard.tsx new file mode 100644 index 0000000000..e3810319c5 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { getPlugins } from '../../../../../kibana-services'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { getDashboardPanels } from './dashboard_panels'; +import { I18nProvider } from '@osd/i18n/react'; +import useSearchBarConfiguration from '../../search_bar/use_search_bar_configuration'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../../common/constants'; +import { getDashboardFilters } from './dashboard_panels_filters'; +import './vulnerability_detector_filters.scss'; +import { getKPIsPanel } from './dashboard_panels_kpis'; +const plugins = getPlugins(); + +const SearchBar = getPlugins().data.ui.SearchBar; + +const DashboardByRenderer = plugins.dashboard.DashboardContainerByValueRenderer; + +/* The vulnerabilities dashboard is made up of 3 dashboards because the filters need +a wrapper for visual adjustments, while the Kpi, the Open vs Close visualization and +the rest of the visualizations have different configurations at the dashboard level. */ + +export const DashboardVuls: React.FC = () => { + const { searchBarProps } = useSearchBarConfiguration({ + defaultIndexPatternID: VULNERABILITIES_INDEX_PATTERN_ID, + filters: [], + }); + + return ( + <> + + + +
+ +
+ + + + ); +}; diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels.ts new file mode 100644 index 0000000000..70dc718d03 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels.ts @@ -0,0 +1,681 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../../common/constants'; + +const getVisStateTopVulnerabilities = (indexPatternId: string) => { + return { + id: 'most_detected_vulnerabilities', + title: 'Most detected vulnerabilities', + type: 'horizontal_bar', + params: { + type: 'histogram', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 200, + }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'bottom', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 75, + filter: true, + 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', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 10, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'Vulnerability.ID', + }, + schema: 'segment', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateTopVulnerabilitiesEndpoints = (indexPatternId: string) => { + return { + id: 'most_vulnerable_endpoints_vulnerabilities', + title: 'The most vulnerable endpoints', + type: 'horizontal_bar', + params: { + type: 'histogram', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 200, + }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'bottom', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 75, + filter: true, + 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', + }, + }, + uiState: { + vis: { + legendOpen: false, + }, + }, + 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', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'agent.id', + orderBy: '1', + order: 'desc', + size: 10, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'package.path', + }, + schema: 'segment', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'agent.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'mm', + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateAccumulationMostDetectedVulnerabilities = (indexPatternId: string) => { + return { + id: 'accumulation_most_vulnerable_vulnerabilities', + title: 'Accumulation of the most detected vulnerabilities', + type: 'line', + params: { + type: 'line', + 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: 'line', + mode: 'normal', + data: { + label: 'Count', + id: '1', + }, + valueAxis: 'ValueAxis-1', + drawLinesBetweenPoints: false, + lineWidth: 2, + interpolate: 'linear', + showCircles: true, + }, + ], + addTooltip: true, + addLegend: true, + legendPosition: 'right', + times: [], + addTimeMarker: false, + labels: {}, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + radiusRatio: 20, + }, + 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', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'count', + params: {}, + schema: 'radius', + }, + { + id: '4', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Others', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'group', + }, + { + id: '3', + enabled: true, + type: 'date_histogram', + params: { + field: 'event.created', + timeRange: { + from: 'now-24h', + to: 'now', + }, + useNormalizedOpenSearchInterval: true, + scaleMetricValues: false, + interval: 'w', + // eslint-disable-next-line camelcase + drop_partials: false, + // eslint-disable-next-line camelcase + min_doc_count: 1, + // eslint-disable-next-line camelcase + extended_bounds: {}, + }, + schema: 'segment', + }, + ], + }, + }; +}; + +const getVisStateInventoryTable = (indexPatternId: string) => { + return { + id: 'inventory_table_vulnerabilities', + title: 'Inventory table', + type: 'table', + params: { + perPage: 5, + showPartialRows: false, + showMetricsAtAllLevels: false, + showTotal: false, + totalFunc: 'sum', + percentageCol: '', + }, + 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', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'package.name', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'name', + }, + schema: 'bucket', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'package.version', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'version', + }, + schema: 'bucket', + }, + { + id: '4', + enabled: true, + type: 'terms', + params: { + field: 'package.architecture', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'architecture', + }, + schema: 'bucket', + }, + { + id: '5', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.severity', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'severity', + }, + schema: 'bucket', + }, + { + id: '6', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'id', + }, + schema: 'bucket', + }, + { + id: '7', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.score.version', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'score version', + }, + schema: 'bucket', + }, + { + id: '8', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.score.base', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'score base', + }, + schema: 'bucket', + }, + ], + }, + }; +}; + +export const getDashboardPanels = (): { + [panelId: string]: DashboardPanelState; +} => { + return { + '6': { + gridData: { + w: 16, + h: 12, + x: 0, + y: 0, + i: '6', + }, + type: 'visualization', + explicitInput: { + id: '6', + savedVis: getVisStateTopVulnerabilities(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + '7': { + gridData: { + w: 16, + h: 12, + x: 16, + y: 0, + i: '7', + }, + type: 'visualization', + explicitInput: { + id: '7', + savedVis: getVisStateTopVulnerabilitiesEndpoints(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + '8': { + gridData: { + w: 16, + h: 12, + x: 32, + y: 0, + i: '8', + }, + type: 'visualization', + explicitInput: { + id: '8', + savedVis: getVisStateAccumulationMostDetectedVulnerabilities( + VULNERABILITIES_INDEX_PATTERN_ID + ), + }, + }, + '9': { + gridData: { + w: 48, + h: 12, + x: 0, + y: 14, + i: '9', + }, + type: 'visualization', + explicitInput: { + id: '9', + savedVis: getVisStateInventoryTable(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_filters.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_filters.ts new file mode 100644 index 0000000000..8e25e649f5 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_filters.ts @@ -0,0 +1,159 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../../common/constants'; + +const getVisStateFilter = ( + id: string, + indexPatternId: string, + title: string, + label: string, + fieldName: string, +) => { + return { + id, + title, + type: 'table', + params: { + perPage: 5, + percentageCol: '', + row: true, + showMetricsAtAllLevels: false, + showPartialRows: false, + showTotal: false, + totalFunc: 'sum', + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: fieldName, + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: label, + }, + schema: 'bucket', + }, + ], + }, + }; +}; + +export const getDashboardFilters = (): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + topPackageSelector: { + gridData: { + w: 12, + h: 12, + x: 0, + y: 0, + i: 'topPackageSelector', + }, + type: 'visualization', + explicitInput: { + id: 'topPackageSelector', + savedVis: getVisStateFilter( + 'topPackageSelector', + VULNERABILITIES_INDEX_PATTERN_ID, + 'Top Packages vulnerabilities', + 'Package', + 'package.name', + ), + }, + }, + topOSVulnerabilities: { + gridData: { + w: 12, + h: 12, + x: 12, + y: 0, + i: 'topOSVulnerabilities', + }, + type: 'visualization', + explicitInput: { + id: 'topOSVulnerabilities', + savedVis: getVisStateFilter( + 'topOSVulnerabilities', + VULNERABILITIES_INDEX_PATTERN_ID, + 'Top Operating system vulnerabilities', + 'Operating system', + 'host.os.name', + ), + }, + }, + topAgentVulnerabilities: { + gridData: { + w: 12, + h: 12, + x: 24, + y: 0, + i: 'topAgentVulnerabilities', + }, + type: 'visualization', + explicitInput: { + id: 'topAgentVulnerabilities', + savedVis: getVisStateFilter( + 'topAgentVulnerabilities', + VULNERABILITIES_INDEX_PATTERN_ID, + 'Agent filter', + 'Agent', + 'agent.id', + ), + }, + }, + topVulnerabilities: { + gridData: { + w: 12, + h: 12, + x: 36, + y: 0, + i: 'topVulnerabilities', + }, + type: 'visualization', + explicitInput: { + id: 'topVulnerabilities', + savedVis: getVisStateFilter( + 'topVulnerabilities', + VULNERABILITIES_INDEX_PATTERN_ID, + 'Top vulnerabilities', + 'Vulnerability', + 'vulnerability.id', + ), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_kpis.ts b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_kpis.ts new file mode 100644 index 0000000000..d7950c46bd --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/dashboard_panels_kpis.ts @@ -0,0 +1,415 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../../common/constants'; + +const getVisStateSeverityCritical = (indexPatternId: string) => { + return { + id: 'severity_critical_vulnerabilities', + title: 'Critical', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Reds', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + 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', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"critical"', + language: 'kuery', + }, + label: '- Critical Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityHigh = (indexPatternId: string) => { + return { + id: 'severity_high_vulnerabilities', + title: 'High', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Blues', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + uiState: { + vis: { + colors: { + 'High Severity Alerts - Count': '#38D1BA', + }, + }, + }, + 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', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"high"', + language: 'kuery', + }, + label: '- High Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityMedium = (indexPatternId: string) => { + return { + id: 'severity_medium_vulnerabilities', + title: 'Medium', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Yellow to Red', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: true, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + 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', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"medium"', + language: 'kuery', + }, + label: '- Medium Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityLow = (indexPatternId: string) => { + return { + id: 'severity_low_vulnerabilities', + title: 'Low', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Greens', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + 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', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"low"', + language: 'kuery', + }, + label: '- Low Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +export const getKPIsPanel = (): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + '1': { + gridData: { + w: 12, + h: 6, + x: 0, + y: 0, + i: '1', + }, + type: 'visualization', + explicitInput: { + id: '1', + savedVis: getVisStateSeverityCritical(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + '2': { + gridData: { + w: 12, + h: 6, + x: 12, + y: 0, + i: '2', + }, + type: 'visualization', + explicitInput: { + id: '2', + savedVis: getVisStateSeverityHigh(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + '3': { + gridData: { + w: 12, + h: 6, + x: 24, + y: 0, + i: '3', + }, + type: 'visualization', + explicitInput: { + id: '3', + savedVis: getVisStateSeverityMedium(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + '4': { + gridData: { + w: 12, + h: 6, + x: 36, + y: 0, + i: '4', + }, + type: 'visualization', + explicitInput: { + id: '4', + savedVis: getVisStateSeverityLow(VULNERABILITIES_INDEX_PATTERN_ID), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/index.tsx b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/index.tsx new file mode 100644 index 0000000000..b691822976 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/index.tsx @@ -0,0 +1 @@ +export * from './dashboard'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/vulnerability_detector_filters.scss b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/vulnerability_detector_filters.scss new file mode 100644 index 0000000000..631158a73e --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/dashboards/overview/vulnerability_detector_filters.scss @@ -0,0 +1,6 @@ +.vulnerability-dashboard-filters-wrapper { + .euiDataGrid__controls,.euiDataGrid__pagination { + display: none!important; + } +} + diff --git a/plugins/main/public/components/overview/vulnerabilities/data_grid/use_data_grid.ts b/plugins/main/public/components/overview/vulnerabilities/data_grid/use_data_grid.ts new file mode 100644 index 0000000000..533ac5f7b4 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/data_grid/use_data_grid.ts @@ -0,0 +1,104 @@ +import { EuiDataGridCellValueElementProps, EuiDataGridColumn, EuiDataGridProps, EuiDataGridSorting } from "@elastic/eui" +import { useEffect, useMemo, useState, Fragment } from "react"; +import { SearchResponse } from "@opensearch-project/opensearch/api/types"; +import { IFieldType, IndexPattern } from "../../../../../../../src/plugins/data/common"; +import { parseData, getFieldFormatted } from '../dashboards/inventory/inventory_service'; + +type tDataGridProps = { + indexPattern: IndexPattern; + results: SearchResponse; + defaultColumns: EuiDataGridColumn[]; + DocViewInspectButton: ({ rowIndex }: EuiDataGridCellValueElementProps) => React.JSX.Element + ariaLabelledBy: string; +}; + +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, + }, + }; + }) || []; +} + +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; + return rowsParsed.hasOwnProperty(relativeRowIndex) + ? getFieldFormatted(relativeRowIndex, columnId, indexPattern, rowsParsed) + : 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, + 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/overview/vulnerabilities/doc_viewer/doc_viewer.tsx b/plugins/main/public/components/overview/vulnerabilities/doc_viewer/doc_viewer.tsx new file mode 100644 index 0000000000..08e170f9d5 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/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 function 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/overview/vulnerabilities/doc_viewer/use_doc_viewer.ts b/plugins/main/public/components/overview/vulnerabilities/doc_viewer/use_doc_viewer.ts new file mode 100644 index 0000000000..d38e58bc3a --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/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/overview/vulnerabilities/index.tsx b/plugins/main/public/components/overview/vulnerabilities/index.tsx new file mode 100644 index 0000000000..d46e147b8e --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/index.tsx @@ -0,0 +1 @@ +export * from './dashboards'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/search_bar/index.ts b/plugins/main/public/components/overview/vulnerabilities/search_bar/index.ts new file mode 100644 index 0000000000..22509f1244 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/search_bar/index.ts @@ -0,0 +1 @@ +// searchbar index \ No newline at end of file diff --git a/plugins/main/public/components/overview/vulnerabilities/search_bar/use_search_bar_configuration.test.ts b/plugins/main/public/components/overview/vulnerabilities/search_bar/use_search_bar_configuration.test.ts new file mode 100644 index 0000000000..37685811d8 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/search_bar/use_search_bar_configuration.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_configuration'; +import { getDataPlugin } from '../../../../kibana-services'; +import * as timeFilterHook from '../../../common/hooks/use-time-filter'; +import * as queryManagerHook from '../../../common/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('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/overview/vulnerabilities/search_bar/use_search_bar_configuration.tsx b/plugins/main/public/components/overview/vulnerabilities/search_bar/use_search_bar_configuration.tsx new file mode 100644 index 0000000000..335f17be53 --- /dev/null +++ b/plugins/main/public/components/overview/vulnerabilities/search_bar/use_search_bar_configuration.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { + SearchBarProps, + FilterManager, + TimeRange, + Query, +} from '../../../../../../../src/plugins/data/public'; +import { + Filter, + IIndexPattern, + IndexPatternsContract, +} from '../../../../../../../src/plugins/data/public'; +import { getDataPlugin } from '../../../../kibana-services'; + +import { useFilterManager, useQueryManager, useTimeFilter } from '../../../common/hooks'; +import { AUTHORIZED_AGENTS } from '../../../../../common/constants'; +import { VULNERABILITIES_INDEX_PATTERN_ID } from '../common/constants'; + +// Input - types +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 useSearchBarConfiguration = (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 ?? VULNERABILITIES_INDEX_PATTERN_ID; + /* 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 + 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 useSearchBarConfiguration; diff --git a/plugins/main/public/types.ts b/plugins/main/public/types.ts index 3a75c7fdc6..9a62af2de6 100644 --- a/plugins/main/public/types.ts +++ b/plugins/main/public/types.ts @@ -8,16 +8,18 @@ import { UiActionsSetup } from '../../../src/plugins/ui_actions/public'; import { SecurityOssPluginStart } from '../../../src/plugins/security_oss/public/'; import { SavedObjectsStart } from '../../../src/plugins/saved_objects/public'; import { TelemetryPluginStart, TelemetryPluginSetup } from '../../../src/plugins/telemetry/public'; +import { DashboardStart } from '../../../src/plugins/dashboard/public' export interface AppPluginStartDependencies { navigation: NavigationPublicPluginStart; data: DataPublicPluginStart; visualizations: VisualizationsStart; discover: DiscoverStart; - charts: ChartsPluginStart - securityOss: SecurityOssPluginStart, - savedObjects: SavedObjectsStart, - telemetry: TelemetryPluginStart + charts: ChartsPluginStart; + securityOss: SecurityOssPluginStart; + savedObjects: SavedObjectsStart; + telemetry: TelemetryPluginStart; + dashboard: DashboardStart; } export interface AppDependencies { core: CoreStart;