mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
## Summary Adds Machine Learning Anomaly Table to Host Details and Network Details. Filters them down to the specific host name and specific network ip of the details <img width="1677" alt="Screen Shot 2019-07-02 at 6 40 42 PM" src="https://user-images.githubusercontent.com/1151048/60555277-3d417300-9cf9-11e9-83b7-7c78ed65cd27.png"> <img width="1024" alt="Screen Shot 2019-07-02 at 6 40 27 PM" src="https://user-images.githubusercontent.com/1151048/60555308-5d713200-9cf9-11e9-88b5-f629169e47ce.png"> ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) - [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
This commit is contained in:
parent
7eabd2a069
commit
d5246141c6
25 changed files with 644 additions and 59 deletions
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`create_influencers renders correctly against snapshot 1`] = `
|
||||
exports[`entity_draggable renders correctly against snapshot 1`] = `
|
||||
<Connect(DraggableWrapperComponent)
|
||||
dataProvider={
|
||||
Object {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { shallow, mount } from 'enzyme';
|
|||
import { EntityDraggable } from './entity_draggable';
|
||||
import { TestProviders } from '../../mock/test_providers';
|
||||
|
||||
describe('create_influencers', () => {
|
||||
describe('entity_draggable', () => {
|
||||
test('renders correctly against snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<EntityDraggable idPrefix="id-prefix" entityName="entity-name" entityValue="entity-value" />
|
||||
|
@ -24,6 +24,6 @@ describe('create_influencers', () => {
|
|||
<EntityDraggable idPrefix="id-prefix" entityName="entity-name" entityValue="entity-value" />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.text()).toEqual('entity-name: “entity-value”');
|
||||
expect(wrapper.text()).toEqual('entity-name: "entity-value"');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -41,7 +41,7 @@ export const EntityDraggable = React.memo<Props>(
|
|||
<Provider dataProvider={dataProvider} />
|
||||
</DragEffects>
|
||||
) : (
|
||||
<>{`${entityName}: “${entityValue}”`}</>
|
||||
<>{`${entityName}: "${entityValue}"`}</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getEntries } from './get_entries';
|
||||
|
||||
describe('get_entries', () => {
|
||||
test('returns null if the entries is an empty object', () => {
|
||||
const [key, value] = getEntries({});
|
||||
expect(key).toEqual(null);
|
||||
expect(value).toEqual(null);
|
||||
});
|
||||
|
||||
test('returns key value if the entries as a key value', () => {
|
||||
const [key, value] = getEntries({ 'host.name': 'some-host-value' });
|
||||
expect(key).toEqual('host.name');
|
||||
expect(value).toEqual('some-host-value');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export const getEntries = (
|
||||
entityOrInfluencer: Record<string, string>
|
||||
): [string, string] | [null, null] => {
|
||||
const entries = Object.entries(entityOrInfluencer);
|
||||
if (Array.isArray(entries[0])) {
|
||||
const [[key, value]] = entries;
|
||||
return [key, value];
|
||||
} else {
|
||||
return [null, null];
|
||||
}
|
||||
};
|
|
@ -4,21 +4,21 @@ exports[`create_influencers renders correctly against snapshot 1`] = `
|
|||
<span>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="host.name: “zeek-iowa”"
|
||||
key="host.name: \\"zeek-iowa\\""
|
||||
>
|
||||
host.name: “zeek-iowa”
|
||||
host.name: "zeek-iowa"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="process.name: “du”"
|
||||
key="process.name: \\"du\\""
|
||||
>
|
||||
process.name: “du”
|
||||
process.name: "du"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="user.name: “root”"
|
||||
key="user.name: \\"root\\""
|
||||
>
|
||||
user.name: “root”
|
||||
user.name: "root"
|
||||
</EuiFlexItem>
|
||||
</span>
|
||||
`;
|
||||
|
|
|
@ -25,7 +25,7 @@ describe('create_influencers', () => {
|
|||
|
||||
test('it returns expected createKeyAndValue record with special left and right quotes', () => {
|
||||
const entities = createKeyAndValue({ 'name-1': 'value-1' });
|
||||
expect(entities).toEqual('name-1: “value-1”');
|
||||
expect(entities).toEqual('name-1: "value-1"');
|
||||
});
|
||||
|
||||
test('it returns expected createKeyAndValue record when empty object is passed', () => {
|
||||
|
@ -35,7 +35,7 @@ describe('create_influencers', () => {
|
|||
|
||||
test('it creates the anomalies without filtering anything out since they are all well formed', () => {
|
||||
const wrapper = mount(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
|
||||
expect(wrapper.text()).toEqual('host.name: “zeek-iowa”process.name: “du”user.name: “root”');
|
||||
expect(wrapper.text()).toEqual('host.name: "zeek-iowa"process.name: "du"user.name: "root"');
|
||||
});
|
||||
|
||||
test('it returns empty text when passed in empty objects of influencers', () => {
|
||||
|
@ -52,7 +52,7 @@ describe('create_influencers', () => {
|
|||
];
|
||||
const wrapper = mount(<span>{createInfluencers(anomalies.anomalies[0])}</span>);
|
||||
expect(wrapper.text()).toEqual(
|
||||
'influencer-name-one: “influencer-value-one”influencer-name-two: “influencer-value-two”'
|
||||
'influencer-name-one: "influencer-value-one"influencer-name-two: "influencer-value-two"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,10 +8,12 @@ import { EuiFlexItem } from '@elastic/eui';
|
|||
import React from 'react';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { Anomaly } from '../types';
|
||||
import { getEntries } from '../get_entries';
|
||||
|
||||
export const createKeyAndValue = (influencer: Record<string, string>): string => {
|
||||
if (Object.keys(influencer)[0] != null && Object.values(influencer)[0] != null) {
|
||||
return `${Object.keys(influencer)[0]}: “${Object.values(influencer)[0]}”`;
|
||||
const [key, value] = getEntries(influencer);
|
||||
if (key != null && value != null) {
|
||||
return `${key}: "${value}"`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -4,13 +4,20 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getEntries } from '../get_entries';
|
||||
|
||||
export const getHostNameFromInfluencers = (
|
||||
influencers: Array<Record<string, string>>
|
||||
influencers: Array<Record<string, string>>,
|
||||
hostName?: string
|
||||
): string | null => {
|
||||
const recordFound = influencers.find(influencer => {
|
||||
const influencerName = Object.keys(influencer)[0];
|
||||
const [influencerName, influencerValue] = getEntries(influencer);
|
||||
if (influencerName === 'host.name') {
|
||||
return true;
|
||||
if (hostName == null) {
|
||||
return true;
|
||||
} else {
|
||||
return influencerValue === hostName;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -4,22 +4,29 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { DestinationOrSource } from '../types';
|
||||
import { DestinationOrSource, isDestinationOrSource } from '../types';
|
||||
import { getEntries } from '../get_entries';
|
||||
|
||||
export const getNetworkFromInfluencers = (
|
||||
influencers: Array<Record<string, string>>
|
||||
influencers: Array<Record<string, string>>,
|
||||
ip?: string
|
||||
): { ip: string; type: DestinationOrSource } | null => {
|
||||
const recordFound = influencers.find(influencer => {
|
||||
const influencerName = Object.keys(influencer)[0];
|
||||
if (influencerName === 'destination.ip' || influencerName === 'source.ip') {
|
||||
return true;
|
||||
const [influencerName, influencerValue] = getEntries(influencer);
|
||||
if (isDestinationOrSource(influencerName)) {
|
||||
if (ip == null) {
|
||||
return true;
|
||||
} else {
|
||||
return influencerValue === ip;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (recordFound != null) {
|
||||
const influencerName = Object.keys(recordFound)[0];
|
||||
if (influencerName === 'destination.ip' || influencerName === 'source.ip') {
|
||||
const [influencerName] = getEntries(recordFound);
|
||||
if (isDestinationOrSource(influencerName)) {
|
||||
return { ip: Object.values(recordFound)[0], type: influencerName };
|
||||
} else {
|
||||
// default to destination.ip
|
||||
|
|
|
@ -179,7 +179,7 @@ exports[`anomaly_scores renders correctly against snapshot 1`] = `
|
|||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
process.name: “du”
|
||||
process.name: "du"
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>,
|
||||
"title": "Top Anomaly Suspect",
|
||||
|
@ -194,17 +194,17 @@ exports[`anomaly_scores renders correctly against snapshot 1`] = `
|
|||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
host.name: “zeek-iowa”
|
||||
host.name: "zeek-iowa"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
process.name: “du”
|
||||
process.name: "du"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
user.name: “root”
|
||||
user.name: "root"
|
||||
</EuiFlexItem>
|
||||
</React.Fragment>
|
||||
</EuiFlexGroup>,
|
||||
|
|
|
@ -105,7 +105,7 @@ exports[`create_description_list renders correctly against snapshot 1`] = `
|
|||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
process.name: “du”
|
||||
process.name: "du"
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListDescription>
|
||||
|
@ -124,21 +124,21 @@ exports[`create_description_list renders correctly against snapshot 1`] = `
|
|||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="host.name: “zeek-iowa”"
|
||||
key="host.name: \\"zeek-iowa\\""
|
||||
>
|
||||
host.name: “zeek-iowa”
|
||||
host.name: "zeek-iowa"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="process.name: “du”"
|
||||
key="process.name: \\"du\\""
|
||||
>
|
||||
process.name: “du”
|
||||
process.name: "du"
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
key="user.name: “root”"
|
||||
key="user.name: \\"root\\""
|
||||
>
|
||||
user.name: “root”
|
||||
user.name: "root"
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListDescription>
|
||||
|
|
|
@ -80,7 +80,7 @@ export const createDescriptionList = (
|
|||
title: i18n.TOP_ANOMALY_SUSPECT,
|
||||
description: (
|
||||
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
|
||||
<EuiFlexItem grow={false}>{`${score.entityName}: “${score.entityValue}”`}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{`${score.entityName}: "${score.entityValue}"`}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -34,7 +34,7 @@ const sorting = {
|
|||
};
|
||||
|
||||
export const AnomaliesHostTable = React.memo<AnomaliesTableProps>(
|
||||
({ startDate, endDate, narrowDateRange, skip }): JSX.Element | null => {
|
||||
({ startDate, endDate, narrowDateRange, hostName, skip }): JSX.Element | null => {
|
||||
const capabilities = useContext(MlCapabilitiesContext);
|
||||
const [loading, tableData] = useAnomaliesTableData({
|
||||
influencers: [],
|
||||
|
@ -44,7 +44,7 @@ export const AnomaliesHostTable = React.memo<AnomaliesTableProps>(
|
|||
skip,
|
||||
});
|
||||
|
||||
const hosts = convertAnomaliesToHosts(tableData);
|
||||
const hosts = convertAnomaliesToHosts(tableData, hostName);
|
||||
const interval = getIntervalFromAnomalies(tableData);
|
||||
const columns = getAnomaliesHostTableColumns(startDate, endDate, interval, narrowDateRange);
|
||||
const pagination = {
|
||||
|
|
|
@ -34,7 +34,7 @@ const sorting = {
|
|||
};
|
||||
|
||||
export const AnomaliesNetworkTable = React.memo<AnomaliesTableProps>(
|
||||
({ startDate, endDate, narrowDateRange, skip }): JSX.Element | null => {
|
||||
({ startDate, endDate, narrowDateRange, skip, ip }): JSX.Element | null => {
|
||||
const capabilities = useContext(MlCapabilitiesContext);
|
||||
const [loading, tableData] = useAnomaliesTableData({
|
||||
influencers: [],
|
||||
|
@ -44,7 +44,7 @@ export const AnomaliesNetworkTable = React.memo<AnomaliesTableProps>(
|
|||
skip,
|
||||
});
|
||||
|
||||
const networks = convertAnomaliesToNetwork(tableData);
|
||||
const networks = convertAnomaliesToNetwork(tableData, ip);
|
||||
const interval = getIntervalFromAnomalies(tableData);
|
||||
const columns = getAnomaliesNetworkTableColumns(startDate, endDate, interval, narrowDateRange);
|
||||
const pagination = {
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import { mockAnomalies } from '../mock';
|
||||
import { cloneDeep } from 'lodash/fp';
|
||||
import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts';
|
||||
import { convertAnomaliesToHosts, getHostNameFromEntity } from './convert_anomalies_to_hosts';
|
||||
import { AnomaliesByHost } from '../types';
|
||||
|
||||
describe('convert_anomalies_to_hosts', () => {
|
||||
|
@ -117,4 +117,153 @@ describe('convert_anomalies_to_hosts', () => {
|
|||
const expected: AnomaliesByHost[] = [];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if sent in the name of an anomaly', () => {
|
||||
anomalies.anomalies[0].entityName = 'something-else';
|
||||
anomalies.anomalies[0].entityValue = 'something-else';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
|
||||
const entities = convertAnomaliesToHosts(anomalies, 'zeek-iowa');
|
||||
const expected: AnomaliesByHost[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'process.name',
|
||||
entityValue: 'ls',
|
||||
influencers: [
|
||||
{ 'host.name': 'zeek-iowa' },
|
||||
{ 'process.name': 'ls' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-2',
|
||||
rowId: '1561157194802_1',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'ls',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['ls'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-2',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
hostName: 'zeek-iowa',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if an influencer has the name', () => {
|
||||
anomalies.anomalies[0].entityName = 'something-else';
|
||||
anomalies.anomalies[0].entityValue = 'something-else';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
|
||||
anomalies.anomalies[1].entityName = 'something-else';
|
||||
anomalies.anomalies[1].entityValue = 'something-else';
|
||||
const entities = convertAnomaliesToHosts(anomalies, 'zeek-iowa');
|
||||
const expected: AnomaliesByHost[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'something-else',
|
||||
entityValue: 'something-else',
|
||||
influencers: [
|
||||
{ 'host.name': 'zeek-iowa' },
|
||||
{ 'process.name': 'ls' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-2',
|
||||
rowId: '1561157194802_1',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'ls',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['ls'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-2',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
hostName: 'zeek-iowa',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty anomalies if sent in the name of one that does not exist', () => {
|
||||
const entities = convertAnomaliesToHosts(anomalies, 'some-made-up-name-here-for-you');
|
||||
const expected: AnomaliesByHost[] = [];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns true for a found entity name passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'host.name';
|
||||
anomalies.anomalies[0].entityValue = 'zeek-iowa';
|
||||
const found = getHostNameFromEntity(anomalies.anomalies[0], 'zeek-iowa');
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns false for an entity name that does not exist', () => {
|
||||
anomalies.anomalies[0].entityName = 'host.name';
|
||||
anomalies.anomalies[0].entityValue = 'zeek-iowa';
|
||||
const found = getHostNameFromEntity(anomalies.anomalies[0], 'some-made-up-entity-name');
|
||||
expect(found).toEqual(false);
|
||||
});
|
||||
|
||||
test('it returns true for an entity that has host.name within it if no name is passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'host.name';
|
||||
anomalies.anomalies[0].entityValue = 'something-made-up';
|
||||
const found = getHostNameFromEntity(anomalies.anomalies[0]);
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns false for an entity that is not host.name and no name is passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'made-up';
|
||||
const found = getHostNameFromEntity(anomalies.anomalies[0]);
|
||||
expect(found).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,20 +4,23 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Anomalies, AnomaliesByHost } from '../types';
|
||||
import { Anomalies, AnomaliesByHost, Anomaly } from '../types';
|
||||
import { getHostNameFromInfluencers } from '../influencers/get_host_name_from_influencers';
|
||||
|
||||
export const convertAnomaliesToHosts = (anomalies: Anomalies | null): AnomaliesByHost[] => {
|
||||
export const convertAnomaliesToHosts = (
|
||||
anomalies: Anomalies | null,
|
||||
hostName?: string
|
||||
): AnomaliesByHost[] => {
|
||||
if (anomalies == null) {
|
||||
return [];
|
||||
} else {
|
||||
return anomalies.anomalies.reduce<AnomaliesByHost[]>((accum, item) => {
|
||||
if (item.entityName === 'host.name') {
|
||||
if (getHostNameFromEntity(item, hostName)) {
|
||||
return [...accum, { hostName: item.entityValue, anomaly: item }];
|
||||
} else {
|
||||
const hostName = getHostNameFromInfluencers(item.influencers);
|
||||
if (hostName != null) {
|
||||
return [...accum, { hostName, anomaly: item }];
|
||||
const hostNameFromInfluencers = getHostNameFromInfluencers(item.influencers, hostName);
|
||||
if (hostNameFromInfluencers != null) {
|
||||
return [...accum, { hostName: hostNameFromInfluencers, anomaly: item }];
|
||||
} else {
|
||||
return accum;
|
||||
}
|
||||
|
@ -25,3 +28,13 @@ export const convertAnomaliesToHosts = (anomalies: Anomalies | null): AnomaliesB
|
|||
}, []);
|
||||
}
|
||||
};
|
||||
|
||||
export const getHostNameFromEntity = (anomaly: Anomaly, hostName?: string): boolean => {
|
||||
if (anomaly.entityName !== 'host.name') {
|
||||
return false;
|
||||
} else if (hostName == null) {
|
||||
return true;
|
||||
} else {
|
||||
return anomaly.entityValue === hostName;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -11,8 +11,8 @@
|
|||
|
||||
import { mockAnomalies } from '../mock';
|
||||
import { cloneDeep } from 'lodash/fp';
|
||||
import { convertAnomaliesToNetwork } from './convert_anomalies_to_network';
|
||||
import { AnomaliesByHost, AnomaliesByNetwork } from '../types';
|
||||
import { convertAnomaliesToNetwork, getNetworkFromEntity } from './convert_anomalies_to_network';
|
||||
import { AnomaliesByNetwork } from '../types';
|
||||
|
||||
describe('convert_anomalies_to_hosts', () => {
|
||||
let anomalies = cloneDeep(mockAnomalies);
|
||||
|
@ -225,7 +225,289 @@ describe('convert_anomalies_to_hosts', () => {
|
|||
|
||||
test('it returns empty anomalies if sent in a null', () => {
|
||||
const entities = convertAnomaliesToNetwork(null);
|
||||
const expected: AnomaliesByHost[] = [];
|
||||
const expected: AnomaliesByNetwork[] = [];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if sent in the name of an anomaly by source.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'source.ip';
|
||||
anomalies.anomalies[0].entityValue = '127.0.0.1';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
const entities = convertAnomaliesToNetwork(anomalies, '127.0.0.1');
|
||||
const expected: AnomaliesByNetwork[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'source.ip',
|
||||
entityValue: '127.0.0.1',
|
||||
influencers: [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-1',
|
||||
rowId: '1561157194802_0',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'du',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-1',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
type: 'source.ip',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if sent in the name of an anomaly by destination.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'destination.ip';
|
||||
anomalies.anomalies[0].entityValue = '127.0.0.1';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
const entities = convertAnomaliesToNetwork(anomalies, '127.0.0.1');
|
||||
const expected: AnomaliesByNetwork[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'destination.ip',
|
||||
entityValue: '127.0.0.1',
|
||||
influencers: [
|
||||
{ 'host.name': 'something-else' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-1',
|
||||
rowId: '1561157194802_0',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'du',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-1',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
type: 'destination.ip',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if an influencer has the name by source.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'something-else';
|
||||
anomalies.anomalies[0].entityValue = 'something-else';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'source.ip': '127.0.0.1' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
const entities = convertAnomaliesToNetwork(anomalies, '127.0.0.1');
|
||||
const expected: AnomaliesByNetwork[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'something-else',
|
||||
entityValue: 'something-else',
|
||||
influencers: [
|
||||
{ 'source.ip': '127.0.0.1' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-1',
|
||||
rowId: '1561157194802_0',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'du',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-1',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
type: 'source.ip',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns a specific anomaly if an influencer has the name by destination.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'something-else';
|
||||
anomalies.anomalies[0].entityValue = 'something-else';
|
||||
anomalies.anomalies[0].influencers = [
|
||||
{ 'destination.ip': '127.0.0.1' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
];
|
||||
const entities = convertAnomaliesToNetwork(anomalies, '127.0.0.1');
|
||||
const expected: AnomaliesByNetwork[] = [
|
||||
{
|
||||
anomaly: {
|
||||
detectorIndex: 0,
|
||||
entityName: 'something-else',
|
||||
entityValue: 'something-else',
|
||||
influencers: [
|
||||
{ 'destination.ip': '127.0.0.1' },
|
||||
{ 'process.name': 'du' },
|
||||
{ 'user.name': 'root' },
|
||||
],
|
||||
jobId: 'job-1',
|
||||
rowId: '1561157194802_0',
|
||||
severity: 16.193669439507826,
|
||||
source: {
|
||||
actual: [1],
|
||||
bucket_span: 900,
|
||||
by_field_name: 'process.name',
|
||||
by_field_value: 'du',
|
||||
detector_index: 0,
|
||||
function: 'rare',
|
||||
function_description: 'rare',
|
||||
influencers: [
|
||||
{ influencer_field_name: 'user.name', influencer_field_values: ['root'] },
|
||||
{ influencer_field_name: 'process.name', influencer_field_values: ['du'] },
|
||||
{ influencer_field_name: 'host.name', influencer_field_values: ['zeek-iowa'] },
|
||||
],
|
||||
initial_record_score: 16.193669439507826,
|
||||
is_interim: false,
|
||||
job_id: 'job-1',
|
||||
multi_bucket_impact: 0,
|
||||
partition_field_name: 'host.name',
|
||||
partition_field_value: 'zeek-iowa',
|
||||
probability: 0.024041164411288146,
|
||||
record_score: 16.193669439507826,
|
||||
result_type: 'record',
|
||||
timestamp: 1560664800000,
|
||||
typical: [0.024041164411288146],
|
||||
},
|
||||
time: 1560664800000,
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
type: 'destination.ip',
|
||||
},
|
||||
];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty anomalies if sent in the name of one that does not exist', () => {
|
||||
const entities = convertAnomaliesToNetwork(anomalies, 'some-made-up-name-here-for-you');
|
||||
const expected: AnomaliesByNetwork[] = [];
|
||||
expect(entities).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns true for a found entity name passed in by source.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'source.ip';
|
||||
anomalies.anomalies[0].entityValue = '255.255.255.255';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0], '255.255.255.255');
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns true for a found entity name passed in by destination.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'destination.ip';
|
||||
anomalies.anomalies[0].entityValue = '255.255.255.255';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0], '255.255.255.255');
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns false for an entity name that does not exist by source.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'source.ip';
|
||||
anomalies.anomalies[0].entityValue = '255.255.255.255';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0], 'some-made-up-entity-name');
|
||||
expect(found).toEqual(false);
|
||||
});
|
||||
|
||||
test('it returns false for an entity name that does not exist by destination.ip', () => {
|
||||
anomalies.anomalies[0].entityName = 'destination.ip';
|
||||
anomalies.anomalies[0].entityValue = '255.255.255.255';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0], 'some-made-up-entity-name');
|
||||
expect(found).toEqual(false);
|
||||
});
|
||||
|
||||
test('it returns true for an entity that has source.ip within it if no name is passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'source.ip';
|
||||
anomalies.anomalies[0].entityValue = 'something-made-up';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0]);
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns true for an entity that has destination.ip within it if no name is passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'destination.ip';
|
||||
anomalies.anomalies[0].entityValue = 'something-made-up';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0]);
|
||||
expect(found).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns false for an entity that not source.ip or destination.ip and no name is passed in', () => {
|
||||
anomalies.anomalies[0].entityName = 'made-up';
|
||||
const found = getNetworkFromEntity(anomalies.anomalies[0]);
|
||||
expect(found).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,18 +4,21 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Anomalies, AnomaliesByNetwork } from '../types';
|
||||
import { Anomalies, AnomaliesByNetwork, Anomaly, isDestinationOrSource } from '../types';
|
||||
import { getNetworkFromInfluencers } from '../influencers/get_network_from_influencers';
|
||||
|
||||
export const convertAnomaliesToNetwork = (anomalies: Anomalies | null): AnomaliesByNetwork[] => {
|
||||
export const convertAnomaliesToNetwork = (
|
||||
anomalies: Anomalies | null,
|
||||
ip?: string
|
||||
): AnomaliesByNetwork[] => {
|
||||
if (anomalies == null) {
|
||||
return [];
|
||||
} else {
|
||||
return anomalies.anomalies.reduce<AnomaliesByNetwork[]>((accum, item) => {
|
||||
if (item.entityName === 'source.ip' || item.entityName === 'destination.ip') {
|
||||
if (isDestinationOrSource(item.entityName) && getNetworkFromEntity(item, ip)) {
|
||||
return [...accum, { ip: item.entityValue, type: item.entityName, anomaly: item }];
|
||||
} else {
|
||||
const network = getNetworkFromInfluencers(item.influencers);
|
||||
const network = getNetworkFromInfluencers(item.influencers, ip);
|
||||
if (network != null) {
|
||||
return [...accum, { ip: network.ip, type: network.type, anomaly: item }];
|
||||
} else {
|
||||
|
@ -25,3 +28,15 @@ export const convertAnomaliesToNetwork = (anomalies: Anomalies | null): Anomalie
|
|||
}, []);
|
||||
}
|
||||
};
|
||||
|
||||
export const getNetworkFromEntity = (anomaly: Anomaly, ip?: string): boolean => {
|
||||
if (isDestinationOrSource(anomaly.entityName)) {
|
||||
if (ip == null) {
|
||||
return true;
|
||||
} else {
|
||||
return anomaly.entityValue === ip;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ import { HostDetailsLink } from '../../links';
|
|||
|
||||
import * as i18n from './translations';
|
||||
import { AnomalyScore } from '../score/anomaly_score';
|
||||
import { getEntries } from '../get_entries';
|
||||
|
||||
export const getAnomaliesHostTableColumns = (
|
||||
startDate: number,
|
||||
|
@ -77,8 +78,9 @@ export const getAnomaliesHostTableColumns = (
|
|||
render: (influencers, anomaliesByHost) => (
|
||||
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
|
||||
{influencers.map(influencer => {
|
||||
const entityName = Object.keys(influencer)[0];
|
||||
const entityValue = Object.values(influencer)[0];
|
||||
const [key, value] = getEntries(influencer);
|
||||
const entityName = key != null ? key : '';
|
||||
const entityValue = value != null ? value : '';
|
||||
return (
|
||||
<EuiFlexItem
|
||||
key={`${entityName}-${entityValue}-${createCompoundHostKey(anomaliesByHost)}`}
|
||||
|
|
|
@ -15,6 +15,7 @@ import { IPDetailsLink } from '../../links';
|
|||
|
||||
import * as i18n from './translations';
|
||||
import { AnomalyScore } from '../score/anomaly_score';
|
||||
import { getEntries } from '../get_entries';
|
||||
|
||||
export const getAnomaliesNetworkTableColumns = (
|
||||
startDate: number,
|
||||
|
@ -75,8 +76,9 @@ export const getAnomaliesNetworkTableColumns = (
|
|||
render: (influencers, anomaliesByNetwork) => (
|
||||
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
|
||||
{influencers.map(influencer => {
|
||||
const entityName = Object.keys(influencer)[0];
|
||||
const entityValue = Object.values(influencer)[0];
|
||||
const [key, value] = getEntries(influencer);
|
||||
const entityName = key != null ? key : '';
|
||||
const entityValue = value != null ? value : '';
|
||||
return (
|
||||
<EuiFlexItem
|
||||
key={`${entityName}-${entityValue}-${createCompoundNetworkKey(anomaliesByNetwork)}`}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { isDestinationOrSource } from './types';
|
||||
|
||||
describe('types', () => {
|
||||
test('it returns that something is a source.ip type and value', () => {
|
||||
expect(isDestinationOrSource('source.ip')).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns that something is a destination.ip type and value', () => {
|
||||
expect(isDestinationOrSource('destination.ip')).toEqual(true);
|
||||
});
|
||||
|
||||
test('it returns that something else is not is a destination.ip type and value', () => {
|
||||
expect(isDestinationOrSource('something-else.ip')).toEqual(false);
|
||||
});
|
||||
|
||||
test('it returns that null is not is a destination.ip type and value', () => {
|
||||
expect(isDestinationOrSource(null)).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -78,6 +78,8 @@ export interface AnomaliesTableProps {
|
|||
endDate: number;
|
||||
narrowDateRange: NarrowDateRange;
|
||||
skip: boolean;
|
||||
hostName?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export interface MlCapabilities {
|
||||
|
@ -110,3 +112,8 @@ export interface MlCapabilities {
|
|||
mlFeatureEnabledInSpace: boolean;
|
||||
upgradeInProgress: boolean;
|
||||
}
|
||||
|
||||
const sourceOrDestination = ['source.ip', 'destination.ip'];
|
||||
|
||||
export const isDestinationOrSource = (value: string | null): value is DestinationOrSource =>
|
||||
value != null && sourceOrDestination.includes(value);
|
||||
|
|
|
@ -43,6 +43,7 @@ import { setAbsoluteRangeDatePicker as dispatchAbsoluteRangeDatePicker } from '.
|
|||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
|
||||
import { KpiHostDetailsQuery } from '../../containers/kpi_host_details';
|
||||
import { AnomaliesHostTable } from '../../components/ml/tables/anomalies_host_table';
|
||||
|
||||
const type = hostsModel.HostsType.details;
|
||||
|
||||
|
@ -227,6 +228,23 @@ const HostDetailsComponent = pure<HostDetailsComponentProps>(
|
|||
|
||||
<EuiSpacer />
|
||||
|
||||
<AnomaliesHostTable
|
||||
startDate={from}
|
||||
endDate={to}
|
||||
skip={isInitializing}
|
||||
hostName={hostName}
|
||||
narrowDateRange={(score, interval) => {
|
||||
const fromTo = scoreIntervalToDateTime(score, interval);
|
||||
setAbsoluteRangeDatePicker({
|
||||
id: 'global',
|
||||
from: fromTo.from,
|
||||
to: fromTo.to,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<EventsQuery
|
||||
endDate={to}
|
||||
filterQuery={getFilterQuery(hostName, filterQueryExpression, indexPattern)}
|
||||
|
|
|
@ -42,6 +42,7 @@ import { AnomalyTableProvider } from '../../components/ml/anomaly/anomaly_table_
|
|||
import { networkToInfluencers } from '../../components/ml/influencers/network_to_influencers';
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
import { scoreIntervalToDateTime } from '../../components/ml/score/score_interval_to_datetime';
|
||||
import { AnomaliesNetworkTable } from '../../components/ml/tables/anomalies_network_table';
|
||||
|
||||
const DomainsTableManage = manageQuery(DomainsTable);
|
||||
const TlsTableManage = manageQuery(TlsTable);
|
||||
|
@ -251,6 +252,23 @@ export const IPDetailsComponent = pure<IPDetailsComponentProps>(
|
|||
/>
|
||||
)}
|
||||
</TlsQuery>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<AnomaliesNetworkTable
|
||||
startDate={from}
|
||||
endDate={to}
|
||||
skip={isInitializing}
|
||||
ip={ip}
|
||||
narrowDateRange={(score, interval) => {
|
||||
const fromTo = scoreIntervalToDateTime(score, interval);
|
||||
setAbsoluteRangeDatePicker({
|
||||
id: 'global',
|
||||
from: fromTo.from,
|
||||
to: fromTo.to,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</UseUrlState>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue