mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Inventory][ECO] Entity type Remove Control groups filter (#202177)
closes https://github.com/elastic/kibana/issues/201584 - Removes control group entity types filter - Adds multi-select entity types filer - Add kuery to url - Remove unified entities page - Adding telemetry when entity type is filtered - Refactoring... https://github.com/user-attachments/assets/98fb11ab-76e7-497b-af86-86378c6bfd7f --------- Co-authored-by: Irene Blanco <irene.blanco@elastic.co>
This commit is contained in:
parent
10f50564ac
commit
d8f3f4cb3c
41 changed files with 717 additions and 935 deletions
|
@ -34,7 +34,7 @@ export class EntitiesSynthtraceKibanaClient {
|
|||
});
|
||||
const entityDefinition: EntityDefinitionResponse = await response.json();
|
||||
|
||||
const hasEntityDefinitionsInstalled = entityDefinition.definitions.find(
|
||||
const hasEntityDefinitionsInstalled = entityDefinition.definitions?.find(
|
||||
(definition) => definition.type === 'service'
|
||||
)?.state.installed;
|
||||
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { ENTITY_LATEST, entitiesAliasPattern, type EntityMetadata } from '@kbn/entities-schema';
|
||||
import { decode, encode } from '@kbn/rison';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import * as t from 'io-ts';
|
||||
|
||||
export const entityColumnIdsRt = t.union([
|
||||
|
@ -19,49 +17,6 @@ export const entityColumnIdsRt = t.union([
|
|||
|
||||
export type EntityColumnIds = t.TypeOf<typeof entityColumnIdsRt>;
|
||||
|
||||
export const entityViewRt = t.union([t.literal('unified'), t.literal('grouped')]);
|
||||
|
||||
const paginationRt = t.record(t.string, t.number);
|
||||
export const entityPaginationRt = new t.Type<Record<string, number> | undefined, string, unknown>(
|
||||
'entityPaginationRt',
|
||||
paginationRt.is,
|
||||
(input, context) => {
|
||||
switch (typeof input) {
|
||||
case 'string': {
|
||||
try {
|
||||
const decoded = decode(input);
|
||||
const validation = paginationRt.decode(decoded);
|
||||
if (isRight(validation)) {
|
||||
return t.success(validation.right);
|
||||
}
|
||||
|
||||
return t.failure(input, context);
|
||||
} catch (e) {
|
||||
return t.failure(input, context);
|
||||
}
|
||||
}
|
||||
|
||||
case 'undefined':
|
||||
return t.success(input);
|
||||
|
||||
default: {
|
||||
const validation = paginationRt.decode(input);
|
||||
|
||||
if (isRight(validation)) {
|
||||
return t.success(validation.right);
|
||||
}
|
||||
|
||||
return t.failure(input, context);
|
||||
}
|
||||
}
|
||||
},
|
||||
(o) => encode(o)
|
||||
);
|
||||
|
||||
export type EntityView = t.TypeOf<typeof entityViewRt>;
|
||||
|
||||
export type EntityPagination = t.TypeOf<typeof entityPaginationRt>;
|
||||
|
||||
export const defaultEntitySortField: EntityColumnIds = 'alertsCount';
|
||||
|
||||
export const MAX_NUMBER_OF_ENTITIES = 500;
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { decode, encode } from '@kbn/rison';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import * as t from 'io-ts';
|
||||
|
||||
const validate = (validationRt: t.Any) => (input: unknown, context: t.Context) => {
|
||||
switch (typeof input) {
|
||||
case 'string': {
|
||||
try {
|
||||
const decoded = decode(input);
|
||||
const validation = validationRt.decode(decoded);
|
||||
if (isRight(validation)) {
|
||||
return t.success(validation.right);
|
||||
}
|
||||
|
||||
return t.failure(input, context);
|
||||
} catch (e) {
|
||||
return t.failure(input, context);
|
||||
}
|
||||
}
|
||||
|
||||
case 'undefined':
|
||||
return t.success(input);
|
||||
|
||||
default: {
|
||||
const validation = validationRt.decode(input);
|
||||
|
||||
if (isRight(validation)) {
|
||||
return t.success(validation.right);
|
||||
}
|
||||
|
||||
return t.failure(input, context);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const entityTypeCheckOptions = t.union([t.literal('on'), t.literal('off'), t.literal('mixed')]);
|
||||
export type EntityTypeCheckOptions = t.TypeOf<typeof entityTypeCheckOptions>;
|
||||
|
||||
const entityTypeRt = t.record(t.string, entityTypeCheckOptions);
|
||||
export type EntityType = t.TypeOf<typeof entityTypeRt>;
|
||||
export const entityTypesRt = new t.Type<
|
||||
Record<string, 'on' | 'off' | 'mixed'> | undefined,
|
||||
string,
|
||||
unknown
|
||||
>('entityTypesRt', entityTypeRt.is, validate(entityTypeRt), (o) => encode(o));
|
||||
|
||||
const paginationRt = t.record(t.string, t.number);
|
||||
export type EntityPagination = t.TypeOf<typeof entityPaginationRt>;
|
||||
export const entityPaginationRt = new t.Type<Record<string, number> | undefined, string, unknown>(
|
||||
'entityPaginationRt',
|
||||
paginationRt.is,
|
||||
validate(paginationRt),
|
||||
(o) => encode(o)
|
||||
);
|
|
@ -80,25 +80,6 @@ describe('Home page', () => {
|
|||
cy.contains('foo');
|
||||
});
|
||||
|
||||
it('Shows inventory page with unified view of entities', () => {
|
||||
cy.intercept('GET', '/internal/entities/managed/enablement', {
|
||||
fixture: 'eem_enabled.json',
|
||||
}).as('getEEMStatus');
|
||||
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
|
||||
cy.visitKibana('/app/inventory');
|
||||
cy.wait('@getEEMStatus');
|
||||
cy.contains('Group entities by: Type');
|
||||
cy.getByTestSubj('groupSelectorDropdown').click();
|
||||
cy.getByTestSubj('panelUnified').click();
|
||||
cy.wait('@getEntities');
|
||||
cy.contains('server1');
|
||||
cy.contains('host');
|
||||
cy.contains('synth-node-trace-logs');
|
||||
cy.contains('service');
|
||||
cy.contains('foo');
|
||||
cy.contains('container');
|
||||
});
|
||||
|
||||
it('Navigates to apm when clicking on a service type entity', () => {
|
||||
cy.intercept('GET', '/internal/entities/managed/enablement', {
|
||||
fixture: 'eem_enabled.json',
|
||||
|
@ -148,69 +129,69 @@ describe('Home page', () => {
|
|||
cy.intercept('GET', '/internal/entities/managed/enablement', {
|
||||
fixture: 'eem_enabled.json',
|
||||
}).as('getEEMStatus');
|
||||
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
|
||||
'entityTypeControlGroupOptions'
|
||||
);
|
||||
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
|
||||
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
|
||||
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
|
||||
cy.visitKibana('/app/inventory');
|
||||
cy.wait('@getEntitiesTypes');
|
||||
cy.wait('@getEEMStatus');
|
||||
cy.getByTestSubj('optionsList-control-entity.type').click();
|
||||
cy.wait('@entityTypeControlGroupOptions');
|
||||
cy.getByTestSubj('optionsList-control-selection-service').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click();
|
||||
cy.wait('@getGroups');
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click();
|
||||
cy.wait('@getEntities');
|
||||
cy.get('server1').should('not.exist');
|
||||
cy.contains('synth-node-trace-logs');
|
||||
cy.contains('foo').should('not.exist');
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_service').click();
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').should('not.exist');
|
||||
});
|
||||
|
||||
it('Filters entities by host type', () => {
|
||||
cy.intercept('GET', '/internal/entities/managed/enablement', {
|
||||
fixture: 'eem_enabled.json',
|
||||
}).as('getEEMStatus');
|
||||
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
|
||||
'entityTypeControlGroupOptions'
|
||||
);
|
||||
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
|
||||
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
|
||||
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
|
||||
cy.visitKibana('/app/inventory');
|
||||
cy.wait('@getEntitiesTypes');
|
||||
cy.wait('@getEEMStatus');
|
||||
cy.getByTestSubj('optionsList-control-entity.type').click();
|
||||
cy.wait('@entityTypeControlGroupOptions');
|
||||
cy.getByTestSubj('optionsList-control-selection-host').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click();
|
||||
cy.wait('@getGroups');
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click();
|
||||
cy.wait('@getEntities');
|
||||
cy.contains('server1');
|
||||
cy.contains('synth-node-trace-logs').should('not.exist');
|
||||
cy.contains('foo').should('not.exist');
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_host').click();
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').should('not.exist');
|
||||
});
|
||||
|
||||
it('Filters entities by container type', () => {
|
||||
cy.intercept('GET', '/internal/entities/managed/enablement', {
|
||||
fixture: 'eem_enabled.json',
|
||||
}).as('getEEMStatus');
|
||||
cy.intercept('POST', 'internal/controls/optionsList/entities-*-latest').as(
|
||||
'entityTypeControlGroupOptions'
|
||||
);
|
||||
cy.intercept('GET', '/internal/inventory/entities?**').as('getEntities');
|
||||
cy.intercept('GET', '/internal/inventory/entities/types').as('getEntitiesTypes');
|
||||
cy.intercept('GET', '/internal/inventory/entities/group_by/**').as('getGroups');
|
||||
cy.visitKibana('/app/inventory');
|
||||
cy.wait('@getEntitiesTypes');
|
||||
cy.wait('@getEEMStatus');
|
||||
cy.getByTestSubj('optionsList-control-entity.type').click();
|
||||
cy.wait('@entityTypeControlGroupOptions');
|
||||
cy.getByTestSubj('optionsList-control-selection-container').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click();
|
||||
cy.wait('@getGroups');
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').click();
|
||||
cy.wait('@getEntities');
|
||||
cy.contains('server1').should('not.exist');
|
||||
cy.contains('synth-node-trace-logs').should('not.exist');
|
||||
cy.contains('foo');
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter').click();
|
||||
cy.getByTestSubj('entityTypes_multiSelect_filter_selection_container').click();
|
||||
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').should('not.exist');
|
||||
});
|
||||
|
||||
it('Navigates to discover with actions button in the entities list', () => {
|
||||
|
|
|
@ -27,23 +27,31 @@ Cypress.Commands.add('loginAsSuperUser', () => {
|
|||
Cypress.Commands.add(
|
||||
'loginAs',
|
||||
({ username, password }: { username: string; password: string }) => {
|
||||
const kibanaUrl = Cypress.env('KIBANA_URL');
|
||||
cy.log(`Logging in as ${username} on ${kibanaUrl}`);
|
||||
cy.visit('/');
|
||||
cy.request({
|
||||
log: true,
|
||||
method: 'POST',
|
||||
url: `${kibanaUrl}/internal/security/login`,
|
||||
body: {
|
||||
providerType: 'basic',
|
||||
providerName: 'basic',
|
||||
currentURL: `${kibanaUrl}/login`,
|
||||
params: { username, password },
|
||||
cy.session(
|
||||
username,
|
||||
() => {
|
||||
const kibanaUrl = Cypress.env('KIBANA_URL');
|
||||
cy.log(`Logging in as ${username} on ${kibanaUrl}`);
|
||||
cy.visit('/');
|
||||
cy.request({
|
||||
log: true,
|
||||
method: 'POST',
|
||||
url: `${kibanaUrl}/internal/security/login`,
|
||||
body: {
|
||||
providerType: 'basic',
|
||||
providerName: 'basic',
|
||||
currentURL: `${kibanaUrl}/login`,
|
||||
params: { username, password },
|
||||
},
|
||||
headers: {
|
||||
'kbn-xsrf': 'e2e_test',
|
||||
},
|
||||
});
|
||||
cy.visit('/');
|
||||
},
|
||||
headers: {
|
||||
'kbn-xsrf': 'e2e_test',
|
||||
},
|
||||
});
|
||||
cy.visit('/');
|
||||
{
|
||||
cacheAcrossSpecs: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"ruleRegistry",
|
||||
"share"
|
||||
],
|
||||
"requiredBundles": ["kibanaReact","controls"],
|
||||
"requiredBundles": ["kibanaReact"],
|
||||
"optionalPlugins": ["spaces", "cloud"],
|
||||
"extraPublicDirs": []
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('BadgeFilterWithPopover', () => {
|
|||
});
|
||||
|
||||
it('opens the popover when the badge is clicked', () => {
|
||||
render(<BadgeFilterWithPopover field={field} value={value} />);
|
||||
render(<BadgeFilterWithPopover field={field} value={value} onFilter={jest.fn()} />);
|
||||
expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(value));
|
||||
expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument();
|
||||
|
@ -35,9 +35,25 @@ describe('BadgeFilterWithPopover', () => {
|
|||
});
|
||||
|
||||
it('copies value to clipboard when the "Copy value" button is clicked', () => {
|
||||
render(<BadgeFilterWithPopover field={field} value={value} />);
|
||||
render(<BadgeFilterWithPopover field={field} value={value} onFilter={jest.fn()} />);
|
||||
fireEvent.click(screen.getByText(value));
|
||||
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton'));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
it('Filter for an entity', () => {
|
||||
const handleFilter = jest.fn();
|
||||
render(<BadgeFilterWithPopover field={field} value={value} onFilter={handleFilter} />);
|
||||
fireEvent.click(screen.getByText(value));
|
||||
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton'));
|
||||
expect(handleFilter).toHaveBeenCalledWith(value, 'on');
|
||||
});
|
||||
|
||||
it('Filter out an entity', () => {
|
||||
const handleFilter = jest.fn();
|
||||
render(<BadgeFilterWithPopover field={field} value={value} onFilter={handleFilter} />);
|
||||
fireEvent.click(screen.getByText(value));
|
||||
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterOutButton'));
|
||||
expect(handleFilter).toHaveBeenCalledWith(value, 'off');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,19 +18,18 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
|
||||
import React, { useState } from 'react';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
import type { EntityTypeCheckOptions } from '../../../common/rt_types';
|
||||
|
||||
interface Props {
|
||||
field: string;
|
||||
value: string;
|
||||
onFilter: (value: string, checked: EntityTypeCheckOptions) => void;
|
||||
}
|
||||
|
||||
export function BadgeFilterWithPopover({ field, value }: Props) {
|
||||
export function BadgeFilterWithPopover({ field, value, onFilter }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useEuiTheme();
|
||||
const { addFilter } = useUnifiedSearchContext();
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
|
@ -82,7 +81,7 @@ export function BadgeFilterWithPopover({ field, value }: Props) {
|
|||
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
|
||||
iconType="plusInCircle"
|
||||
onClick={() => {
|
||||
addFilter({ fieldName: ENTITY_TYPE, operation: '+', value });
|
||||
onFilter(value, 'on');
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
|
||||
|
@ -92,10 +91,10 @@ export function BadgeFilterWithPopover({ field, value }: Props) {
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
|
||||
data-test-subj="inventoryBadgeFilterWithPopoverFilterOutButton"
|
||||
iconType="minusInCircle"
|
||||
onClick={() => {
|
||||
addFilter({ fieldName: ENTITY_TYPE, operation: '-', value });
|
||||
onFilter(value, 'off');
|
||||
}}
|
||||
>
|
||||
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
|
||||
|
|
|
@ -77,6 +77,7 @@ export const Grid: Story<EntityGridStoriesArgs> = (args) => {
|
|||
onChangePage={setPageIndex}
|
||||
onChangeSort={setSort}
|
||||
pageIndex={pageIndex}
|
||||
onFilterByType={() => {}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
@ -99,6 +100,7 @@ export const EmptyGrid: Story<EntityGridStoriesArgs> = (args) => {
|
|||
onChangePage={setPageIndex}
|
||||
onChangeSort={setSort}
|
||||
pageIndex={pageIndex}
|
||||
onFilterByType={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -22,6 +22,7 @@ import { getColumns } from './grid_columns';
|
|||
import { AlertsBadge } from '../alerts_badge/alerts_badge';
|
||||
import { EntityName } from './entity_name';
|
||||
import { EntityActions } from '../entity_actions';
|
||||
import { type EntityTypeCheckOptions } from '../../../common/rt_types';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
|
@ -31,6 +32,7 @@ interface Props {
|
|||
pageIndex: number;
|
||||
onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void;
|
||||
onChangePage: (nextPage: number) => void;
|
||||
onFilterByType: (value: string, checked: EntityTypeCheckOptions) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
@ -43,6 +45,7 @@ export function EntitiesGrid({
|
|||
pageIndex,
|
||||
onChangePage,
|
||||
onChangeSort,
|
||||
onFilterByType,
|
||||
}: Props) {
|
||||
const [showActions, setShowActions] = useState<boolean>(true);
|
||||
|
||||
|
@ -84,7 +87,13 @@ export function EntitiesGrid({
|
|||
return entity?.alertsCount ? <AlertsBadge entity={entity} /> : null;
|
||||
|
||||
case 'entityType':
|
||||
return <BadgeFilterWithPopover field={ENTITY_TYPE} value={entityType} />;
|
||||
return (
|
||||
<BadgeFilterWithPopover
|
||||
field={ENTITY_TYPE}
|
||||
value={entityType}
|
||||
onFilter={onFilterByType}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'entityLastSeenTimestamp':
|
||||
return (
|
||||
|
@ -120,7 +129,7 @@ export function EntitiesGrid({
|
|||
return null;
|
||||
}
|
||||
},
|
||||
[entities]
|
||||
[entities, onFilterByType]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
|
@ -9,12 +9,7 @@ import React from 'react';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { InventorySummary } from './inventory_summary';
|
||||
|
||||
// Do not test the GroupSelector, as it needs a lot more complicated setup
|
||||
jest.mock('./group_selector', () => ({
|
||||
GroupSelector: () => <>Selector</>,
|
||||
}));
|
||||
import { EntitiesSummary } from '.';
|
||||
|
||||
function MockEnvWrapper({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
|
@ -24,19 +19,19 @@ function MockEnvWrapper({ children }: { children?: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
describe('InventorySummary', () => {
|
||||
describe('EntitiesSummary', () => {
|
||||
it('renders the total entities without any group totals', () => {
|
||||
render(<InventorySummary totalEntities={10} />, { wrapper: MockEnvWrapper });
|
||||
render(<EntitiesSummary totalEntities={10} />, { wrapper: MockEnvWrapper });
|
||||
expect(screen.getByText('10 Entities')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument();
|
||||
});
|
||||
it('renders the total entities with group totals', () => {
|
||||
render(<InventorySummary totalEntities={15} totalGroups={3} />, { wrapper: MockEnvWrapper });
|
||||
render(<EntitiesSummary totalEntities={15} totalGroups={3} />, { wrapper: MockEnvWrapper });
|
||||
expect(screen.getByText('15 Entities')).toBeInTheDocument();
|
||||
expect(screen.queryByText('3 Groups')).toBeInTheDocument();
|
||||
});
|
||||
it("won't render either totals when not provided anything", () => {
|
||||
render(<InventorySummary />, { wrapper: MockEnvWrapper });
|
||||
render(<EntitiesSummary />, { wrapper: MockEnvWrapper });
|
||||
expect(screen.queryByTestId('inventorySummaryEntitiesTotal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('inventorySummaryGroupsTotal')).not.toBeInTheDocument();
|
||||
});
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
export function EntitiesSummary({
|
||||
totalEntities,
|
||||
totalGroups,
|
||||
}: {
|
||||
totalEntities?: number;
|
||||
totalGroups?: number;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const isGrouped = totalGroups !== undefined;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
css={css`
|
||||
font-weight: ${euiTheme.font.weight.semiBold};
|
||||
`}
|
||||
>
|
||||
{totalEntities !== undefined && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<span
|
||||
data-test-subj="inventorySummaryEntitiesTotal"
|
||||
css={css`
|
||||
border-right: ${isGrouped ? euiTheme.border.thin : 'none'};
|
||||
margin-right: ${euiTheme.size.base};
|
||||
padding-right: ${euiTheme.size.base};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.inventory.groupedInventoryPage.entitiesTotalLabel"
|
||||
defaultMessage="{total} Entities"
|
||||
values={{ total: totalEntities }}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{isGrouped ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<span data-test-subj="inventorySummaryGroupsTotal">
|
||||
<FormattedMessage
|
||||
id="xpack.inventory.groupedInventoryPage.groupsTotalLabel"
|
||||
defaultMessage="{total} Groups"
|
||||
values={{ total: totalGroups }}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
export function InventoryPanelBadge({
|
||||
export function EntityCountBadge({
|
||||
name,
|
||||
value,
|
||||
'data-test-subj': dataTestSubj,
|
|
@ -7,10 +7,9 @@
|
|||
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { EntityGroupAccordion } from '.';
|
||||
|
||||
import { InventoryGroupAccordion } from './inventory_group_accordion';
|
||||
|
||||
describe('Grouped Inventory Accordion', () => {
|
||||
describe('EntityGroupAccordion', () => {
|
||||
it('renders with correct values', () => {
|
||||
const props = {
|
||||
groupBy: 'entity.type',
|
||||
|
@ -26,14 +25,14 @@ describe('Grouped Inventory Accordion', () => {
|
|||
],
|
||||
};
|
||||
render(
|
||||
<InventoryGroupAccordion
|
||||
<EntityGroupAccordion
|
||||
groupValue={props.groups[0]['entity.type']}
|
||||
groupCount={props.groups[0].count}
|
||||
groupBy={props.groupBy}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(props.groups[0]['entity.type'])).toBeInTheDocument();
|
||||
const container = screen.getByTestId('inventoryPanelBadgeEntitiesCount_entity.type_host');
|
||||
const container = screen.getByTestId('entityCountBadge_entity.type_host');
|
||||
expect(within(container).getByText('Entities:')).toBeInTheDocument();
|
||||
expect(within(container).getByText(props.groups[0].count)).toBeInTheDocument();
|
||||
});
|
|
@ -5,15 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import { EuiDataGridSorting } from '@elastic/eui';
|
||||
import { decodeOrThrow } from '@kbn/io-ts-utils';
|
||||
import React from 'react';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import { type EntityColumnIds } from '../../../common/entities';
|
||||
import {
|
||||
type EntityTypeCheckOptions,
|
||||
entityPaginationRt,
|
||||
type EntityColumnIds,
|
||||
type EntityPagination,
|
||||
} from '../../../common/entities';
|
||||
entityTypesRt,
|
||||
} from '../../../common/rt_types';
|
||||
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
|
||||
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useInventoryRouter } from '../../hooks/use_inventory_router';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
|
@ -24,29 +25,14 @@ interface Props {
|
|||
groupValue: string;
|
||||
}
|
||||
|
||||
const paginationDecoder = decodeOrThrow(entityPaginationRt);
|
||||
|
||||
export function GroupedEntitiesGrid({ groupValue }: Props) {
|
||||
const { query } = useInventoryParams('/');
|
||||
const { sortField, sortDirection, pagination: paginationQuery } = query;
|
||||
const { sortField, sortDirection, kuery } = query;
|
||||
const { pagination, entityTypes } = useInventoryDecodedQueryParams();
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
let pagination: EntityPagination | undefined = {};
|
||||
const { stringifiedEsQuery } = useUnifiedSearchContext();
|
||||
try {
|
||||
pagination = paginationDecoder(paginationQuery);
|
||||
} catch (error) {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
pagination: undefined,
|
||||
},
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
const pageIndex = pagination?.[groupValue] ?? 0;
|
||||
|
||||
const { refreshSubject$, isControlPanelsInitiated } = useUnifiedSearchContext();
|
||||
const { refreshSubject$ } = useUnifiedSearchContext();
|
||||
const {
|
||||
services: { inventoryAPIClient },
|
||||
} = useKibana();
|
||||
|
@ -57,28 +43,19 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
|
|||
refresh,
|
||||
} = useInventoryAbortableAsync(
|
||||
({ signal }) => {
|
||||
if (isControlPanelsInitiated) {
|
||||
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
|
||||
params: {
|
||||
query: {
|
||||
sortDirection,
|
||||
sortField,
|
||||
esQuery: stringifiedEsQuery,
|
||||
entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined,
|
||||
},
|
||||
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
|
||||
params: {
|
||||
query: {
|
||||
sortDirection,
|
||||
sortField,
|
||||
kuery,
|
||||
entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
});
|
||||
},
|
||||
[
|
||||
groupValue,
|
||||
inventoryAPIClient,
|
||||
sortDirection,
|
||||
sortField,
|
||||
isControlPanelsInitiated,
|
||||
stringifiedEsQuery,
|
||||
]
|
||||
[groupValue, inventoryAPIClient, sortDirection, sortField, kuery]
|
||||
);
|
||||
|
||||
useEffectOnce(() => {
|
||||
|
@ -111,6 +88,16 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
function handleEntityTypeFilter(entityType: string, checkOption: EntityTypeCheckOptions) {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
entityTypes: entityTypesRt.encode({ ...entityTypes, [entityType]: checkOption }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EntitiesGrid
|
||||
entities={value.entities}
|
||||
|
@ -120,6 +107,7 @@ export function GroupedEntitiesGrid({ groupValue }: Props) {
|
|||
onChangePage={handlePageChange}
|
||||
onChangeSort={handleSortChange}
|
||||
pageIndex={pageIndex}
|
||||
onFilterByType={handleEntityTypeFilter}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -8,27 +8,22 @@ import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, useEuiTheme } from '@elast
|
|||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { EntityCountBadge } from './entity_count_badge';
|
||||
import { GroupedEntitiesGrid } from './grouped_entities_grid';
|
||||
import { InventoryPanelBadge } from './inventory_panel_badge';
|
||||
|
||||
const ENTITIES_COUNT_BADGE = i18n.translate(
|
||||
'xpack.inventory.inventoryGroupPanel.entitiesBadgeLabel',
|
||||
{ defaultMessage: 'Entities' }
|
||||
);
|
||||
|
||||
export interface InventoryGroupAccordionProps {
|
||||
export interface Props {
|
||||
groupBy: string;
|
||||
groupValue: string;
|
||||
groupCount: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function InventoryGroupAccordion({
|
||||
groupBy,
|
||||
groupValue,
|
||||
groupCount,
|
||||
isLoading,
|
||||
}: InventoryGroupAccordionProps) {
|
||||
export function EntityGroupAccordion({ groupBy, groupValue, groupCount, isLoading }: Props) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
@ -55,8 +50,8 @@ export function InventoryGroupAccordion({
|
|||
}
|
||||
buttonElement="div"
|
||||
extraAction={
|
||||
<InventoryPanelBadge
|
||||
data-test-subj={`inventoryPanelBadgeEntitiesCount_${groupBy}_${groupValue}`}
|
||||
<EntityCountBadge
|
||||
data-test-subj={`entityCountBadge_${groupBy}_${groupValue}`}
|
||||
name={ENTITIES_COUNT_BADGE}
|
||||
value={groupCount}
|
||||
/>
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { GroupBySelector } from '.';
|
||||
import { InventoryComponentWrapperMock } from '../entity_group_accordion/mock/inventory_component_wrapper_mock';
|
||||
|
||||
describe('GroupBySelector', () => {
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<InventoryComponentWrapperMock>
|
||||
<GroupBySelector />
|
||||
</InventoryComponentWrapperMock>
|
||||
);
|
||||
});
|
||||
it('Should default to Type', async () => {
|
||||
expect(await screen.findByText('Group entities by: Type')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -5,49 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiPopover, EuiContextMenu, EuiButtonEmpty } from '@elastic/eui';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { EuiButtonEmpty, EuiContextMenu, EuiPopover } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { EntityView } from '../../../common/entities';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useInventoryRouter } from '../../hooks/use_inventory_router';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
const GROUP_LABELS: Record<EntityView, string> = {
|
||||
unified: i18n.translate('xpack.inventory.groupedInventoryPage.noneLabel', {
|
||||
defaultMessage: 'None',
|
||||
}),
|
||||
grouped: i18n.translate('xpack.inventory.groupedInventoryPage.typeLabel', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
};
|
||||
const ENTITY_TYPE_LABEL = i18n.translate('xpack.inventory.groupedInventoryPage.typeLabel', {
|
||||
defaultMessage: 'Type',
|
||||
});
|
||||
|
||||
export interface GroupedSelectorProps {
|
||||
groupSelected: string;
|
||||
onGroupChange: (groupSelection: string) => void;
|
||||
}
|
||||
|
||||
export function GroupSelector() {
|
||||
const { query } = useInventoryParams('/');
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
export function GroupBySelector() {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const groupBy = query.view ?? 'grouped';
|
||||
|
||||
const onGroupChange = (selected: EntityView) => {
|
||||
const { pagination: _, ...rest } = query;
|
||||
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...rest,
|
||||
view: groupBy === selected ? 'unified' : selected,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isGroupSelected = (groupKey: EntityView) => {
|
||||
return groupBy === groupKey;
|
||||
};
|
||||
|
||||
const panels = [
|
||||
{
|
||||
|
@ -56,17 +24,10 @@ export function GroupSelector() {
|
|||
defaultMessage: 'Select grouping',
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
'data-test-subj': 'panelUnified',
|
||||
name: GROUP_LABELS.unified,
|
||||
icon: isGroupSelected('unified') ? 'check' : 'empty',
|
||||
onClick: () => onGroupChange('unified'),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'panelType',
|
||||
name: GROUP_LABELS.grouped,
|
||||
icon: isGroupSelected('grouped') ? 'check' : 'empty',
|
||||
onClick: () => onGroupChange('grouped'),
|
||||
name: ENTITY_TYPE_LABEL,
|
||||
icon: 'check',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -83,13 +44,13 @@ export function GroupSelector() {
|
|||
iconSize="s"
|
||||
iconType="arrowDown"
|
||||
onClick={onButtonClick}
|
||||
title={GROUP_LABELS[groupBy]}
|
||||
title={ENTITY_TYPE_LABEL}
|
||||
size="s"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.inventory.groupedInventoryPage.groupedByLabel"
|
||||
defaultMessage={`Group entities by: {grouping}`}
|
||||
values={{ grouping: GROUP_LABELS[groupBy] }}
|
||||
values={{ grouping: ENTITY_TYPE_LABEL }}
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
);
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { GroupSelector } from './group_selector';
|
||||
|
||||
import { InventoryComponentWrapperMock } from './mock/inventory_component_wrapper_mock';
|
||||
|
||||
describe('GroupSelector', () => {
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<InventoryComponentWrapperMock>
|
||||
<GroupSelector />
|
||||
</InventoryComponentWrapperMock>
|
||||
);
|
||||
});
|
||||
it('Should default to Type', async () => {
|
||||
expect(await screen.findByText('Group entities by: Type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.skip('Should change to None', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const selector = screen.getByText('Group entities by: Type');
|
||||
|
||||
expect(selector).toBeInTheDocument();
|
||||
|
||||
await user.click(selector);
|
||||
|
||||
const noneOption = screen.getByTestId('panelUnified');
|
||||
|
||||
expect(noneOption).toBeInTheDocument();
|
||||
|
||||
await user.click(noneOption);
|
||||
|
||||
expect(await screen.findByText('Group entities by: None')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -1,70 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
|
||||
import React from 'react';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import { flattenObject } from '@kbn/observability-utils-common/object/flatten_object';
|
||||
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
import { InventoryGroupAccordion } from './inventory_group_accordion';
|
||||
import { InventorySummary } from './inventory_summary';
|
||||
|
||||
export function GroupedInventory() {
|
||||
const {
|
||||
services: { inventoryAPIClient },
|
||||
} = useKibana();
|
||||
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
|
||||
useUnifiedSearchContext();
|
||||
|
||||
const {
|
||||
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
|
||||
refresh,
|
||||
loading,
|
||||
} = useInventoryAbortableAsync(
|
||||
({ signal }) => {
|
||||
if (isControlPanelsInitiated) {
|
||||
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
|
||||
params: {
|
||||
path: {
|
||||
field: ENTITY_TYPE,
|
||||
},
|
||||
query: { esQuery: stringifiedEsQuery },
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
},
|
||||
[inventoryAPIClient, stringifiedEsQuery, isControlPanelsInitiated]
|
||||
);
|
||||
|
||||
useEffectOnce(() => {
|
||||
const refreshSubscription = refreshSubject$.subscribe(refresh);
|
||||
|
||||
return () => refreshSubscription.unsubscribe();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<InventorySummary totalEntities={value.entitiesCount} totalGroups={value.groups.length} />
|
||||
<EuiSpacer size="m" />
|
||||
{value.groups.map((group) => {
|
||||
const groupValue = flattenObject(group)[value.groupBy];
|
||||
return (
|
||||
<InventoryGroupAccordion
|
||||
key={`${value.groupBy}-${groupValue}`}
|
||||
groupBy={value.groupBy}
|
||||
groupValue={groupValue}
|
||||
groupCount={group.count}
|
||||
isLoading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { GroupSelector } from './group_selector';
|
||||
|
||||
export function InventorySummary({
|
||||
totalEntities,
|
||||
totalGroups,
|
||||
}: {
|
||||
totalEntities?: number;
|
||||
totalGroups?: number;
|
||||
}) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const isGrouped = totalGroups !== undefined;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
css={css`
|
||||
font-weight: ${euiTheme.font.weight.semiBold};
|
||||
`}
|
||||
>
|
||||
{totalEntities !== undefined && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<span
|
||||
data-test-subj="inventorySummaryEntitiesTotal"
|
||||
css={css`
|
||||
border-right: ${isGrouped ? euiTheme.border.thin : 'none'};
|
||||
margin-right: ${euiTheme.size.base};
|
||||
padding-right: ${euiTheme.size.base};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.inventory.groupedInventoryPage.entitiesTotalLabel"
|
||||
defaultMessage="{total} Entities"
|
||||
values={{ total: totalEntities }}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
{isGrouped ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<span data-test-subj="inventorySummaryGroupsTotal">
|
||||
<FormattedMessage
|
||||
id="xpack.inventory.groupedInventoryPage.groupsTotalLabel"
|
||||
defaultMessage="{total} Groups"
|
||||
values={{ total: totalGroups }}
|
||||
/>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<GroupSelector />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
* 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 {
|
||||
ControlGroupRenderer,
|
||||
ControlGroupRendererApi,
|
||||
ControlGroupRuntimeState,
|
||||
} from '@kbn/controls-plugin/public';
|
||||
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
|
||||
import { ControlPanels, useControlPanels } from '@kbn/observability-shared-plugin/public';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { skip, Subscription } from 'rxjs';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
|
||||
const controlPanelDefinitions: ControlPanels = {
|
||||
[ENTITY_TYPE]: {
|
||||
order: 0,
|
||||
type: 'optionsListControl',
|
||||
fieldName: ENTITY_TYPE,
|
||||
width: 'small',
|
||||
grow: false,
|
||||
title: 'Type',
|
||||
},
|
||||
};
|
||||
|
||||
export function ControlGroups() {
|
||||
const {
|
||||
isControlPanelsInitiated,
|
||||
setIsControlPanelsInitiated,
|
||||
dataView,
|
||||
searchState,
|
||||
onPanelFiltersChange,
|
||||
} = useUnifiedSearchContext();
|
||||
const [controlPanels, setControlPanels] = useControlPanels(controlPanelDefinitions, dataView);
|
||||
const subscriptions = useRef<Subscription>(new Subscription());
|
||||
|
||||
const getInitialInput = useCallback(
|
||||
() => async () => {
|
||||
const initialInput: Partial<ControlGroupRuntimeState> = {
|
||||
chainingSystem: 'HIERARCHICAL',
|
||||
labelPosition: 'oneLine',
|
||||
initialChildControlState: controlPanels,
|
||||
};
|
||||
|
||||
return { initialState: initialInput };
|
||||
},
|
||||
[controlPanels]
|
||||
);
|
||||
|
||||
const loadCompleteHandler = useCallback(
|
||||
(controlGroup: ControlGroupRendererApi) => {
|
||||
if (!controlGroup) return;
|
||||
|
||||
subscriptions.current.add(
|
||||
controlGroup.filters$.pipe(skip(1)).subscribe((newFilters = []) => {
|
||||
onPanelFiltersChange(newFilters);
|
||||
})
|
||||
);
|
||||
|
||||
subscriptions.current.add(
|
||||
controlGroup.getInput$().subscribe(({ initialChildControlState }) => {
|
||||
if (!isControlPanelsInitiated) {
|
||||
setIsControlPanelsInitiated(true);
|
||||
}
|
||||
setControlPanels(initialChildControlState);
|
||||
})
|
||||
);
|
||||
},
|
||||
[isControlPanelsInitiated, onPanelFiltersChange, setControlPanels, setIsControlPanelsInitiated]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSubscriptions = subscriptions.current;
|
||||
return () => {
|
||||
currentSubscriptions.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!dataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ControlGroupRenderer
|
||||
getCreationOptions={getInitialInput()}
|
||||
onApiAvailable={loadCompleteHandler}
|
||||
query={searchState.query}
|
||||
compressed={false}
|
||||
filters={searchState.filters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiFilterButton,
|
||||
EuiFilterGroup,
|
||||
EuiPopover,
|
||||
EuiPopoverTitle,
|
||||
EuiSelectable,
|
||||
EuiSelectableOption,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { entityTypesRt, type EntityType } from '../../../common/rt_types';
|
||||
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
|
||||
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useInventoryRouter } from '../../hooks/use_inventory_router';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status';
|
||||
|
||||
export function EntityTypesMultiSelect() {
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
const { query } = useInventoryParams('/*');
|
||||
const { entityTypes: selectedEntityTypes } = useInventoryDecodedQueryParams();
|
||||
|
||||
const {
|
||||
services: { inventoryAPIClient, telemetry },
|
||||
} = useKibana();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const { value, loading } = useInventoryAbortableAsync(
|
||||
({ signal }) => inventoryAPIClient.fetch('GET /internal/inventory/entities/types', { signal }),
|
||||
[inventoryAPIClient]
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
value?.entityTypes.map((type): EuiSelectableOption => {
|
||||
const checked = selectedEntityTypes?.[type];
|
||||
return {
|
||||
label: type,
|
||||
checked,
|
||||
'data-test-subj': `entityTypes_multiSelect_filter_selection_${type}`,
|
||||
};
|
||||
}) || [],
|
||||
[selectedEntityTypes, value?.entityTypes]
|
||||
);
|
||||
|
||||
const registerEntityTypeFilteredEvent = useCallback(
|
||||
({ filterEntityTypes }: { filterEntityTypes: EntityType }) => {
|
||||
const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(filterEntityTypes);
|
||||
|
||||
telemetry.reportEntityInventoryEntityTypeFiltered({
|
||||
include_entity_types: entityTypesOn,
|
||||
exclude_entity_types: entityTypesOff,
|
||||
});
|
||||
},
|
||||
[telemetry]
|
||||
);
|
||||
|
||||
function handleEntityTypeChecked(nextItems: EntityType) {
|
||||
registerEntityTypeFilteredEvent({ filterEntityTypes: nextItems });
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
entityTypes: entityTypesRt.encode(nextItems),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFilterGroup>
|
||||
<EuiPopover
|
||||
id="entityTypeMultiSelector"
|
||||
button={
|
||||
<EuiFilterButton
|
||||
data-test-subj="entityTypes_multiSelect_filter"
|
||||
iconType="arrowDown"
|
||||
badgeColor="success"
|
||||
onClick={() => setIsPopoverOpen((state) => !state)}
|
||||
isSelected={isPopoverOpen}
|
||||
numFilters={items.filter((item) => item.checked !== 'off').length}
|
||||
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
|
||||
numActiveFilters={items.filter((item) => item.checked === 'on').length}
|
||||
>
|
||||
{i18n.translate('xpack.inventory.entityTypesMultSelect.typeFilterButtonLabel', {
|
||||
defaultMessage: 'Type',
|
||||
})}
|
||||
</EuiFilterButton>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => setIsPopoverOpen(false)}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiSelectable
|
||||
allowExclusions
|
||||
searchable
|
||||
searchProps={{
|
||||
placeholder: i18n.translate(
|
||||
'xpack.inventory.entityTypesMultSelect.euiSelectable.placeholder',
|
||||
{ defaultMessage: 'Filter types' }
|
||||
),
|
||||
compressed: true,
|
||||
}}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.inventory.entityTypesMultSelect.euiSelectable.typeLabel',
|
||||
{ defaultMessage: 'Entity type' }
|
||||
)}
|
||||
options={items}
|
||||
onChange={(newOptions) => {
|
||||
handleEntityTypeChecked(
|
||||
newOptions
|
||||
.filter((item) => item.checked)
|
||||
.reduce<EntityType>((acc, curr) => ({ ...acc, [curr.label]: curr.checked! }), {})
|
||||
);
|
||||
}}
|
||||
isLoading={loading}
|
||||
loadingMessage={i18n.translate(
|
||||
'xpack.inventory.entityTypesMultSelect.euiSelectable.loading',
|
||||
{ defaultMessage: 'Loading types' }
|
||||
)}
|
||||
emptyMessage={i18n.translate(
|
||||
'xpack.inventory.entityTypesMultSelect.euiSelectable.empty',
|
||||
{ defaultMessage: 'No types available' }
|
||||
)}
|
||||
noMatchesMessage={i18n.translate(
|
||||
'xpack.inventory.entityTypesMultSelect.euiSelectable.notFound',
|
||||
{ defaultMessage: 'No types found' }
|
||||
)}
|
||||
>
|
||||
{(list, search) => (
|
||||
<div style={{ width: 300 }}>
|
||||
<EuiPopoverTitle paddingSize="s">{search}</EuiPopoverTitle>
|
||||
{list}
|
||||
</div>
|
||||
)}
|
||||
</EuiSelectable>
|
||||
</EuiPopover>
|
||||
</EuiFilterGroup>
|
||||
);
|
||||
}
|
|
@ -4,19 +4,24 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { Query } from '@kbn/es-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useInventoryRouter } from '../../hooks/use_inventory_router';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback';
|
||||
import { ControlGroups } from './control_groups';
|
||||
import { EntityTypesMultiSelect } from './entity_types_multi_select';
|
||||
|
||||
export function SearchBar() {
|
||||
const { refreshSubject$, dataView, searchState, onQueryChange } = useUnifiedSearchContext();
|
||||
|
||||
const { refreshSubject$, dataView } = useUnifiedSearchContext();
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
const {
|
||||
query,
|
||||
query: { kuery },
|
||||
} = useInventoryParams('/*');
|
||||
const {
|
||||
services: {
|
||||
unifiedSearch,
|
||||
|
@ -30,44 +35,40 @@ export function SearchBar() {
|
|||
const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui;
|
||||
|
||||
const syncSearchBarWithUrl = useCallback(() => {
|
||||
const query = searchState.query;
|
||||
if (query && !deepEqual(queryStringService.getQuery(), query)) {
|
||||
queryStringService.setQuery(query);
|
||||
const _query = kuery ? { query: kuery, language: 'kuery' } : undefined;
|
||||
if (_query && !deepEqual(queryStringService.getQuery(), _query)) {
|
||||
queryStringService.setQuery(_query);
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
if (!_query) {
|
||||
queryStringService.clearQuery();
|
||||
}
|
||||
}, [searchState.query, queryStringService]);
|
||||
}, [kuery, queryStringService]);
|
||||
|
||||
useEffect(() => {
|
||||
syncSearchBarWithUrl();
|
||||
}, [syncSearchBarWithUrl]);
|
||||
|
||||
const registerSearchSubmittedEvent = useCallback(
|
||||
({ searchQuery, searchIsUpdate }: { searchQuery?: Query; searchIsUpdate?: boolean }) => {
|
||||
telemetry.reportEntityInventorySearchQuerySubmitted({
|
||||
kuery_fields: getKqlFieldsWithFallback(searchQuery?.query as string),
|
||||
action: searchIsUpdate ? 'submit' : 'refresh',
|
||||
});
|
||||
},
|
||||
[telemetry]
|
||||
);
|
||||
|
||||
const handleQuerySubmit = useCallback<NonNullable<SearchBarOwnProps['onQuerySubmit']>>(
|
||||
({ query = { language: 'kuery', query: '' } }, isUpdate) => {
|
||||
if (isUpdate) {
|
||||
onQueryChange(query);
|
||||
} else {
|
||||
({ query: _query = { language: 'kuery', query: '' } }, isUpdate) => {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
kuery: _query?.query as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isUpdate) {
|
||||
refreshSubject$.next();
|
||||
}
|
||||
|
||||
registerSearchSubmittedEvent({
|
||||
searchQuery: query,
|
||||
searchIsUpdate: isUpdate,
|
||||
telemetry.reportEntityInventorySearchQuerySubmitted({
|
||||
kuery_fields: getKqlFieldsWithFallback(_query?.query as string),
|
||||
action: isUpdate ? 'submit' : 'refresh',
|
||||
});
|
||||
},
|
||||
[registerSearchSubmittedEvent, onQueryChange, refreshSubject$]
|
||||
[inventoryRoute, query, telemetry, refreshSubject$]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -75,16 +76,14 @@ export function SearchBar() {
|
|||
appName="Inventory"
|
||||
displayStyle="inPage"
|
||||
indexPatterns={dataView ? [dataView] : undefined}
|
||||
renderQueryInputAppend={() => <ControlGroups />}
|
||||
renderQueryInputAppend={() => <EntityTypesMultiSelect />}
|
||||
onQuerySubmit={handleQuerySubmit}
|
||||
placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', {
|
||||
defaultMessage:
|
||||
'Search for your entities by name or its metadata (e.g. entity.type : service)',
|
||||
})}
|
||||
showDatePicker={false}
|
||||
showFilterBar
|
||||
showQueryInput
|
||||
showQueryMenu
|
||||
showFilterBar={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiDataGridSorting } from '@elastic/eui';
|
||||
import { decodeOrThrow } from '@kbn/io-ts-utils';
|
||||
import React from 'react';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import {
|
||||
entityPaginationRt,
|
||||
type EntityColumnIds,
|
||||
type EntityPagination,
|
||||
} from '../../../common/entities';
|
||||
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useInventoryRouter } from '../../hooks/use_inventory_router';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
import { EntitiesGrid } from '../entities_grid';
|
||||
import { InventorySummary } from '../grouped_inventory/inventory_summary';
|
||||
|
||||
const paginationDecoder = decodeOrThrow(entityPaginationRt);
|
||||
|
||||
export function UnifiedInventory() {
|
||||
const {
|
||||
services: { inventoryAPIClient },
|
||||
} = useKibana();
|
||||
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
|
||||
useUnifiedSearchContext();
|
||||
const { query } = useInventoryParams('/');
|
||||
const { sortDirection, sortField, pagination: paginationQuery } = query;
|
||||
|
||||
let pagination: EntityPagination | undefined = {};
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
try {
|
||||
pagination = paginationDecoder(paginationQuery);
|
||||
} catch (error) {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
pagination: undefined,
|
||||
},
|
||||
});
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const pageIndex = pagination?.unified ?? 0;
|
||||
|
||||
const {
|
||||
value = { entities: [] },
|
||||
loading,
|
||||
refresh,
|
||||
} = useInventoryAbortableAsync(
|
||||
({ signal }) => {
|
||||
if (isControlPanelsInitiated) {
|
||||
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
|
||||
params: {
|
||||
query: {
|
||||
sortDirection,
|
||||
sortField,
|
||||
esQuery: stringifiedEsQuery,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
},
|
||||
[inventoryAPIClient, sortDirection, sortField, isControlPanelsInitiated, stringifiedEsQuery]
|
||||
);
|
||||
|
||||
useEffectOnce(() => {
|
||||
const refreshSubscription = refreshSubject$.subscribe(refresh);
|
||||
return () => refreshSubscription.unsubscribe();
|
||||
});
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
pagination: entityPaginationRt.encode({
|
||||
...pagination,
|
||||
unified: nextPage,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
sortField: sorting.id as EntityColumnIds,
|
||||
sortDirection: sorting.direction,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InventorySummary />
|
||||
<EntitiesGrid
|
||||
entities={value.entities}
|
||||
loading={loading}
|
||||
sortDirection={sortDirection}
|
||||
sortField={sortField}
|
||||
onChangePage={handlePageChange}
|
||||
onChangeSort={handleSortChange}
|
||||
pageIndex={pageIndex}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -6,9 +6,9 @@
|
|||
*/
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { InventoryEntity } from '../../common/entities';
|
||||
import { useKibana } from './use_kibana';
|
||||
import { useFetchEntityDefinition } from './use_fetch_entity_definition';
|
||||
import { useAdHocDataView } from './use_adhoc_data_view';
|
||||
import { useFetchEntityDefinition } from './use_fetch_entity_definition';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export const useDiscoverRedirect = (entity: InventoryEntity) => {
|
||||
const {
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { decodeOrThrow } from '@kbn/io-ts-utils';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
entityPaginationRt,
|
||||
entityTypesRt,
|
||||
type EntityPagination,
|
||||
type EntityType,
|
||||
} from '../../common/rt_types';
|
||||
import { useInventoryParams } from './use_inventory_params';
|
||||
import { useInventoryRouter } from './use_inventory_router';
|
||||
|
||||
const entityTypeDecoder = decodeOrThrow(entityTypesRt);
|
||||
const paginationDecoder = decodeOrThrow(entityPaginationRt);
|
||||
|
||||
export function useInventoryDecodedQueryParams() {
|
||||
const inventoryRoute = useInventoryRouter();
|
||||
const {
|
||||
query,
|
||||
query: { entityTypes, pagination },
|
||||
} = useInventoryParams('/*');
|
||||
|
||||
const resetUrlParam = useCallback(
|
||||
(queryParamName: string) => {
|
||||
inventoryRoute.push('/', {
|
||||
path: {},
|
||||
query: {
|
||||
...query,
|
||||
[queryParamName]: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
[inventoryRoute, query]
|
||||
);
|
||||
|
||||
const selectedEntityTypes: EntityType = useMemo(() => {
|
||||
try {
|
||||
return entityTypeDecoder(entityTypes) || {};
|
||||
} catch (e) {
|
||||
resetUrlParam('entityTypes');
|
||||
return {};
|
||||
}
|
||||
}, [entityTypes, resetUrlParam]);
|
||||
|
||||
const selectedPagination: EntityPagination = useMemo(() => {
|
||||
try {
|
||||
return paginationDecoder(pagination) || {};
|
||||
} catch (error) {
|
||||
resetUrlParam('pagination');
|
||||
return {};
|
||||
}
|
||||
}, [pagination, resetUrlParam]);
|
||||
|
||||
return { entityTypes: selectedEntityTypes, pagination: selectedPagination };
|
||||
}
|
|
@ -4,162 +4,19 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { buildEsQuery, type Filter, fromKueryExpression, type Query } from '@kbn/es-query';
|
||||
import createContainer from 'constate';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { map, Subject, Subscription, tap } from 'rxjs';
|
||||
import { generateFilters } from '@kbn/data-plugin/public';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useKibanaQuerySettings } from '@kbn/observability-shared-plugin/public';
|
||||
import { useState } from 'react';
|
||||
import { Subject } from 'rxjs';
|
||||
import { ENTITIES_LATEST_ALIAS } from '../../common/entities';
|
||||
import { useAdHocDataView } from './use_adhoc_data_view';
|
||||
import { useUnifiedSearchUrl } from './use_unified_search_url';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
function useUnifiedSearch() {
|
||||
const [isControlPanelsInitiated, setIsControlPanelsInitiated] = useState(false);
|
||||
const { dataView } = useAdHocDataView(ENTITIES_LATEST_ALIAS);
|
||||
const [refreshSubject$] = useState<Subject<void>>(new Subject());
|
||||
const { searchState, setSearchState } = useUnifiedSearchUrl();
|
||||
const kibanaQuerySettings = useKibanaQuerySettings();
|
||||
const {
|
||||
services: {
|
||||
data: {
|
||||
query: { filterManager: filterManagerService, queryString: queryStringService },
|
||||
},
|
||||
notifications,
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
useEffectOnce(() => {
|
||||
if (!deepEqual(filterManagerService.getFilters(), searchState.filters)) {
|
||||
filterManagerService.setFilters(
|
||||
searchState.filters.map((item) => ({
|
||||
...item,
|
||||
meta: { ...item.meta, index: dataView?.id },
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (!deepEqual(queryStringService.getQuery(), searchState.query)) {
|
||||
queryStringService.setQuery(searchState.query);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = new Subscription();
|
||||
subscription.add(
|
||||
filterManagerService
|
||||
.getUpdates$()
|
||||
.pipe(
|
||||
map(() => filterManagerService.getFilters()),
|
||||
tap((filters) => setSearchState({ type: 'SET_FILTERS', filters }))
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
subscription.add(
|
||||
queryStringService
|
||||
.getUpdates$()
|
||||
.pipe(
|
||||
map(() => queryStringService.getQuery() as Query),
|
||||
tap((query) => setSearchState({ type: 'SET_QUERY', query }))
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [filterManagerService, queryStringService, setSearchState]);
|
||||
|
||||
const validateQuery = useCallback(
|
||||
(query: Query) => {
|
||||
fromKueryExpression(query.query, kibanaQuerySettings);
|
||||
},
|
||||
[kibanaQuerySettings]
|
||||
);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(query: Query) => {
|
||||
try {
|
||||
validateQuery(query);
|
||||
setSearchState({ type: 'SET_QUERY', query });
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
notifications.toasts.addDanger({
|
||||
title: i18n.translate('xpack.inventory.unifiedSearchContext.queryError', {
|
||||
defaultMessage: 'Error while updating the new query',
|
||||
}),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
[validateQuery, setSearchState, notifications.toasts]
|
||||
);
|
||||
|
||||
const onPanelFiltersChange = useCallback(
|
||||
(panelFilters: Filter[]) => {
|
||||
setSearchState({ type: 'SET_PANEL_FILTERS', panelFilters });
|
||||
},
|
||||
[setSearchState]
|
||||
);
|
||||
|
||||
const onFiltersChange = useCallback(
|
||||
(filters: Filter[]) => {
|
||||
setSearchState({ type: 'SET_FILTERS', filters });
|
||||
},
|
||||
[setSearchState]
|
||||
);
|
||||
|
||||
const addFilter = useCallback(
|
||||
({
|
||||
fieldName,
|
||||
operation,
|
||||
value,
|
||||
}: {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
operation: '+' | '-';
|
||||
}) => {
|
||||
if (dataView) {
|
||||
const newFilters = generateFilters(
|
||||
filterManagerService,
|
||||
fieldName,
|
||||
value,
|
||||
operation,
|
||||
dataView
|
||||
);
|
||||
setSearchState({ type: 'SET_FILTERS', filters: [...newFilters, ...searchState.filters] });
|
||||
}
|
||||
},
|
||||
[dataView, filterManagerService, searchState.filters, setSearchState]
|
||||
);
|
||||
|
||||
const stringifiedEsQuery = useMemo(() => {
|
||||
if (dataView) {
|
||||
return JSON.stringify(
|
||||
buildEsQuery(dataView, searchState.query, [
|
||||
...searchState.panelFilters,
|
||||
...searchState.filters,
|
||||
])
|
||||
);
|
||||
}
|
||||
}, [dataView, searchState.panelFilters, searchState.filters, searchState.query]);
|
||||
|
||||
return {
|
||||
isControlPanelsInitiated,
|
||||
setIsControlPanelsInitiated,
|
||||
dataView,
|
||||
refreshSubject$,
|
||||
searchState,
|
||||
addFilter,
|
||||
stringifiedEsQuery,
|
||||
onQueryChange,
|
||||
onPanelFiltersChange,
|
||||
onFiltersChange,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
/*
|
||||
* 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 { FilterStateStore } from '@kbn/es-query';
|
||||
import { useUrlState } from '@kbn/observability-shared-plugin/public';
|
||||
import { enumeration } from '@kbn/securitysolution-io-ts-types';
|
||||
import { fold } from 'fp-ts/lib/Either';
|
||||
import { constant, identity } from 'fp-ts/lib/function';
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import * as t from 'io-ts';
|
||||
import { useReducer } from 'react';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
|
||||
const FilterRT = t.intersection([
|
||||
t.type({
|
||||
meta: t.partial({
|
||||
alias: t.union([t.null, t.string]),
|
||||
disabled: t.boolean,
|
||||
negate: t.boolean,
|
||||
controlledBy: t.string,
|
||||
group: t.string,
|
||||
index: t.string,
|
||||
isMultiIndex: t.boolean,
|
||||
type: t.string,
|
||||
key: t.string,
|
||||
params: t.any,
|
||||
value: t.any,
|
||||
}),
|
||||
}),
|
||||
t.partial({
|
||||
query: t.record(t.string, t.any),
|
||||
$state: t.type({
|
||||
store: enumeration('FilterStateStore', FilterStateStore),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
const FiltersRT = t.array(FilterRT);
|
||||
|
||||
const QueryStateRT = t.type({
|
||||
language: t.string,
|
||||
query: t.union([t.string, t.record(t.string, t.any)]),
|
||||
});
|
||||
|
||||
const SearchStateRT = t.type({
|
||||
panelFilters: FiltersRT,
|
||||
filters: FiltersRT,
|
||||
query: QueryStateRT,
|
||||
});
|
||||
|
||||
const encodeUrlState = SearchStateRT.encode;
|
||||
const decodeUrlState = (value: unknown) => {
|
||||
return pipe(SearchStateRT.decode(value), fold(constant(undefined), identity));
|
||||
};
|
||||
|
||||
type SearchState = t.TypeOf<typeof SearchStateRT>;
|
||||
|
||||
const INITIAL_VALUE: SearchState = {
|
||||
query: { language: 'kuery', query: '' },
|
||||
panelFilters: [],
|
||||
filters: [],
|
||||
};
|
||||
|
||||
export type StateAction =
|
||||
| { type: 'SET_FILTERS'; filters: SearchState['filters'] }
|
||||
| { type: 'SET_QUERY'; query: SearchState['query'] }
|
||||
| { type: 'SET_PANEL_FILTERS'; panelFilters: SearchState['panelFilters'] };
|
||||
|
||||
const reducer = (state: SearchState, action: StateAction): SearchState => {
|
||||
switch (action.type) {
|
||||
case 'SET_FILTERS':
|
||||
return { ...state, filters: action.filters };
|
||||
case 'SET_QUERY':
|
||||
return { ...state, query: action.query };
|
||||
case 'SET_PANEL_FILTERS':
|
||||
return { ...state, panelFilters: action.panelFilters };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export function useUnifiedSearchUrl() {
|
||||
const [urlState, setUrlState] = useUrlState<SearchState>({
|
||||
defaultState: INITIAL_VALUE,
|
||||
decodeUrlState,
|
||||
encodeUrlState,
|
||||
urlStateKey: '_a',
|
||||
writeDefaultState: true,
|
||||
});
|
||||
|
||||
const [searchState, setSearchState] = useReducer(reducer, urlState);
|
||||
|
||||
if (!deepEqual(searchState, urlState)) {
|
||||
setUrlState(searchState);
|
||||
}
|
||||
|
||||
return { searchState, setSearchState };
|
||||
}
|
|
@ -4,12 +4,83 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
|
||||
import { flattenObject } from '@kbn/observability-utils-common/object/flatten_object';
|
||||
import React from 'react';
|
||||
import { GroupedInventory } from '../../components/grouped_inventory';
|
||||
import { UnifiedInventory } from '../../components/unified_inventory';
|
||||
import useEffectOnce from 'react-use/lib/useEffectOnce';
|
||||
import { EntitiesSummary } from '../../components/entities_summary';
|
||||
import { EntityGroupAccordion } from '../../components/entity_group_accordion';
|
||||
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
|
||||
import { useInventoryDecodedQueryParams } from '../../hooks/use_inventory_decoded_query_params';
|
||||
import { useInventoryParams } from '../../hooks/use_inventory_params';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
|
||||
import { GroupBySelector } from '../../components/group_by_selector';
|
||||
import { groupEntityTypesByStatus } from '../../utils/group_entity_types_by_status';
|
||||
|
||||
export function InventoryPage() {
|
||||
const { query } = useInventoryParams('/');
|
||||
return query.view === 'unified' ? <UnifiedInventory /> : <GroupedInventory />;
|
||||
const {
|
||||
services: { inventoryAPIClient },
|
||||
} = useKibana();
|
||||
const { refreshSubject$ } = useUnifiedSearchContext();
|
||||
const {
|
||||
query: { kuery },
|
||||
} = useInventoryParams('/');
|
||||
const { entityTypes } = useInventoryDecodedQueryParams();
|
||||
|
||||
const {
|
||||
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
|
||||
refresh,
|
||||
loading,
|
||||
} = useInventoryAbortableAsync(
|
||||
({ signal }) => {
|
||||
const { entityTypesOff, entityTypesOn } = groupEntityTypesByStatus(entityTypes);
|
||||
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
|
||||
params: {
|
||||
path: {
|
||||
field: ENTITY_TYPE,
|
||||
},
|
||||
query: {
|
||||
includeEntityTypes: entityTypesOn.length ? JSON.stringify(entityTypesOn) : undefined,
|
||||
excludeEntityTypes: entityTypesOff.length ? JSON.stringify(entityTypesOff) : undefined,
|
||||
kuery,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
});
|
||||
},
|
||||
[entityTypes, inventoryAPIClient, kuery]
|
||||
);
|
||||
|
||||
useEffectOnce(() => {
|
||||
const refreshSubscription = refreshSubject$.subscribe(refresh);
|
||||
return () => refreshSubscription.unsubscribe();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EntitiesSummary totalEntities={value.entitiesCount} totalGroups={value.groups.length} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<GroupBySelector />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
{value.groups.map((group) => {
|
||||
const groupValue = flattenObject(group)[value.groupBy];
|
||||
return (
|
||||
<EntityGroupAccordion
|
||||
key={`${value.groupBy}-${groupValue}`}
|
||||
groupBy={value.groupBy}
|
||||
groupValue={groupValue}
|
||||
groupCount={group.count}
|
||||
isLoading={loading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
import { Outlet, createRouter } from '@kbn/typed-react-router-config';
|
||||
import * as t from 'io-ts';
|
||||
import React from 'react';
|
||||
import { defaultEntitySortField, entityColumnIdsRt } from '../../common/entities';
|
||||
import { InventoryPageTemplate } from '../components/inventory_page_template';
|
||||
import { InventoryPage } from '../pages/inventory_page';
|
||||
import { defaultEntitySortField, entityColumnIdsRt, entityViewRt } from '../../common/entities';
|
||||
|
||||
/**
|
||||
* The array of route definitions to be used when the application
|
||||
|
@ -29,10 +29,9 @@ const inventoryRoutes = {
|
|||
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
|
||||
}),
|
||||
t.partial({
|
||||
view: entityViewRt,
|
||||
pagination: t.string,
|
||||
_a: t.string,
|
||||
controlPanels: t.string,
|
||||
entityTypes: t.string,
|
||||
kuery: t.string,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
|
@ -40,7 +39,6 @@ const inventoryRoutes = {
|
|||
query: {
|
||||
sortField: defaultEntitySortField,
|
||||
sortDirection: 'desc',
|
||||
view: 'grouped',
|
||||
},
|
||||
},
|
||||
children: {
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
type EntityInventoryViewedParams,
|
||||
type EntityInventorySearchQuerySubmittedParams,
|
||||
type EntityViewClickedParams,
|
||||
type EntityInventoryEntityTypeFilteredParams,
|
||||
} from './types';
|
||||
|
||||
export class TelemetryClient implements ITelemetryClient {
|
||||
|
@ -36,4 +37,10 @@ export class TelemetryClient implements ITelemetryClient {
|
|||
public reportEntityViewClicked = (params: EntityViewClickedParams) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_VIEW_CLICKED, params);
|
||||
};
|
||||
|
||||
public reportEntityInventoryEntityTypeFiltered = (
|
||||
params: EntityInventoryEntityTypeFilteredParams
|
||||
) => {
|
||||
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, params);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -58,6 +58,30 @@ const searchQuerySubmittedEventType: TelemetryEvent = {
|
|||
},
|
||||
};
|
||||
|
||||
const entityInventoryEntityTypeFilteredEventType: TelemetryEvent = {
|
||||
eventType: TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
|
||||
schema: {
|
||||
include_entity_types: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'List of Entity types used to filter for.',
|
||||
},
|
||||
},
|
||||
},
|
||||
exclude_entity_types: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'keyword',
|
||||
_meta: {
|
||||
description: 'List of Entity types used to filter out.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const entityViewClickedEventType: TelemetryEvent = {
|
||||
eventType: TelemetryEventTypes.ENTITY_VIEW_CLICKED,
|
||||
schema: {
|
||||
|
@ -81,4 +105,5 @@ export const inventoryTelemetryEventBasedTypes = [
|
|||
entityInventoryViewedEventType,
|
||||
searchQuerySubmittedEventType,
|
||||
entityViewClickedEventType,
|
||||
entityInventoryEntityTypeFilteredEventType,
|
||||
];
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
type EntityViewClickedParams,
|
||||
type EntityInventorySearchQuerySubmittedParams,
|
||||
TelemetryEventTypes,
|
||||
EntityInventoryEntityTypeFilteredParams,
|
||||
} from './types';
|
||||
|
||||
describe('TelemetryService', () => {
|
||||
|
@ -145,4 +146,24 @@ describe('TelemetryService', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#reportEntityInventoryEntityTypeFiltered', () => {
|
||||
it('should report entity type filtered with properties', async () => {
|
||||
const setupParams = getSetupParams();
|
||||
service.setup(setupParams);
|
||||
const telemetry = service.start();
|
||||
const params: EntityInventoryEntityTypeFilteredParams = {
|
||||
include_entity_types: ['container'],
|
||||
exclude_entity_types: ['service'],
|
||||
};
|
||||
|
||||
telemetry.reportEntityInventoryEntityTypeFiltered(params);
|
||||
|
||||
expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1);
|
||||
expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith(
|
||||
TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
|
||||
params
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,6 +30,11 @@ export interface EntityInventorySearchQuerySubmittedParams {
|
|||
action: 'submit' | 'refresh';
|
||||
}
|
||||
|
||||
export interface EntityInventoryEntityTypeFilteredParams {
|
||||
include_entity_types: string[];
|
||||
exclude_entity_types: string[];
|
||||
}
|
||||
|
||||
export interface EntityViewClickedParams {
|
||||
entity_type: string;
|
||||
view_type: 'detail' | 'flyout';
|
||||
|
@ -39,7 +44,8 @@ export type TelemetryEventParams =
|
|||
| InventoryAddDataParams
|
||||
| EntityInventoryViewedParams
|
||||
| EntityInventorySearchQuerySubmittedParams
|
||||
| EntityViewClickedParams;
|
||||
| EntityViewClickedParams
|
||||
| EntityInventoryEntityTypeFilteredParams;
|
||||
|
||||
export interface ITelemetryClient {
|
||||
reportInventoryAddData(params: InventoryAddDataParams): void;
|
||||
|
@ -48,6 +54,7 @@ export interface ITelemetryClient {
|
|||
params: EntityInventorySearchQuerySubmittedParams
|
||||
): void;
|
||||
reportEntityViewClicked(params: EntityViewClickedParams): void;
|
||||
reportEntityInventoryEntityTypeFiltered(params: EntityInventoryEntityTypeFilteredParams): void;
|
||||
}
|
||||
|
||||
export enum TelemetryEventTypes {
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { EntityType } from '../../common/rt_types';
|
||||
|
||||
export function groupEntityTypesByStatus(entityTypes: EntityType) {
|
||||
const entityTypesKeys = Object.keys(entityTypes);
|
||||
return {
|
||||
entityTypesOn: entityTypesKeys.filter((key) => entityTypes[key] === 'on').sort(),
|
||||
entityTypesOff: entityTypesKeys.filter((key) => entityTypes[key] === 'off').sort(),
|
||||
};
|
||||
}
|
|
@ -5,26 +5,43 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { kqlQuery } from '@kbn/observability-plugin/server';
|
||||
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
|
||||
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
|
||||
import {
|
||||
ENTITIES_LATEST_ALIAS,
|
||||
type EntityGroup,
|
||||
MAX_NUMBER_OF_ENTITIES,
|
||||
type EntityGroup,
|
||||
} from '../../../common/entities';
|
||||
import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
|
||||
|
||||
export async function getEntityGroupsBy({
|
||||
inventoryEsClient,
|
||||
field,
|
||||
esQuery,
|
||||
kuery,
|
||||
includeEntityTypes = [],
|
||||
excludeEntityTypes = [],
|
||||
}: {
|
||||
inventoryEsClient: ObservabilityElasticsearchClient;
|
||||
field: string;
|
||||
esQuery?: QueryDslQueryContainer;
|
||||
includeEntityTypes?: string[];
|
||||
excludeEntityTypes?: string[];
|
||||
kuery?: string;
|
||||
}): Promise<EntityGroup[]> {
|
||||
const from = `FROM ${ENTITIES_LATEST_ALIAS}`;
|
||||
const where = [getBuiltinEntityDefinitionIdESQLWhereClause()];
|
||||
const params: ScalarValue[] = [];
|
||||
|
||||
if (includeEntityTypes.length) {
|
||||
where.push(`WHERE ${ENTITY_TYPE} IN (${includeEntityTypes.map(() => '?').join()})`);
|
||||
params.push(...includeEntityTypes);
|
||||
}
|
||||
|
||||
if (excludeEntityTypes.length) {
|
||||
where.push(`WHERE ${ENTITY_TYPE} NOT IN (${excludeEntityTypes.map(() => '?').join()})`);
|
||||
params.push(...excludeEntityTypes);
|
||||
}
|
||||
|
||||
const group = `STATS count = COUNT(*) by ${field}`;
|
||||
const sort = `SORT ${field} asc`;
|
||||
|
@ -35,7 +52,8 @@ export async function getEntityGroupsBy({
|
|||
'get_entities_groups',
|
||||
{
|
||||
query,
|
||||
filter: esQuery,
|
||||
filter: { bool: { filter: kqlQuery(kuery) } },
|
||||
params,
|
||||
},
|
||||
{ transform: 'plain' }
|
||||
);
|
||||
|
|
|
@ -5,19 +5,20 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { kqlQuery } from '@kbn/observability-plugin/server';
|
||||
import {
|
||||
ENTITY_DISPLAY_NAME,
|
||||
ENTITY_LAST_SEEN,
|
||||
ENTITY_TYPE,
|
||||
} from '@kbn/observability-shared-plugin/common';
|
||||
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
|
||||
import { unflattenObject } from '@kbn/observability-utils-common/object/unflatten_object';
|
||||
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client';
|
||||
import {
|
||||
ENTITIES_LATEST_ALIAS,
|
||||
InventoryEntity,
|
||||
MAX_NUMBER_OF_ENTITIES,
|
||||
type EntityColumnIds,
|
||||
type InventoryEntity,
|
||||
} from '../../../common/entities';
|
||||
import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
|
||||
|
||||
|
@ -35,13 +36,13 @@ export async function getLatestEntities({
|
|||
inventoryEsClient,
|
||||
sortDirection,
|
||||
sortField,
|
||||
esQuery,
|
||||
kuery,
|
||||
entityTypes,
|
||||
}: {
|
||||
inventoryEsClient: ObservabilityElasticsearchClient;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
sortField: EntityColumnIds;
|
||||
esQuery?: QueryDslQueryContainer;
|
||||
kuery?: string;
|
||||
entityTypes?: string[];
|
||||
}): Promise<InventoryEntity[]> {
|
||||
// alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default.
|
||||
|
@ -78,7 +79,7 @@ export async function getLatestEntities({
|
|||
'get_latest_entities',
|
||||
{
|
||||
query,
|
||||
filter: esQuery,
|
||||
filter: { bool: { filter: kqlQuery(kuery) } },
|
||||
params,
|
||||
},
|
||||
{ transform: 'plain' }
|
||||
|
|
|
@ -47,7 +47,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
|
|||
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
|
||||
}),
|
||||
t.partial({
|
||||
esQuery: jsonRt.pipe(t.UnknownRecord),
|
||||
kuery: t.string,
|
||||
entityTypes: jsonRt.pipe(t.array(t.string)),
|
||||
}),
|
||||
]),
|
||||
|
@ -69,7 +69,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
|
|||
plugin: `@kbn/${INVENTORY_APP_ID}-plugin`,
|
||||
});
|
||||
|
||||
const { sortDirection, sortField, esQuery, entityTypes } = params.query;
|
||||
const { sortDirection, sortField, kuery, entityTypes } = params.query;
|
||||
|
||||
const [alertsClient, latestEntities] = await Promise.all([
|
||||
createAlertsClient({ plugins, request }),
|
||||
|
@ -77,7 +77,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
|
|||
inventoryEsClient,
|
||||
sortDirection,
|
||||
sortField,
|
||||
esQuery,
|
||||
kuery,
|
||||
entityTypes,
|
||||
}),
|
||||
]);
|
||||
|
@ -113,7 +113,9 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
|
|||
t.type({ path: t.type({ field: t.literal(ENTITY_TYPE) }) }),
|
||||
t.partial({
|
||||
query: t.partial({
|
||||
esQuery: jsonRt.pipe(t.UnknownRecord),
|
||||
includeEntityTypes: jsonRt.pipe(t.array(t.string)),
|
||||
excludeEntityTypes: jsonRt.pipe(t.array(t.string)),
|
||||
kuery: t.string,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
@ -129,12 +131,14 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
|
|||
});
|
||||
|
||||
const { field } = params.path;
|
||||
const { esQuery } = params.query ?? {};
|
||||
const { kuery, includeEntityTypes, excludeEntityTypes } = params.query ?? {};
|
||||
|
||||
const groups = await getEntityGroupsBy({
|
||||
inventoryEsClient,
|
||||
field,
|
||||
esQuery,
|
||||
kuery,
|
||||
includeEntityTypes,
|
||||
excludeEntityTypes,
|
||||
});
|
||||
|
||||
const entitiesCount = groups.reduce((acc, group) => acc + group.count, 0);
|
||||
|
|
|
@ -54,8 +54,6 @@
|
|||
"@kbn/storybook",
|
||||
"@kbn/dashboard-plugin",
|
||||
"@kbn/deeplinks-analytics",
|
||||
"@kbn/controls-plugin",
|
||||
"@kbn/securitysolution-io-ts-types",
|
||||
"@kbn/react-hooks",
|
||||
"@kbn/observability-utils-common",
|
||||
"@kbn/observability-utils-browser",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue