Merge branch 'master' into eui/33.0

This commit is contained in:
Greg Thompson 2021-05-11 12:43:25 -05:00
commit 9698262124
No known key found for this signature in database
GPG key ID: ED1F695C1077B958
27 changed files with 738 additions and 289 deletions

View file

@ -577,15 +577,11 @@
"deprecated": false,
"children": [
{
"parentPluginId": "spacesOss",
"id": "def-public.SpacesApi.activeSpace$",
"type": "Object",
"tags": [],
"label": "activeSpace$",
"description": [
"\nObservable representing the currently active space.\nThe details of the space can change without a full page reload (such as display name, color, etc.)"
],
"id": "def-public.SpacesApi.getActiveSpace$",
"type": "Function",
"label": "getActiveSpace$",
"signature": [
"() => ",
"Observable",
"<",
{
@ -597,11 +593,16 @@
},
">"
],
"description": [
"\nObservable representing the currently active space.\nThe details of the space can change without a full page reload (such as display name, color, etc.)"
],
"children": [],
"tags": [],
"returnComment": [],
"source": {
"path": "src/plugins/spaces_oss/public/api.ts",
"lineNumber": 22
},
"deprecated": false
}
},
{
"parentPluginId": "spacesOss",

View file

@ -140,7 +140,7 @@ export default function (/* { providerAPI } */) {
-----------
**Services**:::
Services are named singleton values produced by a Service Provider. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services.\
Services are named singleton values produced by a Service Provider. Tests and other services can retrieve service instances by asking for them by name. All functionality except the mocha API is exposed via services.
**Page objects**:::
Page objects are a special type of service that encapsulate behaviors common to a particular page or plugin. When you write your own plugin, youll likely want to add a page object (or several) that describes the common interactions your tests need to execute.

View file

@ -2,7 +2,8 @@
=== TSVB
*TSVB* enables you to visualize the data from multiple data series, supports <<aggregation-reference,
most {es} metric aggregations>>, multiple visualization types, custom functions, and some math. To use *TSVB*, your data must have a date field.
most {es} metric aggregations>>, multiple visualization types, custom functions, and some math.
To create *TSVB* visualization panels, your data must have a time field.
[role="screenshot"]
image::visualize/images/tsvb-screenshot.png[TSVB overview]
@ -17,15 +18,19 @@ Open *TSVB*, then make sure the required settings are configured.
. On the *New visualization* window, click *TSVB*.
. In *TSVB*, click *Panel options*, then make sure the following settings are configured:
. In *TSVB*, click *Panel options*, then specify the required *Data* settings.
* *Index pattern*
* *Time field*
* *Interval*
.. From the *Index pattern* dropdown, select the index pattern you want to visualize.
+
To visualize an {es} index, open the *Index pattern select mode* menu, deselect *Use only {kib} index patterns*, then enter the {es} index.
. Select a *Drop last bucket* option. It is dropped by default because the time filter intersects the time range of the last bucket, but can be enabled to see the partial data.
.. From the *Time field* dropdown, select the field you want to visualize, then enter the field *Interval*.
. In the *Panel filter* field, specify any <<kuery-query, KQL filters>> to select specific documents.
.. Select a *Drop last bucket* option.
+
By default, *TSVB* drops the last bucket because the time filter intersects the time range of the last bucket. To view the partial data, select *No*.
.. In the *Panel filter* field, enter <<kuery-query, KQL filters>> to view specific documents.
[float]
[[configure-the-data-series]]

View file

@ -8,7 +8,7 @@
import { pipe } from 'fp-ts/lib/pipeable';
import { left } from 'fp-ts/lib/Either';
import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils';
import { foldLeftRight, getPaths } from '../test_utils';
import { NonEmptyStringArray } from '.';
describe('non_empty_string_array', () => {

View file

@ -84,7 +84,8 @@ export async function mountApp({
} = pluginsStart;
const spacesApi = pluginsStart.spacesOss?.isSpacesAvailable ? pluginsStart.spacesOss : undefined;
const activeSpaceId = spacesApi && (await spacesApi.activeSpace$.pipe(first()).toPromise())?.id;
const activeSpaceId =
spacesApi && (await spacesApi.getActiveSpace$().pipe(first()).toPromise())?.id;
let globalEmbedSettings: DashboardEmbedSettings | undefined;
const dashboardServices: DashboardAppServices = {

View file

@ -64,6 +64,29 @@ describe('Range filter builder', () => {
});
});
it('should convert strings to numbers if the field is scripted and type number', () => {
const field = getField('script number');
expect(buildRangeFilter(field, { gte: '1', lte: '3' }, indexPattern)).toEqual({
meta: {
field: 'script number',
index: 'id',
params: {},
},
script: {
script: {
lang: 'expression',
source: '(' + field!.script + ')>=gte && (' + field!.script + ')<=lte',
params: {
value: '>=1 <=3',
gte: 1,
lte: 3,
},
},
},
});
});
it('should wrap painless scripts in comparator lambdas', () => {
const field = getField('script date');
const expected =

View file

@ -138,7 +138,10 @@ export const buildRangeFilter = (
};
export const getRangeScript = (field: IFieldType, params: RangeFilterParams) => {
const knownParams = pickBy(params, (val, key: any) => key in operators);
const knownParams = mapValues(
pickBy(params, (val, key: any) => key in operators),
(value) => (field.type === 'number' && typeof value === 'string' ? parseFloat(value) : value)
);
let script = map(
knownParams,
(val: any, key: string) => '(' + field.script + ')' + get(operators, key) + key

View file

@ -124,6 +124,33 @@ describe('DiscoverGrid', () => {
expect(getDisplayedDocNr(component)).toBe(5);
});
test('showing selected documents, underlying data changes, all documents are displayed, selection is gone', async () => {
await toggleDocSelection(component, esHits[0]);
await toggleDocSelection(component, esHits[1]);
expect(getSelectedDocNr(component)).toBe(2);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');
findTestSubject(component, 'dscGridShowSelectedDocuments').simulate('click');
expect(getDisplayedDocNr(component)).toBe(2);
component.setProps({
rows: [
{
_index: 'i',
_id: '6',
_score: 1,
_type: '_doc',
_source: {
date: '2020-20-02T12:12:12.128',
name: 'test6',
extension: 'doc',
bytes: 50,
},
},
],
});
expect(getDisplayedDocNr(component)).toBe(1);
expect(getSelectedDocNr(component)).toBe(0);
});
test('showing only selected documents and remove filter deselecting each doc manually', async () => {
await toggleDocSelection(component, esHits[0]);
findTestSubject(component, 'dscGridSelectionBtn').simulate('click');

View file

@ -164,17 +164,33 @@ export const DiscoverGrid = ({
const [isFilterActive, setIsFilterActive] = useState(false);
const displayedColumns = getDisplayedColumns(columns, indexPattern);
const defaultColumns = displayedColumns.includes('_source');
const usedSelectedDocs = useMemo(() => {
if (!selectedDocs.length || !rows?.length) {
return [];
}
const idMap = rows.reduce((map, row) => map.set(getDocId(row), true), new Map());
// filter out selected docs that are no longer part of the current data
const result = selectedDocs.filter((docId) => idMap.get(docId));
if (result.length === 0 && isFilterActive) {
setIsFilterActive(false);
}
return result;
}, [selectedDocs, rows, isFilterActive]);
const displayedRows = useMemo(() => {
if (!rows) {
return [];
}
if (!isFilterActive || selectedDocs.length === 0) {
if (!isFilterActive || usedSelectedDocs.length === 0) {
return rows;
}
return rows.filter((row) => {
return selectedDocs.includes(getDocId(row));
});
}, [rows, selectedDocs, isFilterActive]);
const rowsFiltered = rows.filter((row) => usedSelectedDocs.includes(getDocId(row)));
if (!rowsFiltered.length) {
// in case the selected docs are no longer part of the sample of 500, show all docs
return rows;
}
return rowsFiltered;
}, [rows, usedSelectedDocs, isFilterActive]);
/**
* Pagination
@ -258,16 +274,16 @@ export const DiscoverGrid = ({
const additionalControls = useMemo(
() =>
selectedDocs.length ? (
usedSelectedDocs.length ? (
<DiscoverGridDocumentToolbarBtn
isFilterActive={isFilterActive}
rows={rows!}
selectedDocs={selectedDocs}
selectedDocs={usedSelectedDocs}
setSelectedDocs={setSelectedDocs}
setIsFilterActive={setIsFilterActive}
/>
) : null,
[selectedDocs, isFilterActive, rows, setIsFilterActive]
[usedSelectedDocs, isFilterActive, rows, setIsFilterActive]
);
if (!rowCount) {
@ -291,7 +307,7 @@ export const DiscoverGrid = ({
onFilter,
indexPattern,
isDarkMode: services.uiSettings.get('theme:darkMode'),
selectedDocs,
selectedDocs: usedSelectedDocs,
setSelectedDocs: (newSelectedDocs) => {
setSelectedDocs(newSelectedDocs);
if (isFilterActive && newSelectedDocs.length === 0) {

View file

@ -11,7 +11,7 @@ import { of } from 'rxjs';
import type { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api';
const createApiMock = (): jest.Mocked<SpacesApi> => ({
activeSpace$: of(),
getActiveSpace$: jest.fn().mockReturnValue(of()),
getActiveSpace: jest.fn(),
ui: createApiUiMock(),
});

View file

@ -19,7 +19,7 @@ export interface SpacesApi {
* Observable representing the currently active space.
* The details of the space can change without a full page reload (such as display name, color, etc.)
*/
readonly activeSpace$: Observable<Space>;
getActiveSpace$(): Observable<Space>;
/**
* Retrieve the currently active space.

View file

@ -128,6 +128,10 @@ describe('SearchUILogic', () => {
validFields: ['test'],
validSortFields: ['test'],
validFacetFields: ['test'],
defaultValues: {
urlField: 'url',
titleField: 'title',
},
};
describe('loadFieldData', () => {
@ -142,7 +146,13 @@ describe('SearchUILogic', () => {
expect(http.get).toHaveBeenCalledWith(
'/api/app_search/engines/engine1/search_ui/field_config'
);
expect(SearchUILogic.actions.onFieldDataLoaded).toHaveBeenCalledWith(MOCK_RESPONSE);
expect(SearchUILogic.actions.onFieldDataLoaded).toHaveBeenCalledWith({
validFields: ['test'],
validSortFields: ['test'],
validFacetFields: ['test'],
urlField: 'url',
titleField: 'title',
});
});
it('handles errors', async () => {

View file

@ -17,6 +17,8 @@ interface InitialFieldValues {
validFields: string[];
validSortFields: string[];
validFacetFields: string[];
urlField?: string;
titleField?: string;
}
interface SearchUIActions {
loadFieldData(): void;
@ -61,8 +63,20 @@ export const SearchUILogic = kea<MakeLogicType<SearchUIValues, SearchUIActions>>
validFields: [[], { onFieldDataLoaded: (_, { validFields }) => validFields }],
validSortFields: [[], { onFieldDataLoaded: (_, { validSortFields }) => validSortFields }],
validFacetFields: [[], { onFieldDataLoaded: (_, { validFacetFields }) => validFacetFields }],
titleField: ['', { onTitleFieldChange: (_, { titleField }) => titleField }],
urlField: ['', { onUrlFieldChange: (_, { urlField }) => urlField }],
titleField: [
'',
{
onTitleFieldChange: (_, { titleField }) => titleField,
onFieldDataLoaded: (_, { titleField }) => titleField || '',
},
],
urlField: [
'',
{
onUrlFieldChange: (_, { urlField }) => urlField,
onFieldDataLoaded: (_, { urlField }) => urlField || '',
},
],
facetFields: [[], { onFacetFieldsChange: (_, { facetFields }) => facetFields }],
sortFields: [[], { onSortFieldsChange: (_, { sortFields }) => sortFields }],
activeField: [ActiveField.None, { onActiveFieldChange: (_, { activeField }) => activeField }],
@ -76,8 +90,20 @@ export const SearchUILogic = kea<MakeLogicType<SearchUIValues, SearchUIActions>>
try {
const initialFieldValues = await http.get(url);
const {
defaultValues: { urlField, titleField },
validFields,
validSortFields,
validFacetFields,
} = initialFieldValues;
actions.onFieldDataLoaded(initialFieldValues);
actions.onFieldDataLoaded({
validFields,
validSortFields,
validFacetFields,
urlField,
titleField,
});
} catch (e) {
flashAPIErrors(e);
}

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
FIELD_ORIGIN,
SOURCE_TYPES,
STYLE_TYPE,
COLOR_MAP_TYPE,
} from '../../../../../../../maps/common/constants';
import { EMSTermJoinConfig } from '../../../../../../../maps/public';
import { FieldVisStats } from '../../types';
import { VectorLayerDescriptor } from '../../../../../../../maps/common/descriptor_types';
import { EmbeddedMapComponent } from '../../../embedded_map';
export const getChoroplethTopValuesLayer = (
fieldName: string,
topValues: Array<{ key: any; doc_count: number }>,
{ layerId, field }: EMSTermJoinConfig
): VectorLayerDescriptor => {
return {
id: htmlIdGenerator()(),
label: i18n.translate('xpack.fileDataVisualizer.choroplethMap.topValuesCount', {
defaultMessage: 'Top values count for {fieldName}',
values: { fieldName },
}),
joins: [
{
// Left join is the id from the type of field (e.g. world_countries)
leftField: field,
right: {
id: 'anomaly_count',
type: SOURCE_TYPES.TABLE_SOURCE,
__rows: topValues,
__columns: [
{
name: 'key',
type: 'string',
},
{
name: 'doc_count',
type: 'number',
},
],
// Right join/term is the field in the doc youre trying to join it to (foreign key - e.g. US)
term: 'key',
},
},
],
sourceDescriptor: {
type: 'EMS_FILE',
id: layerId,
},
style: {
type: 'VECTOR',
// @ts-ignore missing style properties. Remove once 'VectorLayerDescriptor' type is updated
properties: {
icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } },
fillColor: {
type: STYLE_TYPE.DYNAMIC,
options: {
color: 'Blue to Red',
colorCategory: 'palette_0',
fieldMetaOptions: { isEnabled: true, sigma: 3 },
type: COLOR_MAP_TYPE.ORDINAL,
field: {
name: 'doc_count',
origin: FIELD_ORIGIN.JOIN,
},
useCustomColorRamp: false,
},
},
lineColor: {
type: STYLE_TYPE.DYNAMIC,
options: { fieldMetaOptions: { isEnabled: true } },
},
lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } },
},
isTimeAware: true,
},
type: 'VECTOR',
};
};
interface Props {
stats: FieldVisStats | undefined;
suggestion: EMSTermJoinConfig;
}
export const ChoroplethMap: FC<Props> = ({ stats, suggestion }) => {
const { fieldName, isTopValuesSampled, topValues, topValuesSamplerShardSize } = stats!;
const layerList: VectorLayerDescriptor[] = useMemo(
() => [getChoroplethTopValuesLayer(fieldName || '', topValues || [], suggestion)],
[suggestion, fieldName, topValues]
);
return (
<EuiFlexItem data-test-subj={'fileDataVisualizerChoroplethMapTopValues'}>
<div style={{ width: '100%', minHeight: 300 }}>
<EmbeddedMapComponent layerList={layerList} />
</div>
{isTopValuesSampled === true && (
<>
<EuiSpacer size="xs" />
<EuiText size="xs" textAlign={'left'}>
<FormattedMessage
id="xpack.fileDataVisualizer.fieldDataCardExpandedRow.choroplethMapTopValues.calculatedFromSampleDescription"
defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard"
values={{
topValuesSamplerShardSize,
}}
/>
</EuiText>
</>
)}
</EuiFlexItem>
);
};

View file

@ -5,21 +5,55 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import type { FieldDataRowProps } from '../../types/field_data_row';
import { TopValues } from '../../../top_values';
import { EMSTermJoinConfig } from '../../../../../../../maps/public';
import { useFileDataVisualizerKibana } from '../../../../kibana_context';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
import { ChoroplethMap } from './choropleth_map';
const COMMON_EMS_LAYER_IDS = [
'world_countries',
'administrative_regions_lvl2',
'usa_zip_codes',
'usa_states',
];
export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => {
const { stats } = config;
const [EMSSuggestion, setEMSSuggestion] = useState<EMSTermJoinConfig | null | undefined>();
const { stats, fieldName } = config;
const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined;
const {
services: { maps: mapsPlugin },
} = useFileDataVisualizerKibana();
const loadEMSTermSuggestions = useCallback(async () => {
if (!mapsPlugin) return;
const suggestion: EMSTermJoinConfig | null = await mapsPlugin.suggestEMSTermJoinConfig({
emsLayerIds: COMMON_EMS_LAYER_IDS,
sampleValues: Array.isArray(stats?.topValues)
? stats?.topValues.map((value) => value.key)
: [],
sampleValuesColumnName: fieldName || '',
});
setEMSSuggestion(suggestion);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldName]);
useEffect(
function getInitialEMSTermSuggestion() {
loadEMSTermSuggestions();
},
[loadEMSTermSuggestions]
);
return (
<ExpandedRowContent dataTestSubj={'mlDVKeywordContent'}>
<DocumentStatsTable config={config} />
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" />
{EMSSuggestion && stats && <ChoroplethMap stats={stats} suggestion={EMSSuggestion} />}
</ExpandedRowContent>
);
};

View file

@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import useInterval from 'react-use/lib/useInterval';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { SavedView } from '../../../../containers/saved_view/saved_view';
import { AutoSizer } from '../../../../components/auto_sizer';
import { convertIntervalToString } from '../../../../utils/convert_interval_to_string';
import { NodesOverview } from './nodes_overview';
@ -26,182 +27,191 @@ import { ViewSwitcher } from './waffle/view_switcher';
import { IntervalLabel } from './waffle/interval_label';
import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter';
import { createLegend } from '../lib/create_legend';
import { useSavedViewContext } from '../../../../containers/saved_view/saved_view';
import { useWaffleViewState } from '../hooks/use_waffle_view_state';
import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control';
import { BottomDrawer } from './bottom_drawer';
import { Legend } from './waffle/legend';
export const Layout = () => {
const [showLoading, setShowLoading] = useState(true);
const { sourceId, source } = useSourceContext();
const { currentView, shouldLoadDefault } = useSavedViewContext();
const {
metric,
groupBy,
sort,
nodeType,
accountId,
region,
changeView,
view,
autoBounds,
boundsOverride,
legend,
} = useWaffleOptionsContext();
const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext();
const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext();
const { loading, nodes, reload, interval } = useSnapshot(
filterQueryAsJson,
[metric],
groupBy,
nodeType,
sourceId,
currentTime,
accountId,
region,
false
);
export const Layout = React.memo(
({
shouldLoadDefault,
currentView,
}: {
shouldLoadDefault: boolean;
currentView: SavedView<any> | null;
}) => {
const [showLoading, setShowLoading] = useState(true);
const { sourceId, source } = useSourceContext();
const {
metric,
groupBy,
sort,
nodeType,
accountId,
region,
changeView,
view,
autoBounds,
boundsOverride,
legend,
} = useWaffleOptionsContext();
const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext();
const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext();
const { loading, nodes, reload, interval } = useSnapshot(
filterQueryAsJson,
[metric],
groupBy,
nodeType,
sourceId,
currentTime,
accountId,
region,
false
);
const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette;
const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps;
const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors;
const legendPalette = legend?.palette ?? DEFAULT_LEGEND.palette;
const legendSteps = legend?.steps ?? DEFAULT_LEGEND.steps;
const legendReverseColors = legend?.reverseColors ?? DEFAULT_LEGEND.reverseColors;
const options = {
formatter: InfraFormatterType.percent,
formatTemplate: '{{value}}',
legend: createLegend(legendPalette, legendSteps, legendReverseColors),
metric,
sort,
fields: source?.configuration?.fields,
groupBy,
};
const options = {
formatter: InfraFormatterType.percent,
formatTemplate: '{{value}}',
legend: createLegend(legendPalette, legendSteps, legendReverseColors),
metric,
sort,
fields: source?.configuration?.fields,
groupBy,
};
useInterval(
() => {
if (!loading) {
jumpToTime(Date.now());
}
},
isAutoReloading ? 5000 : null
);
useInterval(
() => {
if (!loading) {
jumpToTime(Date.now());
}
},
isAutoReloading ? 5000 : null
);
const intervalAsString = convertIntervalToString(interval);
const dataBounds = calculateBoundsFromNodes(nodes);
const bounds = autoBounds ? dataBounds : boundsOverride;
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]);
const { viewState, onViewChange } = useWaffleViewState();
useEffect(() => {
if (currentView) {
onViewChange(currentView);
}
}, [currentView, onViewChange]);
useEffect(() => {
// load snapshot data after default view loaded, unless we're not loading a view
if (currentView != null || !shouldLoadDefault) {
reload();
}
/**
* INFO: why disable exhaustive-deps
* We need to wait on the currentView not to be null because it is loaded async and could change the view state.
* We don't actually need to watch the value of currentView though, since the view state will be synched up by the
* changing params in the reload method so we should only "watch" the reload method.
*
* TODO: Should refactor this in the future to make it more clear where all the view state is coming
* from and it's precedence [query params, localStorage, defaultView, out of the box view]
*/
const intervalAsString = convertIntervalToString(interval);
const dataBounds = calculateBoundsFromNodes(nodes);
const bounds = autoBounds ? dataBounds : boundsOverride;
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [reload, shouldLoadDefault]);
const formatter = useCallback(createInventoryMetricFormatter(options.metric), [options.metric]);
const { viewState, onViewChange } = useWaffleViewState();
useEffect(() => {
setShowLoading(true);
}, [options.metric, nodeType]);
useEffect(() => {
if (currentView) {
onViewChange(currentView);
}
}, [currentView, onViewChange]);
useEffect(() => {
const hasNodes = nodes && nodes.length;
// Don't show loading screen when we're auto-reloading
setShowLoading(!hasNodes);
}, [nodes]);
useEffect(() => {
// load snapshot data after default view loaded, unless we're not loading a view
if (currentView != null || !shouldLoadDefault) {
reload();
}
return (
<>
<PageContent>
<AutoSizer bounds>
{({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => (
<MainContainer ref={pageMeasureRef}>
<AutoSizer bounds>
{({ measureRef: topActionMeasureRef, bounds: { height: topActionHeight = 0 } }) => (
<>
<TopActionContainer ref={topActionMeasureRef}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="m"
>
<Toolbar nodeType={nodeType} currentTime={currentTime} />
<EuiFlexItem grow={false}>
<IntervalLabel intervalAsString={intervalAsString} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewSwitcher view={view} onChange={changeView} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<SavedViewContainer>
<SavedViewsToolbarControls viewState={viewState} />
</SavedViewContainer>
</TopActionContainer>
<AutoSizer bounds>
{({ measureRef, bounds: { height = 0 } }) => (
<>
<NodesOverview
nodes={nodes}
options={options}
nodeType={nodeType}
loading={loading}
showLoading={showLoading}
reload={reload}
onDrilldown={applyFilterQuery}
currentTime={currentTime}
view={view}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
formatter={formatter}
bottomMargin={height}
topMargin={topActionHeight}
/>
{view === 'map' && (
<BottomDrawer
measureRef={measureRef}
interval={interval}
/**
* INFO: why disable exhaustive-deps
* We need to wait on the currentView not to be null because it is loaded async and could change the view state.
* We don't actually need to watch the value of currentView though, since the view state will be synched up by the
* changing params in the reload method so we should only "watch" the reload method.
*
* TODO: Should refactor this in the future to make it more clear where all the view state is coming
* from and it's precedence [query params, localStorage, defaultView, out of the box view]
*/
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [reload, shouldLoadDefault]);
useEffect(() => {
setShowLoading(true);
}, [options.metric, nodeType]);
useEffect(() => {
const hasNodes = nodes && nodes.length;
// Don't show loading screen when we're auto-reloading
setShowLoading(!hasNodes);
}, [nodes]);
return (
<>
<PageContent>
<AutoSizer bounds>
{({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => (
<MainContainer ref={pageMeasureRef}>
<AutoSizer bounds>
{({
measureRef: topActionMeasureRef,
bounds: { height: topActionHeight = 0 },
}) => (
<>
<TopActionContainer ref={topActionMeasureRef}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="m"
>
<Toolbar nodeType={nodeType} currentTime={currentTime} />
<EuiFlexItem grow={false}>
<IntervalLabel intervalAsString={intervalAsString} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewSwitcher view={view} onChange={changeView} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<SavedViewContainer>
<SavedViewsToolbarControls viewState={viewState} />
</SavedViewContainer>
</TopActionContainer>
<AutoSizer bounds>
{({ measureRef, bounds: { height = 0 } }) => (
<>
<NodesOverview
nodes={nodes}
options={options}
nodeType={nodeType}
loading={loading}
showLoading={showLoading}
reload={reload}
onDrilldown={applyFilterQuery}
currentTime={currentTime}
view={view}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
formatter={formatter}
width={width}
>
<Legend
bottomMargin={height}
topMargin={topActionHeight}
/>
{view === 'map' && (
<BottomDrawer
measureRef={measureRef}
interval={interval}
formatter={formatter}
bounds={bounds}
dataBounds={dataBounds}
legend={options.legend}
/>
</BottomDrawer>
)}
</>
)}
</AutoSizer>
</>
)}
</AutoSizer>
</MainContainer>
)}
</AutoSizer>
</PageContent>
</>
);
};
width={width}
>
<Legend
formatter={formatter}
bounds={bounds}
dataBounds={dataBounds}
legend={options.legend}
/>
</BottomDrawer>
)}
</>
)}
</AutoSizer>
</>
)}
</AutoSizer>
</MainContainer>
)}
</AutoSizer>
</PageContent>
</>
);
}
);
const MainContainer = euiStyled.div`
position: relative;

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useSavedViewContext } from '../../../../containers/saved_view/saved_view';
import { Layout } from './layout';
export const LayoutView = () => {
const { shouldLoadDefault, currentView } = useSavedViewContext();
return <Layout shouldLoadDefault={shouldLoadDefault} currentView={currentView} />;
};

View file

@ -21,7 +21,7 @@ import { ViewSourceConfigurationButton } from '../../../components/source_config
import { Source } from '../../../containers/metrics_source';
import { useTrackPageview } from '../../../../../observability/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { Layout } from './components/layout';
import { LayoutView } from './components/layout_view';
import { useLinkProps } from '../../../hooks/use_link_props';
import { SavedViewProvider } from '../../../containers/saved_view/saved_view';
import { DEFAULT_WAFFLE_VIEW_STATE } from './hooks/use_waffle_view_state';
@ -69,7 +69,7 @@ export const SnapshotPage = () => {
viewType={'inventory-view'}
defaultViewState={DEFAULT_WAFFLE_VIEW_STATE}
>
<Layout />
<LayoutView />
</SavedViewProvider>
</>
) : hasFailedLoadingSource ? (

View file

@ -782,7 +782,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer {
}
const filterExpr = getPointFilterExpression(this.hasJoins());
if (filterExpr !== mbMap.getFilter(pointLayerId)) {
if (!_.isEqual(filterExpr, mbMap.getFilter(pointLayerId))) {
mbMap.setFilter(pointLayerId, filterExpr);
mbMap.setFilter(textLayerId, filterExpr);
}
@ -818,7 +818,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer {
}
const filterExpr = getPointFilterExpression(this.hasJoins());
if (filterExpr !== mbMap.getFilter(symbolLayerId)) {
if (!_.isEqual(filterExpr, mbMap.getFilter(symbolLayerId))) {
mbMap.setFilter(symbolLayerId, filterExpr);
}
@ -900,14 +900,14 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer {
this.syncVisibilityWithMb(mbMap, fillLayerId);
mbMap.setLayerZoomRange(fillLayerId, this.getMinZoom(), this.getMaxZoom());
const fillFilterExpr = getFillFilterExpression(hasJoins);
if (fillFilterExpr !== mbMap.getFilter(fillLayerId)) {
if (!_.isEqual(fillFilterExpr, mbMap.getFilter(fillLayerId))) {
mbMap.setFilter(fillLayerId, fillFilterExpr);
}
this.syncVisibilityWithMb(mbMap, lineLayerId);
mbMap.setLayerZoomRange(lineLayerId, this.getMinZoom(), this.getMaxZoom());
const lineFilterExpr = getLineFilterExpression(hasJoins);
if (lineFilterExpr !== mbMap.getFilter(lineLayerId)) {
if (!_.isEqual(lineFilterExpr, mbMap.getFilter(lineLayerId))) {
mbMap.setFilter(lineLayerId, lineFilterExpr);
}
@ -931,7 +931,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer {
}
const filterExpr = getCentroidFilterExpression(this.hasJoins());
if (filterExpr !== mbMap.getFilter(centroidLayerId)) {
if (!_.isEqual(filterExpr, mbMap.getFilter(centroidLayerId))) {
mbMap.setFilter(centroidLayerId, filterExpr);
}

View file

@ -15,61 +15,55 @@ import {
export const EXCLUDE_TOO_MANY_FEATURES_BOX = ['!=', ['get', KBN_TOO_MANY_FEATURES_PROPERTY], true];
const EXCLUDE_CENTROID_FEATURES = ['!=', ['get', KBN_IS_CENTROID_FEATURE], true];
const VISIBILITY_FILTER_CLAUSE = ['all', ['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]];
// Kibana features are features added by kibana that do not exist in real data
const EXCLUDE_KBN_FEATURES = ['all', EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES];
function getFilterExpression(geometryFilter: unknown[], hasJoins: boolean) {
const filters: unknown[] = [
EXCLUDE_TOO_MANY_FEATURES_BOX,
EXCLUDE_CENTROID_FEATURES,
geometryFilter,
];
const CLOSED_SHAPE_MB_FILTER = [
...EXCLUDE_KBN_FEATURES,
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON],
],
];
if (hasJoins) {
filters.push(['==', ['get', FEATURE_VISIBLE_PROPERTY_NAME], true]);
}
const VISIBLE_CLOSED_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CLOSED_SHAPE_MB_FILTER];
const ALL_SHAPE_MB_FILTER = [
...EXCLUDE_KBN_FEATURES,
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING],
],
];
const VISIBLE_ALL_SHAPE_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, ALL_SHAPE_MB_FILTER];
const POINT_MB_FILTER = [
...EXCLUDE_KBN_FEATURES,
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POINT],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT],
],
];
const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER];
const CENTROID_MB_FILTER = ['all', ['==', ['get', KBN_IS_CENTROID_FEATURE], true]];
const VISIBLE_CENTROID_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, CENTROID_MB_FILTER];
return ['all', ...filters];
}
export function getFillFilterExpression(hasJoins: boolean): unknown[] {
return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER;
return getFilterExpression(
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON],
],
hasJoins
);
}
export function getLineFilterExpression(hasJoins: boolean): unknown[] {
return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER;
return getFilterExpression(
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POLYGON],
['==', ['geometry-type'], GEO_JSON_TYPE.LINE_STRING],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_LINE_STRING],
],
hasJoins
);
}
export function getPointFilterExpression(hasJoins: boolean): unknown[] {
return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER;
return getFilterExpression(
[
'any',
['==', ['geometry-type'], GEO_JSON_TYPE.POINT],
['==', ['geometry-type'], GEO_JSON_TYPE.MULTI_POINT],
],
hasJoins
);
}
export function getCentroidFilterExpression(hasJoins: boolean): unknown[] {
return hasJoins ? VISIBLE_CENTROID_MB_FILTER : CENTROID_MB_FILTER;
return getFilterExpression(['==', ['get', KBN_IS_CENTROID_FEATURE], true], hasJoins);
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const COMMON_EMS_LAYER_IDS = [
'world_countries',
'administrative_regions_lvl2',
'usa_zip_codes',
'usa_states',
];

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { EuiFlexItem, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
FIELD_ORIGIN,
SOURCE_TYPES,
STYLE_TYPE,
COLOR_MAP_TYPE,
} from '../../../../../../../../maps/common/constants';
import { EMSTermJoinConfig } from '../../../../../../../../maps/public';
import { FieldVisStats } from '../../../../stats_table/types';
import { VectorLayerDescriptor } from '../../../../../../../../maps/common/descriptor_types';
import { MlEmbeddedMapComponent } from '../../../../../components/ml_embedded_map';
export const getChoroplethTopValuesLayer = (
fieldName: string,
topValues: Array<{ key: any; doc_count: number }>,
{ layerId, field }: EMSTermJoinConfig
): VectorLayerDescriptor => {
return {
id: htmlIdGenerator()(),
label: i18n.translate('xpack.ml.dataviz.choroplethMap.topValuesCount', {
defaultMessage: 'Top values count for {fieldName}',
values: { fieldName },
}),
joins: [
{
// Left join is the id from the type of field (e.g. world_countries)
leftField: field,
right: {
id: 'anomaly_count',
type: SOURCE_TYPES.TABLE_SOURCE,
__rows: topValues,
__columns: [
{
name: 'key',
type: 'string',
},
{
name: 'doc_count',
type: 'number',
},
],
// Right join/term is the field in the doc youre trying to join it to (foreign key - e.g. US)
term: 'key',
},
},
],
sourceDescriptor: {
type: 'EMS_FILE',
id: layerId,
},
style: {
type: 'VECTOR',
// @ts-ignore missing style properties. Remove once 'VectorLayerDescriptor' type is updated
properties: {
icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } },
fillColor: {
type: STYLE_TYPE.DYNAMIC,
options: {
color: 'Blue to Red',
colorCategory: 'palette_0',
fieldMetaOptions: { isEnabled: true, sigma: 3 },
type: COLOR_MAP_TYPE.ORDINAL,
field: {
name: 'doc_count',
origin: FIELD_ORIGIN.JOIN,
},
useCustomColorRamp: false,
},
},
lineColor: {
type: STYLE_TYPE.DYNAMIC,
options: { fieldMetaOptions: { isEnabled: true } },
},
lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } },
},
isTimeAware: true,
},
type: 'VECTOR',
};
};
interface Props {
stats: FieldVisStats | undefined;
suggestion: EMSTermJoinConfig;
}
export const ChoroplethMap: FC<Props> = ({ stats, suggestion }) => {
const { fieldName, isTopValuesSampled, topValues, topValuesSamplerShardSize } = stats!;
const layerList: VectorLayerDescriptor[] = useMemo(
() => [getChoroplethTopValuesLayer(fieldName || '', topValues || [], suggestion)],
[suggestion, stats]
);
return (
<EuiFlexItem data-test-subj={'mlChoroplethMapTopValues'}>
<div style={{ width: '100%', minHeight: 300 }}>
<MlEmbeddedMapComponent layerList={layerList} />
</div>
{isTopValuesSampled === true && (
<>
<EuiSpacer size="xs" />
<EuiText size="xs" textAlign={'left'}>
<FormattedMessage
id="xpack.ml.fieldDataCard.choroplethMapTopValues.calculatedFromSampleDescription"
defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard"
values={{
topValuesSamplerShardSize,
}}
/>
</EuiText>
</>
)}
</EuiFlexItem>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ChoroplethMap } from './choropleth_map';

View file

@ -5,21 +5,50 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { FC, useEffect, useState } from 'react';
import type { FieldDataRowProps } from '../../types/field_data_row';
import { TopValues } from '../../../index_based/components/field_data_row/top_values';
import { ChoroplethMap } from '../../../index_based/components/field_data_row/choropleth_map';
import { useMlKibana } from '../../../../../application/contexts/kibana';
import { EMSTermJoinConfig } from '../../../../../../../maps/public';
import { COMMON_EMS_LAYER_IDS } from '../../../../../../common/constants/embeddable_map';
import { DocumentStatsTable } from './document_stats';
import { ExpandedRowContent } from './expanded_row_content';
export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => {
const { stats } = config;
const [EMSSuggestion, setEMSSuggestion] = useState<EMSTermJoinConfig | null | undefined>();
const { stats, fieldName } = config;
const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined;
const {
services: { maps: mapsPlugin },
} = useMlKibana();
const loadEMSTermSuggestions = async () => {
if (!mapsPlugin) return;
const suggestion: EMSTermJoinConfig | null = await mapsPlugin.suggestEMSTermJoinConfig({
emsLayerIds: COMMON_EMS_LAYER_IDS,
sampleValues: Array.isArray(stats?.topValues)
? stats?.topValues.map((value) => value.key)
: [],
sampleValuesColumnName: fieldName || '',
});
setEMSSuggestion(suggestion);
};
useEffect(
function getInitialEMSTermSuggestion() {
loadEMSTermSuggestions();
},
[config?.fieldName]
);
return (
<ExpandedRowContent dataTestSubj={'mlDVKeywordContent'}>
<DocumentStatsTable config={config} />
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" />
{EMSSuggestion && stats && <ChoroplethMap stats={stats} suggestion={EMSSuggestion} />}
{EMSSuggestion === null && (
<TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" />
)}
</ExpandedRowContent>
);
};

View file

@ -28,14 +28,9 @@ import { isDefined } from '../../../common/types/guards';
import { MlEmbeddedMapComponent } from '../components/ml_embedded_map';
import { EMSTermJoinConfig } from '../../../../maps/public';
import { AnomaliesTableRecord } from '../../../common/types/anomalies';
import { COMMON_EMS_LAYER_IDS } from '../../../common/constants/embeddable_map';
const MAX_ENTITY_VALUES = 3;
const COMMON_EMS_LAYER_IDS = [
'world_countries',
'administrative_regions_lvl2',
'usa_zip_codes',
'usa_states',
];
function getAnomalyRows(anomalies: AnomaliesTableRecord[], jobId: string) {
const anomalyRows: Record<

View file

@ -59,7 +59,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
spacesManager: this.spacesManager,
getStartServices: core.getStartServices,
}),
activeSpace$: this.spacesManager.onActiveSpaceChange$,
getActiveSpace$: () => this.spacesManager.onActiveSpaceChange$,
getActiveSpace: () => this.spacesManager.getActiveSpace(),
};

View file

@ -40,13 +40,10 @@ export default function ({ getPageObjects, getService }) {
maxzoom: 24,
filter: [
'all',
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']],
['==', ['get', '__kbn_isvisibleduetojoin__'], true],
[
'all',
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']],
],
],
layout: { visibility: 'visible' },
paint: {
@ -124,17 +121,10 @@ export default function ({ getPageObjects, getService }) {
maxzoom: 24,
filter: [
'all',
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']],
['==', ['get', '__kbn_isvisibleduetojoin__'], true],
[
'all',
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
[
'any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon'],
],
],
],
layout: { visibility: 'visible' },
paint: {
@ -208,19 +198,16 @@ export default function ({ getPageObjects, getService }) {
maxzoom: 24,
filter: [
'all',
['==', ['get', '__kbn_isvisibleduetojoin__'], true],
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
[
'all',
['!=', ['get', '__kbn_too_many_features__'], true],
['!=', ['get', '__kbn_is_centroid_feature__'], true],
[
'any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon'],
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString'],
],
'any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon'],
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString'],
],
['==', ['get', '__kbn_isvisibleduetojoin__'], true],
],
layout: { visibility: 'visible' },
paint: { 'line-color': '#41937c', 'line-opacity': 0.75, 'line-width': 1 },