[Inventory][ECO] Use ControlGroupRenderer to filter by entity types (#199174)

closes https://github.com/elastic/kibana/issues/193397


https://github.com/user-attachments/assets/e78639a8-bc63-4c5a-8676-0ad9b5f0563e

- Added `Entity type` control group field on the Inventory page. 
- Added `Filters` buttons to the Unified Search bar on the Inventory
oage
- Moved common hooks from infra to Obs-shared
- Refactoring

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2024-11-12 10:28:02 +00:00 committed by GitHub
parent d6b8f9b619
commit 4a16e910e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 784 additions and 758 deletions

View file

@ -119,7 +119,7 @@ pageLoadAssetSize:
observabilityAiAssistantManagement: 19279
observabilityLogsExplorer: 46650
observabilityOnboarding: 19573
observabilityShared: 80000
observabilityShared: 111036
osquery: 107090
painlessLab: 179748
presentationPanel: 55463

View file

@ -54,7 +54,9 @@ export function useAbortableAsync<T>(
})
.catch((err) => {
setValue(undefined);
setError(err);
if (!controller.signal.aborted) {
setError(err);
}
})
.finally(() => setLoading(false));
} else {

View file

@ -15,8 +15,8 @@ import {
ALERT_STATUS_RECOVERED,
ALERT_STATUS_UNTRACKED,
} from '@kbn/rule-data-utils';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { ContentTabIds } from '../types';
import { useUrlState } from '../../../hooks/use_url_state';
import { ASSET_DETAILS_URL_STATE_KEY } from '../constants';
import { ALERT_STATUS_ALL } from '../../shared/alerts/constants';

View file

@ -9,7 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useUiTracker } from '@kbn/observability-shared-plugin/public';
import { useUiTracker, useUrlState } from '@kbn/observability-shared-plugin/public';
import {
MutationContext,
SavedViewResult,
@ -23,7 +23,6 @@ import {
} from '../../common/http_api/latest';
import type { InventoryView } from '../../common/inventory_views';
import { useKibanaContextForPlugin } from './use_kibana';
import { useUrlState } from './use_url_state';
import { useSavedViewsNotifier } from './use_saved_views_notifier';
import { useSourceContext } from '../containers/metrics_source';

View file

@ -9,7 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useUiTracker } from '@kbn/observability-shared-plugin/public';
import { useUiTracker, useUrlState } from '@kbn/observability-shared-plugin/public';
import {
MutationContext,
@ -23,7 +23,6 @@ import {
UpdateMetricsExplorerViewAttributesRequestPayload,
} from '../../common/http_api/latest';
import { MetricsExplorerView } from '../../common/metrics_explorer_views';
import { useUrlState } from './use_url_state';
import { useSavedViewsNotifier } from './use_saved_views_notifier';
import { useSourceContext } from '../containers/metrics_source';
import { useKibanaContextForPlugin } from './use_kibana';

View file

@ -9,7 +9,7 @@ import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import * as rt from 'io-ts';
import { useUrlState } from '../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,

View file

@ -12,8 +12,8 @@ import moment from 'moment';
import * as rt from 'io-ts';
import type { TimeRange as KibanaTimeRange } from '@kbn/es-query';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { TimeRange } from '../../../../common/time/time_range';
import { useUrlState } from '../../../hooks/use_url_state';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,

View file

@ -6,7 +6,7 @@
*/
import { EuiErrorBoundary } from '@elastic/eui';
import { useTrackPageview } from '@kbn/observability-shared-plugin/public';
import { useKibanaQuerySettings, useTrackPageview } from '@kbn/observability-shared-plugin/public';
import React from 'react';
import { useLogViewContext } from '@kbn/logs-shared-plugin/public';
import { useKibanaContextForPlugin } from '../../../hooks/use_kibana';
@ -14,7 +14,6 @@ import { useLogsBreadcrumbs } from '../../../hooks/use_logs_breadcrumbs';
import { LogStreamPageStateProvider } from '../../../observability_logs/log_stream_page/state';
import { streamTitle } from '../../../translations';
import { useKbnUrlStateStorageFromRouterContext } from '../../../containers/kbn_url_state_context';
import { useKibanaQuerySettings } from '../../../hooks/use_kibana_query_settings';
import { ConnectedStreamPageContent } from './page_content';
export const StreamPage = () => {

View file

@ -0,0 +1,41 @@
/*
* 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 { ControlPanels } from '@kbn/observability-shared-plugin/public';
export const availableControlsPanels = {
HOST_OS_NAME: 'host.os.name',
CLOUD_PROVIDER: 'cloud.provider',
SERVICE_NAME: 'service.name',
};
export const controlPanelConfigs: ControlPanels = {
[availableControlsPanels.HOST_OS_NAME]: {
order: 0,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.HOST_OS_NAME,
title: 'Operating System',
},
[availableControlsPanels.CLOUD_PROVIDER]: {
order: 1,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.CLOUD_PROVIDER,
title: 'Cloud Provider',
},
[availableControlsPanels.SERVICE_NAME]: {
order: 2,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.SERVICE_NAME,
title: 'Service Name',
},
};

View file

@ -5,18 +5,19 @@
* 2.0.
*/
import React, { useCallback, useEffect, useRef } from 'react';
import {
ControlGroupRenderer,
ControlGroupRendererApi,
DataControlApi,
ControlGroupRuntimeState,
DataControlApi,
} from '@kbn/controls-plugin/public';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { DataView } from '@kbn/data-views-plugin/public';
import { Subscription } from 'rxjs';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useControlPanels } from '../../hooks/use_control_panels_url_state';
import { useControlPanels } from '@kbn/observability-shared-plugin/public';
import React, { useCallback, useEffect, useRef } from 'react';
import { Subscription } from 'rxjs';
import { controlPanelConfigs } from './control_panels_config';
import { ControlTitle } from './controls_title';
interface Props {
@ -34,7 +35,7 @@ export const ControlsContent: React.FC<Props> = ({
timeRange,
onFiltersChange,
}) => {
const [controlPanels, setControlPanels] = useControlPanels(dataView);
const [controlPanels, setControlPanels] = useControlPanels(controlPanelConfigs, dataView);
const subscriptions = useRef<Subscription>(new Subscription());
const getInitialInput = useCallback(

View file

@ -8,8 +8,8 @@
import React from 'react';
import { EuiFormLabel, EuiText, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { availableControlsPanels } from '../../hooks/use_control_panels_url_state';
import { Popover } from '../common/popover';
import { availableControlsPanels } from './control_panels_config';
const helpMessages = {
[availableControlsPanels.SERVICE_NAME]: (

View file

@ -12,7 +12,7 @@ import { constant, identity } from 'fp-ts/lib/function';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import deepEqual from 'fast-deep-equal';
import { useReducer } from 'react';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { DEFAULT_PAGE_SIZE, LOCAL_STORAGE_PAGE_SIZE_KEY } from '../constants';
export const GET_DEFAULT_TABLE_PROPERTIES: TableProperties = {

View file

@ -9,7 +9,7 @@ import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
const DEFAULT_QUERY = {
language: 'kuery',

View file

@ -9,7 +9,7 @@ import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
const TAB_ID_URL_STATE_KEY = 'tabId';

View file

@ -10,9 +10,9 @@ import { buildEsQuery, Filter, fromKueryExpression, TimeRange, type Query } from
import { Subscription, map, tap } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { useKibanaQuerySettings } from '@kbn/observability-shared-plugin/public';
import { useSearchSessionContext } from '../../../../hooks/use_search_session';
import { parseDateRange } from '../../../../utils/datemath';
import { useKibanaQuerySettings } from '../../../../hooks/use_kibana_query_settings';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { telemetryTimeRangeFormatter } from '../../../../../common/formatters/telemetry_time_range';
import { useMetricsDataViewContext } from '../../../../containers/metrics_source';

View file

@ -14,7 +14,7 @@ import { constant, identity } from 'fp-ts/lib/function';
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import { FilterStateStore } from '@kbn/es-query';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,

View file

@ -9,7 +9,7 @@ import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
export const GET_DEFAULT_PROPERTIES: AssetDetailsFlyoutProperties = {
detailsItemId: null,

View file

@ -12,12 +12,12 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import createContainter from 'constate';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import {
type InventoryFiltersState,
inventoryFiltersStateRT,
} from '../../../../../common/inventory_views';
import { useAlertPrefillContext } from '../../../../alerting/use_alert_prefill';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useMetricsDataViewContext } from '../../../../containers/metrics_source';
import { convertKueryToElasticSearchQuery } from '../../../../utils/kuery';

View file

@ -11,6 +11,7 @@ import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import createContainer from 'constate';
import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { InventoryViewOptions } from '../../../../../common/inventory_views/types';
import {
type InventoryLegendOptions,
@ -24,7 +25,6 @@ import type {
SnapshotGroupBy,
SnapshotCustomMetricInput,
} from '../../../../../common/http_api/snapshot_api';
import { useUrlState } from '../../../../hooks/use_url_state';
export const DEFAULT_LEGEND: WaffleLegendOptions = {
palette: 'cool',

View file

@ -12,7 +12,7 @@ import { fold } from 'fp-ts/lib/Either';
import DateMath from '@kbn/datemath';
import { constant, identity } from 'fp-ts/lib/function';
import createContainer from 'constate';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time';
export const DEFAULT_WAFFLE_TIME_STATE: WaffleTimeState = {
currentTime: Date.now(),

View file

@ -13,8 +13,8 @@ import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import { useUrlState } from '@kbn/observability-shared-plugin/public';
import { replaceStateKeyInQueryString } from '../../../../../common/url_state_storage_service';
import { useUrlState } from '../../../../hooks/use_url_state';
const parseRange = (range: MetricsTimeInput) => {
const parsedFrom = dateMath.parse(range.from.toString());

View file

@ -80,29 +80,6 @@ export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({
dataset: ENTITY_LATEST,
});
const entityArrayRt = t.array(t.string);
export const entityTypesRt = new t.Type<string[], string, unknown>(
'entityTypesRt',
entityArrayRt.is,
(input, context) => {
if (typeof input === 'string') {
const arr = input.split(',');
const validation = entityArrayRt.decode(arr);
if (isRight(validation)) {
return t.success(validation.right);
}
} else if (Array.isArray(input)) {
const validation = entityArrayRt.decode(input);
if (isRight(validation)) {
return t.success(validation.right);
}
}
return t.failure(input, context);
},
(arr) => arr.join()
);
export interface Entity {
[ENTITY_LAST_SEEN]: string;
[ENTITY_ID]: string;
@ -117,7 +94,7 @@ export interface Entity {
export type EntityGroup = {
count: number;
} & {
[key: string]: any;
[key: string]: string;
};
export type InventoryEntityLatest = z.infer<typeof entityLatestSchema> & {

View file

@ -1,57 +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 { isLeft, isRight } from 'fp-ts/lib/Either';
import { entityTypesRt } from './entities';
const validate = (input: unknown) => entityTypesRt.decode(input);
describe('entityTypesRt codec', () => {
it('should validate a valid string of entity types', () => {
const input = 'service,host,container';
const result = validate(input);
expect(isRight(result)).toBe(true);
if (isRight(result)) {
expect(result.right).toEqual(['service', 'host', 'container']);
}
});
it('should validate a valid array of entity types', () => {
const input = ['service', 'host', 'container'];
const result = validate(input);
expect(isRight(result)).toBe(true);
if (isRight(result)) {
expect(result.right).toEqual(['service', 'host', 'container']);
}
});
it('should fail validation when input is not a string or array', () => {
const input = 123;
const result = validate(input);
expect(isLeft(result)).toBe(true);
});
it('should validate an empty array as valid', () => {
const input: unknown[] = [];
const result = validate(input);
expect(isRight(result)).toBe(true);
if (isRight(result)) {
expect(result.right).toEqual([]);
}
});
it('should serialize a valid array back to a string', () => {
const input = ['service', 'host'];
const serialized = entityTypesRt.encode(input);
expect(serialized).toBe('service,host');
});
it('should serialize an empty array back to an empty string', () => {
const input: string[] = [];
const serialized = entityTypesRt.encode(input);
expect(serialized).toBe('');
});
});

View file

@ -165,16 +165,17 @@ 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/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFilterserviceOption')
.click();
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-service').click();
cy.wait('@getGroups');
cy.contains('service');
cy.getByTestSubj('inventoryGroupTitle_entity.type_service').click();
cy.wait('@getEntities');
cy.get('server1').should('not.exist');
@ -188,16 +189,17 @@ 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/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFilterhostOption')
.click();
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-host').click();
cy.wait('@getGroups');
cy.contains('host');
cy.getByTestSubj('inventoryGroupTitle_entity.type_host').click();
cy.wait('@getEntities');
cy.contains('server1');
@ -211,16 +213,17 @@ 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/group_by/**').as('getGroups');
cy.visitKibana('/app/inventory');
cy.wait('@getEEMStatus');
cy.getByTestSubj('entityTypesFilterComboBox')
.click()
.getByTestSubj('entityTypesFiltercontainerOption')
.click();
cy.getByTestSubj('optionsList-control-entity.type').click();
cy.wait('@entityTypeControlGroupOptions');
cy.getByTestSubj('optionsList-control-selection-container').click();
cy.wait('@getGroups');
cy.contains('container');
cy.getByTestSubj('inventoryGroupTitle_entity.type_container').click();
cy.wait('@getEntities');
cy.contains('server1').should('not.exist');

View file

@ -21,7 +21,7 @@
"ruleRegistry",
"share"
],
"requiredBundles": ["kibanaReact"],
"requiredBundles": ["kibanaReact","controls"],
"optionalPlugins": ["spaces", "cloud"],
"extraPublicDirs": []
}

View file

@ -12,12 +12,12 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config';
import React from 'react';
import { InventoryContextProvider } from '../../context/inventory_context_provider';
import { InventorySearchBarContextProvider } from '../../context/inventory_search_bar_context_provider';
import { KibanaEnvironment } from '../../hooks/use_kibana';
import { UnifiedSearchProvider } from '../../hooks/use_unified_search_context';
import { inventoryRouter } from '../../routes/config';
import { InventoryServices } from '../../services/types';
import { InventoryStartDependencies } from '../../types';
import { HeaderActionMenuItems } from './header_action_menu';
import { KibanaEnvironment } from '../../hooks/use_kibana';
export function AppRoot({
coreStart,
@ -43,12 +43,12 @@ export function AppRoot({
return (
<InventoryContextProvider context={context}>
<RedirectAppLinks coreStart={coreStart}>
<InventorySearchBarContextProvider>
<RouterProvider history={history} router={inventoryRouter}>
<RouterProvider history={history} router={inventoryRouter as any}>
<UnifiedSearchProvider>
<RouteRenderer />
<InventoryHeaderActionMenu appMountParameters={appMountParameters} />
</RouterProvider>
</InventorySearchBarContextProvider>
</UnifiedSearchProvider>
</RouterProvider>
</RedirectAppLinks>
</InventoryContextProvider>
);

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { BadgeFilterWithPopover } from '.';
import { EuiThemeProvider, copyToClipboard } from '@elastic/eui';
import { copyToClipboard } from '@elastic/eui';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { BadgeFilterWithPopover } from '.';
jest.mock('@elastic/eui', () => ({
...jest.requireActual('@elastic/eui'),
@ -17,10 +17,8 @@ jest.mock('@elastic/eui', () => ({
}));
describe('BadgeFilterWithPopover', () => {
const mockOnFilter = jest.fn();
const field = ENTITY_TYPE;
const value = 'host';
const label = 'Host';
const popoverContentDataTestId = 'inventoryBadgeFilterWithPopoverContent';
const popoverContentTitleTestId = 'inventoryBadgeFilterWithPopoverTitle';
@ -28,32 +26,16 @@ describe('BadgeFilterWithPopover', () => {
jest.clearAllMocks();
});
it('renders the badge with the correct label', () => {
render(
<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} label={label} />,
{ wrapper: EuiThemeProvider }
);
expect(screen.queryByText(label)).toBeInTheDocument();
expect(screen.getByText(label).textContent).toBe(label);
});
it('opens the popover when the badge is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
render(<BadgeFilterWithPopover field={field} value={value} />);
expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument();
fireEvent.click(screen.getByText(value));
expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument();
expect(screen.queryByTestId(popoverContentTitleTestId)?.textContent).toBe(`${field}:${value}`);
});
it('calls onFilter when the "Filter for" button is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton'));
expect(mockOnFilter).toHaveBeenCalled();
});
it('copies value to clipboard when the "Copy value" button is clicked', () => {
render(<BadgeFilterWithPopover field={field} value={value} onFilter={mockOnFilter} />);
render(<BadgeFilterWithPopover field={field} value={value} />);
fireEvent.click(screen.getByText(value));
fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton'));
expect(copyToClipboard).toHaveBeenCalledWith(value);

View file

@ -8,28 +8,29 @@
import {
EuiBadge,
EuiButtonEmpty,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
copyToClipboard,
useEuiTheme,
} 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';
interface Props {
field: string;
value: string;
label?: string;
onFilter: () => void;
}
export function BadgeFilterWithPopover({ field, value, onFilter, label }: Props) {
export function BadgeFilterWithPopover({ field, value }: Props) {
const [isOpen, setIsOpen] = useState(false);
const theme = useEuiTheme();
const { addFilter } = useUnifiedSearchContext();
return (
<EuiPopover
@ -43,60 +44,76 @@ export function BadgeFilterWithPopover({ field, value, onFilter, label }: Props)
{ defaultMessage: 'Open popover' }
)}
>
{label || value}
{value}
</EuiBadge>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
panelPaddingSize="s"
>
<span data-test-subj="inventoryBadgeFilterWithPopoverTitle">
<EuiFlexGroup
data-test-subj="inventoryBadgeFilterWithPopoverContent"
responsive={false}
gutterSize="xs"
css={css`
font-family: ${theme.euiTheme.font.familyCode};
`}
>
<EuiFlexItem grow={false}>
<span
css={css`
font-weight: bold;
`}
>
{field}:
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="eui-textBreakWord">{value}</span>
</EuiFlexItem>
</EuiFlexGroup>
</span>
<EuiPopoverTitle>
<span data-test-subj="inventoryBadgeFilterWithPopoverTitle">
<EuiFlexGroup
data-test-subj="inventoryBadgeFilterWithPopoverContent"
responsive={false}
gutterSize="xs"
css={css`
font-family: ${theme.euiTheme.font.familyCode};
`}
>
<EuiFlexItem grow={false}>
<span
css={css`
font-weight: bold;
`}
>
{field}:
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="eui-textBreakWord">{value}</span>
</EuiFlexItem>
</EuiFlexGroup>
</span>
</EuiPopoverTitle>
<EuiFlexGroup responsive={false}>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
iconType="plusInCircle"
onClick={() => {
addFilter({ fieldName: ENTITY_TYPE, operation: '+', value });
}}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
defaultMessage: 'Filter for',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
iconType="minusInCircle"
onClick={() => {
addFilter({ fieldName: ENTITY_TYPE, operation: '-', value });
}}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
defaultMessage: 'Filter out',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiPopoverFooter>
<EuiFlexGrid responsive={false} columns={2}>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverFilterForButton"
iconType="plusInCircle"
onClick={onFilter}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', {
defaultMessage: 'Filter for',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverCopyValueButton"
iconType="copyClipboard"
onClick={() => copyToClipboard(value)}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', {
defaultMessage: 'Copy value',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGrid>
<EuiButtonEmpty
data-test-subj="inventoryBadgeFilterWithPopoverCopyValueButton"
iconType="copyClipboard"
onClick={() => copyToClipboard(value)}
>
{i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', {
defaultMessage: 'Copy value',
})}
</EuiButtonEmpty>
</EuiPopoverFooter>
</EuiPopover>
);

View file

@ -77,7 +77,6 @@ export const Grid: Story<EntityGridStoriesArgs> = (args) => {
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={(selectedEntityType) => updateArgs({ entityType: selectedEntityType })}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -100,7 +99,6 @@ export const EmptyGrid: Story<EntityGridStoriesArgs> = (args) => {
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
onFilterByType={() => {}}
/>
);
};

View file

@ -40,7 +40,6 @@ interface Props {
pageIndex: number;
onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void;
onChangePage: (nextPage: number) => void;
onFilterByType: (entityType: string) => void;
}
const PAGE_SIZE = 20;
@ -53,7 +52,6 @@ export function EntitiesGrid({
pageIndex,
onChangePage,
onChangeSort,
onFilterByType,
}: Props) {
const { getDiscoverRedirectUrl } = useDiscoverRedirect();
@ -98,14 +96,7 @@ export function EntitiesGrid({
return entity?.alertsCount ? <AlertsBadge entity={entity} /> : null;
case ENTITY_TYPE:
return (
<BadgeFilterWithPopover
field={ENTITY_TYPE}
value={entityType}
label={entityType}
onFilter={() => onFilterByType(entityType)}
/>
);
return <BadgeFilterWithPopover field={ENTITY_TYPE} value={entityType} />;
case ENTITY_LAST_SEEN:
return (
<FormattedMessage
@ -147,7 +138,7 @@ export function EntitiesGrid({
return entity[columnId as EntityColumnIds] || '';
}
},
[entities, getDiscoverRedirectUrl, onFilterByType]
[entities, getDiscoverRedirectUrl]
);
if (loading) {

View file

@ -4,13 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiDataGridSorting } from '@elastic/eui';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useKibana } from '../../hooks/use_kibana';
import { EntitiesGrid } from '../entities_grid';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import {
entityPaginationRt,
type EntityColumnIds,
@ -19,35 +16,37 @@ import {
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';
interface Props {
field: string;
groupValue: string;
}
const paginationDecoder = decodeOrThrow(entityPaginationRt);
export function GroupedEntitiesGrid({ field }: Props) {
export function GroupedEntitiesGrid({ groupValue }: Props) {
const { query } = useInventoryParams('/');
const { sortField, sortDirection, kuery, pagination: paginationQuery } = query;
const { sortField, sortDirection, pagination: paginationQuery } = query;
const inventoryRoute = useInventoryRouter();
let pagination: EntityPagination | undefined = {};
const { stringifiedEsQuery } = useUnifiedSearchContext();
try {
pagination = paginationDecoder(paginationQuery);
} catch (error) {
inventoryRoute.push('/', {
path: {},
query: {
sortField,
sortDirection,
kuery,
...query,
pagination: undefined,
},
});
window.location.reload();
}
const pageIndex = pagination?.[field] ?? 0;
const pageIndex = pagination?.[groupValue] ?? 0;
const { refreshSubject$ } = useInventorySearchBarContext();
const { refreshSubject$, isControlPanelsInitiated } = useUnifiedSearchContext();
const {
services: { inventoryAPIClient },
} = useKibana();
@ -58,19 +57,28 @@ export function GroupedEntitiesGrid({ field }: Props) {
refresh,
} = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
entityTypes: field?.length ? JSON.stringify([field]) : undefined,
kuery,
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
esQuery: stringifiedEsQuery,
entityTypes: groupValue?.length ? JSON.stringify([groupValue]) : undefined,
},
},
},
signal,
});
signal,
});
}
},
[field, inventoryAPIClient, kuery, sortDirection, sortField]
[
groupValue,
inventoryAPIClient,
sortDirection,
sortField,
isControlPanelsInitiated,
stringifiedEsQuery,
]
);
useEffectOnce(() => {
@ -86,7 +94,7 @@ export function GroupedEntitiesGrid({ field }: Props) {
...query,
pagination: entityPaginationRt.encode({
...pagination,
[field]: nextPage,
[groupValue]: nextPage,
}),
},
});
@ -103,18 +111,6 @@ export function GroupedEntitiesGrid({ field }: Props) {
});
}
function handleTypeFilter(type: string) {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: {
...rest,
// Override the current entity types
entityTypes: [type],
},
});
}
return (
<EntitiesGrid
entities={value.entities}
@ -124,7 +120,6 @@ export function GroupedEntitiesGrid({ field }: Props) {
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
);
}

View file

@ -8,20 +8,18 @@ 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 { InventoryGroupAccordion } from './inventory_group_accordion';
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';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
export function GroupedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { query } = useInventoryParams('/');
const { kuery, entityTypes } = query;
const { refreshSubject$ } = useInventorySearchBarContext();
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
useUnifiedSearchContext();
const {
value = { groupBy: ENTITY_TYPE, groups: [], entitiesCount: 0 },
@ -29,20 +27,19 @@ export function GroupedInventory() {
loading,
} = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
params: {
path: {
field: ENTITY_TYPE,
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities/group_by/{field}', {
params: {
path: {
field: ENTITY_TYPE,
},
query: { esQuery: stringifiedEsQuery },
},
query: {
kuery,
entityTypes: entityTypes?.length ? JSON.stringify(entityTypes) : undefined,
},
},
signal,
});
signal,
});
}
},
[entityTypes, inventoryAPIClient, kuery]
[inventoryAPIClient, stringifiedEsQuery, isControlPanelsInitiated]
);
useEffectOnce(() => {
@ -58,8 +55,9 @@ export function GroupedInventory() {
{value.groups.map((group) => (
<InventoryGroupAccordion
key={`${value.groupBy}-${group[value.groupBy]}`}
group={group}
groupBy={value.groupBy}
groupValue={group[value.groupBy]}
groupCount={group.count}
isLoading={loading}
/>
))}

View file

@ -25,7 +25,13 @@ describe('Grouped Inventory Accordion', () => {
},
],
};
render(<InventoryGroupAccordion group={props.groups[0]} groupBy={props.groupBy} />);
render(
<InventoryGroupAccordion
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');
expect(within(container).getByText('Entities:')).toBeInTheDocument();

View file

@ -4,12 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiAccordion, EuiPanel, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
import { GroupedEntitiesGrid } from './grouped_entities_grid';
import type { EntityGroup } from '../../../common/entities';
import { InventoryPanelBadge } from './inventory_panel_badge';
const ENTITIES_COUNT_BADGE = i18n.translate(
@ -18,18 +17,19 @@ const ENTITIES_COUNT_BADGE = i18n.translate(
);
export interface InventoryGroupAccordionProps {
group: EntityGroup;
groupBy: string;
groupValue: string;
groupCount: number;
isLoading?: boolean;
}
export function InventoryGroupAccordion({
group,
groupBy,
groupValue,
groupCount,
isLoading,
}: InventoryGroupAccordionProps) {
const { euiTheme } = useEuiTheme();
const field = group[groupBy];
const [open, setOpen] = useState(false);
const onToggle = useCallback(() => {
@ -46,19 +46,19 @@ export function InventoryGroupAccordion({
`}
>
<EuiAccordion
data-test-subj={`inventoryGroup_${groupBy}_${field}`}
id={`inventory-group-${groupBy}-${field}`}
data-test-subj={`inventoryGroup_${groupBy}_${groupValue}`}
id={`inventory-group-${groupBy}-${groupValue}`}
buttonContent={
<EuiTitle size="xs">
<h4 data-test-subj={`inventoryGroupTitle_${groupBy}_${field}`}>{field}</h4>
<h4 data-test-subj={`inventoryGroupTitle_${groupBy}_${groupValue}`}>{groupValue}</h4>
</EuiTitle>
}
buttonElement="div"
extraAction={
<InventoryPanelBadge
data-test-subj={`inventoryPanelBadgeEntitiesCount_${groupBy}_${field}`}
data-test-subj={`inventoryPanelBadgeEntitiesCount_${groupBy}_${groupValue}`}
name={ENTITIES_COUNT_BADGE}
value={group.count}
value={groupCount}
/>
}
buttonProps={{ paddingSize: 'm' }}
@ -78,7 +78,7 @@ export function InventoryGroupAccordion({
hasShadow={false}
paddingSize="m"
>
<GroupedEntitiesGrid field={field} />
<GroupedEntitiesGrid groupValue={groupValue} />
</EuiPanel>
)}
<EuiSpacer size="s" />

View file

@ -0,0 +1,98 @@
/*
* 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>
);
}

View file

@ -1,67 +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 { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useKibana } from '../../hooks/use_kibana';
interface Props {
onChange: (entityTypes: string[]) => void;
}
const toComboBoxOption = (entityType: string): EuiComboBoxOptionOption => ({
key: entityType,
label: entityType,
'data-test-subj': `entityTypesFilter${entityType}Option`,
});
export function EntityTypesControls({ onChange }: Props) {
const {
query: { entityTypes = [] },
} = useInventoryParams('/*');
const {
services: { inventoryAPIClient },
} = useKibana();
const { value, loading } = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities/types', { signal });
},
[inventoryAPIClient]
);
const options = value?.entityTypes.map(toComboBoxOption);
const selectedOptions = entityTypes.map(toComboBoxOption);
return (
<EuiComboBox
data-test-subj="entityTypesFilterComboBox"
isLoading={loading}
css={css`
max-width: 325px;
`}
aria-label={i18n.translate(
'xpack.inventory.entityTypesControls.euiComboBox.accessibleScreenReaderLabel',
{ defaultMessage: 'Entity types filter' }
)}
placeholder={i18n.translate(
'xpack.inventory.entityTypesControls.euiComboBox.placeHolderLabel',
{ defaultMessage: 'Types' }
)}
options={options}
selectedOptions={selectedOptions}
onChange={(newOptions) => {
onChange(newOptions.map((option) => option.key).filter((key): key is string => !!key));
}}
isClearable
/>
);
}

View file

@ -4,39 +4,35 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { Query } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar';
import type { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar';
import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Query } from '@kbn/es-query';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useKibana } from '../../hooks/use_kibana';
import { EntityTypesControls } from './entity_types_controls';
import { DiscoverButton } from './discover_button';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { getKqlFieldsWithFallback } from '../../utils/get_kql_field_names_with_fallback';
import { ControlGroups } from './control_groups';
import { DiscoverButton } from './discover_button';
export function SearchBar() {
const { refreshSubject$, searchBarContentSubject$, dataView } = useInventorySearchBarContext();
const { refreshSubject$, dataView, searchState, onQueryChange } = useUnifiedSearchContext();
const {
services: {
unifiedSearch,
telemetry,
data: {
query: { queryString: queryStringService },
},
telemetry,
},
} = useKibana();
const {
query: { kuery, entityTypes },
} = useInventoryParams('/*');
const { SearchBar: UnifiedSearchBar } = unifiedSearch.ui;
const syncSearchBarWithUrl = useCallback(() => {
const query = kuery ? { query: kuery, language: 'kuery' } : undefined;
const query = searchState.query;
if (query && !deepEqual(queryStringService.getQuery(), query)) {
queryStringService.setQuery(query);
}
@ -44,67 +40,36 @@ export function SearchBar() {
if (!query) {
queryStringService.clearQuery();
}
}, [kuery, queryStringService]);
}, [searchState.query, queryStringService]);
useEffect(() => {
syncSearchBarWithUrl();
}, [syncSearchBarWithUrl]);
const registerSearchSubmittedEvent = useCallback(
({
searchQuery,
searchIsUpdate,
searchEntityTypes,
}: {
searchQuery?: Query;
searchEntityTypes?: string[];
searchIsUpdate?: boolean;
}) => {
({ searchQuery, searchIsUpdate }: { searchQuery?: Query; searchIsUpdate?: boolean }) => {
telemetry.reportEntityInventorySearchQuerySubmitted({
kuery_fields: getKqlFieldsWithFallback(searchQuery?.query as string),
entity_types: searchEntityTypes || [],
action: searchIsUpdate ? 'submit' : 'refresh',
});
},
[telemetry]
);
const registerEntityTypeFilteredEvent = useCallback(
({ filterEntityTypes, filterKuery }: { filterEntityTypes: string[]; filterKuery?: string }) => {
telemetry.reportEntityInventoryEntityTypeFiltered({
entity_types: filterEntityTypes,
kuery_fields: filterKuery ? getKqlFieldsWithFallback(filterKuery) : [],
});
},
[telemetry]
);
const handleEntityTypesChange = useCallback(
(nextEntityTypes: string[]) => {
searchBarContentSubject$.next({ kuery, entityTypes: nextEntityTypes });
registerEntityTypeFilteredEvent({ filterEntityTypes: nextEntityTypes, filterKuery: kuery });
},
[kuery, registerEntityTypeFilteredEvent, searchBarContentSubject$]
);
const handleQuerySubmit = useCallback<NonNullable<SearchBarOwnProps['onQuerySubmit']>>(
({ query }, isUpdate) => {
searchBarContentSubject$.next({
kuery: query?.query as string,
entityTypes,
});
({ query = { language: 'kuery', query: '' } }, isUpdate) => {
if (isUpdate) {
onQueryChange(query);
} else {
refreshSubject$.next();
}
registerSearchSubmittedEvent({
searchQuery: query,
searchEntityTypes: entityTypes,
searchIsUpdate: isUpdate,
});
if (!isUpdate) {
refreshSubject$.next();
}
},
[searchBarContentSubject$, entityTypes, registerSearchSubmittedEvent, refreshSubject$]
[registerSearchSubmittedEvent, onQueryChange, refreshSubject$]
);
return (
@ -113,15 +78,17 @@ export function SearchBar() {
<UnifiedSearchBar
appName="Inventory"
displayStyle="inPage"
showDatePicker={false}
showFilterBar={false}
indexPatterns={dataView ? [dataView] : undefined}
renderQueryInputAppend={() => <EntityTypesControls onChange={handleEntityTypesChange} />}
renderQueryInputAppend={() => <ControlGroups />}
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
/>
</EuiFlexItem>

View file

@ -5,21 +5,21 @@
* 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 { decodeOrThrow } from '@kbn/io-ts-utils';
import {
type EntityColumnIds,
entityPaginationRt,
type EntityColumnIds,
type EntityPagination,
} from '../../../common/entities';
import { EntitiesGrid } from '../entities_grid';
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 { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { InventorySummary } from './inventory_summary';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search_context';
import { EntitiesGrid } from '../entities_grid';
import { InventorySummary } from '../grouped_inventory/inventory_summary';
const paginationDecoder = decodeOrThrow(entityPaginationRt);
@ -27,9 +27,11 @@ export function UnifiedInventory() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { refreshSubject$ } = useInventorySearchBarContext();
const { refreshSubject$, isControlPanelsInitiated, stringifiedEsQuery } =
useUnifiedSearchContext();
const { query } = useInventoryParams('/');
const { sortDirection, sortField, kuery, entityTypes, pagination: paginationQuery } = query;
const { sortDirection, sortField, pagination: paginationQuery } = query;
let pagination: EntityPagination | undefined = {};
const inventoryRoute = useInventoryRouter();
try {
@ -38,9 +40,7 @@ export function UnifiedInventory() {
inventoryRoute.push('/', {
path: {},
query: {
sortField,
sortDirection,
kuery,
...query,
pagination: undefined,
},
});
@ -55,24 +55,24 @@ export function UnifiedInventory() {
refresh,
} = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
entityTypes: entityTypes?.length ? JSON.stringify(entityTypes) : undefined,
kuery,
if (isControlPanelsInitiated) {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
esQuery: stringifiedEsQuery,
},
},
},
signal,
});
signal,
});
}
},
[entityTypes, inventoryAPIClient, kuery, sortDirection, sortField]
[inventoryAPIClient, sortDirection, sortField, isControlPanelsInitiated, stringifiedEsQuery]
);
useEffectOnce(() => {
const refreshSubscription = refreshSubject$.subscribe(refresh);
return () => refreshSubscription.unsubscribe();
});
@ -100,19 +100,6 @@ export function UnifiedInventory() {
});
}
function handleTypeFilter(type: string) {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: {
...rest,
// Override the current entity types
entityTypes: [type],
},
});
}
return (
<>
<InventorySummary />
@ -124,7 +111,6 @@ export function UnifiedInventory() {
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
onFilterByType={handleTypeFilter}
/>
</>
);

View file

@ -5,19 +5,16 @@
* 2.0.
*/
import {
ENTITY_TYPE,
ENTITY_DEFINITION_ID,
ENTITY_DISPLAY_NAME,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
import { useCallback } from 'react';
import { type PhrasesFilter, buildPhrasesFilter } from '@kbn/es-query';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import type { Entity, EntityColumnIds } from '../../common/entities';
import { unflattenEntity } from '../../common/utils/unflatten_entity';
import { useKibana } from './use_kibana';
import { useInventoryParams } from './use_inventory_params';
import { useInventorySearchBarContext } from '../context/inventory_search_bar_context_provider';
import { useUnifiedSearchContext } from './use_unified_search_context';
const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN];
@ -25,32 +22,22 @@ export const useDiscoverRedirect = () => {
const {
services: { share, application, entityManager },
} = useKibana();
const {
query: { kuery, entityTypes },
} = useInventoryParams('/*');
const { dataView } = useInventorySearchBarContext();
const {
dataView,
searchState: { query, filters, panelFilters },
} = useUnifiedSearchContext();
const discoverLocator = share.url.locators.get('DISCOVER_APP_LOCATOR');
const getDiscoverEntitiesRedirectUrl = useCallback(
(entity?: Entity) => {
const filters: PhrasesFilter[] = [];
const entityTypeField = (dataView?.getFieldByName(ENTITY_TYPE) ??
entity?.[ENTITY_TYPE]) as DataViewField;
if (entityTypes && entityTypeField && dataView) {
const entityTypeFilter = buildPhrasesFilter(entityTypeField, entityTypes, dataView);
filters.push(entityTypeFilter);
}
const entityKqlFilter = entity
? entityManager.entityClient.asKqlFilter(unflattenEntity(entity))
: '';
const kueryWithEntityDefinitionFilters = [
kuery,
query.query,
entityKqlFilter,
`${ENTITY_DEFINITION_ID} : builtin*`,
]
@ -62,17 +49,18 @@ export const useDiscoverRedirect = () => {
indexPatternId: dataView?.id ?? '',
columns: ACTIVE_COLUMNS,
query: { query: kueryWithEntityDefinitionFilters, language: 'kuery' },
filters,
filters: [...filters, ...panelFilters],
})
: undefined;
},
[
application.capabilities.discover?.show,
dataView?.id,
discoverLocator,
entityManager.entityClient,
entityTypes,
kuery,
dataView,
filters,
panelFilters,
query.query,
]
);

View file

@ -0,0 +1,166 @@
/*
* 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 { 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 { useAdHocInventoryDataView } from './use_adhoc_inventory_data_view';
import { useUnifiedSearchUrl } from './use_unified_search_url';
import { useKibana } from './use_kibana';
function useUnifiedSearch() {
const [isControlPanelsInitiated, setIsControlPanelsInitiated] = useState(false);
const { dataView } = useAdHocInventoryDataView();
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,
};
}
const UnifiedSearch = createContainer(useUnifiedSearch);
export const [UnifiedSearchProvider, useUnifiedSearchContext] = UnifiedSearch;

View file

@ -0,0 +1,100 @@
/*
* 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 };
}

View file

@ -4,36 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
import { UnifiedInventory } from '../../components/grouped_inventory/unified_inventory';
import React from 'react';
import { GroupedInventory } from '../../components/grouped_inventory';
import { UnifiedInventory } from '../../components/unified_inventory';
import { useInventoryParams } from '../../hooks/use_inventory_params';
export function InventoryPage() {
const { searchBarContentSubject$ } = useInventorySearchBarContext();
const inventoryRoute = useInventoryRouter();
const { query } = useInventoryParams('/');
useEffect(() => {
const searchBarContentSubscription = searchBarContentSubject$.subscribe(
({ ...queryParams }) => {
const { pagination: _, ...rest } = query;
inventoryRoute.push('/', {
path: {},
query: { ...rest, ...queryParams },
});
}
);
return () => {
searchBarContentSubscription.unsubscribe();
};
// If query has updated, the inventoryRoute state is also updated
// as well, so we only need to track changes on query.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, searchBarContentSubject$]);
return query.view === 'unified' ? <UnifiedInventory /> : <GroupedInventory />;
}

View file

@ -9,12 +9,7 @@ import * as t from 'io-ts';
import React from 'react';
import { InventoryPageTemplate } from '../components/inventory_page_template';
import { InventoryPage } from '../pages/inventory_page';
import {
defaultEntitySortField,
entityTypesRt,
entityColumnIdsRt,
entityViewRt,
} from '../../common/entities';
import { defaultEntitySortField, entityColumnIdsRt, entityViewRt } from '../../common/entities';
/**
* The array of route definitions to be used when the application
@ -34,10 +29,10 @@ const inventoryRoutes = {
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
entityTypes: entityTypesRt,
kuery: t.string,
view: entityViewRt,
pagination: t.string,
_a: t.string,
controlPanels: t.string,
}),
]),
}),

View file

@ -14,7 +14,6 @@ import {
type EntityInventoryViewedParams,
type EntityInventorySearchQuerySubmittedParams,
type EntityViewClickedParams,
type EntityInventoryEntityTypeFilteredParams,
} from './types';
export class TelemetryClient implements ITelemetryClient {
@ -34,12 +33,6 @@ export class TelemetryClient implements ITelemetryClient {
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_INVENTORY_SEARCH_QUERY_SUBMITTED, params);
};
public reportEntityInventoryEntityTypeFiltered = (
params: EntityInventoryEntityTypeFilteredParams
) => {
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED, params);
};
public reportEntityViewClicked = (params: EntityViewClickedParams) => {
this.analytics.reportEvent(TelemetryEventTypes.ENTITY_VIEW_CLICKED, params);
};

View file

@ -49,15 +49,6 @@ const searchQuerySubmittedEventType: TelemetryEvent = {
},
},
},
entity_types: {
type: 'array',
items: {
type: 'keyword',
_meta: {
description: 'Entity types used in the search.',
},
},
},
action: {
type: 'keyword',
_meta: {
@ -67,30 +58,6 @@ const searchQuerySubmittedEventType: TelemetryEvent = {
},
};
const entityInventoryEntityTypeFilteredEventType: TelemetryEvent = {
eventType: TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
schema: {
entity_types: {
type: 'array',
items: {
type: 'keyword',
_meta: {
description: 'Entity types used in the filter.',
},
},
},
kuery_fields: {
type: 'array',
items: {
type: 'text',
_meta: {
description: 'Kuery fields used in the filter.',
},
},
},
},
};
const entityViewClickedEventType: TelemetryEvent = {
eventType: TelemetryEventTypes.ENTITY_VIEW_CLICKED,
schema: {
@ -113,6 +80,5 @@ export const inventoryTelemetryEventBasedTypes = [
inventoryAddDataEventType,
entityInventoryViewedEventType,
searchQuerySubmittedEventType,
entityInventoryEntityTypeFilteredEventType,
entityViewClickedEventType,
];

View file

@ -13,7 +13,6 @@ import {
type EntityViewClickedParams,
type EntityInventorySearchQuerySubmittedParams,
TelemetryEventTypes,
type EntityInventoryEntityTypeFilteredParams,
} from './types';
describe('TelemetryService', () => {
@ -115,7 +114,6 @@ describe('TelemetryService', () => {
const params: EntityInventorySearchQuerySubmittedParams = {
kuery_fields: ['_index'],
action: 'submit',
entity_types: ['container'],
};
telemetry.reportEntityInventorySearchQuerySubmitted(params);
@ -128,26 +126,6 @@ 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 = {
kuery_fields: ['_index'],
entity_types: ['container'],
};
telemetry.reportEntityInventoryEntityTypeFiltered(params);
expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1);
expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith(
TelemetryEventTypes.ENTITY_INVENTORY_ENTITY_TYPE_FILTERED,
params
);
});
});
describe('#reportEntityViewClicked', () => {
it('should report entity view clicked with properties', async () => {
const setupParams = getSetupParams();

View file

@ -27,15 +27,9 @@ export interface EntityInventoryViewedParams {
export interface EntityInventorySearchQuerySubmittedParams {
kuery_fields: string[];
entity_types: string[];
action: 'submit' | 'refresh';
}
export interface EntityInventoryEntityTypeFilteredParams {
kuery_fields: string[];
entity_types: string[];
}
export interface EntityViewClickedParams {
entity_type: string;
view_type: 'detail' | 'flyout';
@ -45,7 +39,6 @@ export type TelemetryEventParams =
| InventoryAddDataParams
| EntityInventoryViewedParams
| EntityInventorySearchQuerySubmittedParams
| EntityInventoryEntityTypeFilteredParams
| EntityViewClickedParams;
export interface ITelemetryClient {
@ -54,7 +47,6 @@ export interface ITelemetryClient {
reportEntityInventorySearchQuerySubmitted(
params: EntityInventorySearchQuerySubmittedParams
): void;
reportEntityInventoryEntityTypeFiltered(params: EntityInventoryEntityTypeFilteredParams): void;
reportEntityViewClicked(params: EntityViewClickedParams): void;
}

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import {
ENTITIES_LATEST_ALIAS,
type EntityGroup,
@ -20,38 +18,23 @@ import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper';
export async function getEntityGroupsBy({
inventoryEsClient,
field,
kuery,
entityTypes,
esQuery,
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
field: string;
kuery?: string;
entityTypes?: string[];
esQuery?: QueryDslQueryContainer;
}) {
const from = `FROM ${ENTITIES_LATEST_ALIAS}`;
const where = [getBuiltinEntityDefinitionIdESQLWhereClause()];
const params: ScalarValue[] = [];
if (entityTypes) {
where.push(`WHERE ${ENTITY_TYPE} IN (${entityTypes.map(() => '?').join()})`);
params.push(...entityTypes);
}
// STATS doesn't support parameterisation.
const group = `STATS count = COUNT(*) by ${field}`;
const sort = `SORT ${field} asc`;
// LIMIT doesn't support parameterisation.
const limit = `LIMIT ${MAX_NUMBER_OF_ENTITIES}`;
const query = [from, ...where, group, sort, limit].join(' | ');
const groups = await inventoryEsClient.esql('get_entities_groups', {
query,
filter: {
bool: {
filter: kqlQuery(kuery),
},
},
params,
filter: esQuery,
});
return esqlResultToPlainObjects<EntityGroup>(groups);

View file

@ -5,11 +5,10 @@
* 2.0.
*/
import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import type { ScalarValue } from '@elastic/elasticsearch/lib/api/types';
import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import {
ENTITIES_LATEST_ALIAS,
MAX_NUMBER_OF_ENTITIES,
@ -22,14 +21,14 @@ export async function getLatestEntities({
inventoryEsClient,
sortDirection,
sortField,
esQuery,
entityTypes,
kuery,
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
sortDirection: 'asc' | 'desc';
sortField: EntityColumnIds;
esQuery?: QueryDslQueryContainer;
entityTypes?: string[];
kuery?: string;
}) {
// alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default.
const entitiesSortField = sortField === 'alertsCount' ? ENTITY_LAST_SEEN : sortField;
@ -50,11 +49,7 @@ export async function getLatestEntities({
const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', {
query,
filter: {
bool: {
filter: [...kqlQuery(kuery)],
},
},
filter: esQuery,
params,
});

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import { kqlQuery, termQuery } from '@kbn/observability-plugin/server';
import { ALERT_STATUS, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils';
import { termQuery } from '@kbn/observability-plugin/server';
import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { ALERT_STATUS, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils';
import { AlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client';
import { getGroupByTermsAgg } from './get_group_by_terms_agg';
import { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
@ -21,11 +21,9 @@ type EntityTypeBucketsAggregation = Record<string, { buckets: Bucket[] }>;
export async function getLatestEntitiesAlerts({
alertsClient,
kuery,
identityFieldsPerEntityType,
}: {
alertsClient: AlertsClient;
kuery?: string;
identityFieldsPerEntityType: IdentityFieldsPerEntityType;
}): Promise<Array<{ [key: string]: any; alertsCount?: number; [ENTITY_TYPE]: string }>> {
if (identityFieldsPerEntityType.size === 0) {
@ -37,7 +35,7 @@ export async function getLatestEntitiesAlerts({
track_total_hits: false,
query: {
bool: {
filter: [...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE), ...kqlQuery(kuery)],
filter: termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE),
},
},
};

View file

@ -47,8 +47,8 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
esQuery: jsonRt.pipe(t.UnknownRecord),
entityTypes: jsonRt.pipe(t.array(t.string)),
kuery: t.string,
}),
]),
}),
@ -69,7 +69,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
plugin: `@kbn/${INVENTORY_APP_ID}-plugin`,
});
const { sortDirection, sortField, entityTypes, kuery } = params.query;
const { sortDirection, sortField, esQuery, entityTypes } = params.query;
const [alertsClient, latestEntities] = await Promise.all([
createAlertsClient({ plugins, request }),
@ -77,8 +77,8 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
inventoryEsClient,
sortDirection,
sortField,
esQuery,
entityTypes,
kuery,
}),
]);
@ -87,7 +87,6 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
const alerts = await getLatestEntitiesAlerts({
identityFieldsPerEntityType,
alertsClient,
kuery,
});
const joined = joinByKey(
@ -114,8 +113,7 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
t.type({ path: t.type({ field: t.literal(ENTITY_TYPE) }) }),
t.partial({
query: t.partial({
kuery: t.string,
entityTypes: jsonRt.pipe(t.array(t.string)),
esQuery: jsonRt.pipe(t.UnknownRecord),
}),
}),
]),
@ -131,13 +129,12 @@ export const groupEntitiesByRoute = createInventoryServerRoute({
});
const { field } = params.path;
const { kuery, entityTypes } = params.query ?? {};
const { esQuery } = params.query ?? {};
const groups = await getEntityGroupsBy({
inventoryEsClient,
field,
kuery,
entityTypes,
esQuery,
});
const entitiesCount = groups.reduce((acc, group) => acc + group.count, 0);

View file

@ -56,6 +56,8 @@
"@kbn/zod",
"@kbn/dashboard-plugin",
"@kbn/deeplinks-analytics",
"@kbn/controls-plugin",
"@kbn/securitysolution-io-ts-types",
"@kbn/react-hooks"
]
}

View file

@ -16,13 +16,13 @@ import { noop } from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react';
import usePrevious from 'react-use/lib/usePrevious';
import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/public';
import { useKibanaQuerySettings } from '@kbn/observability-shared-plugin/public';
import { LogEntryCursor } from '../../../common/log_entry';
import { defaultLogViewsStaticConfig, LogViewReference } from '../../../common/log_views';
import { BuiltEsQuery, useLogStream } from '../../containers/logs/log_stream';
import { useLogView } from '../../hooks/use_log_view';
import { LogViewsClient } from '../../services/log_views';
import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration';
import { useKibanaQuerySettings } from '../../utils/use_kibana_query_settings';
import { useLogEntryFlyout } from '../logging/log_entry_flyout';
import { ScrollableLogTextStreamView, VisibleInterval } from '../logging/log_text_stream';
import { LogStreamErrorBoundary } from './log_stream_error_boundary';

View file

@ -1,31 +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 type { EsQueryConfig } from '@kbn/es-query';
import { SerializableRecord } from '@kbn/utility-types';
import { useMemo } from 'react';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
export const useKibanaQuerySettings = (): EsQueryConfig => {
const [allowLeadingWildcards] = useUiSetting$<boolean>(UI_SETTINGS.QUERY_ALLOW_LEADING_WILDCARDS);
const [queryStringOptions] = useUiSetting$<SerializableRecord>(UI_SETTINGS.QUERY_STRING_OPTIONS);
const [dateFormatTZ] = useUiSetting$<string>(UI_SETTINGS.DATEFORMAT_TZ);
const [ignoreFilterIfFieldNotInIndex] = useUiSetting$<boolean>(
UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX
);
return useMemo(
() => ({
allowLeadingWildcards,
queryStringOptions,
dateFormatTZ,
ignoreFilterIfFieldNotInIndex,
}),
[allowLeadingWildcards, dateFormatTZ, ignoreFilterIfFieldNotInIndex, queryStringOptions]
);
};

View file

@ -12,85 +12,49 @@ import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import type { DataView } from '@kbn/data-views-plugin/public';
import { useMemo } from 'react';
import { useUrlState } from '../../../../hooks/use_url_state';
import { useUrlState } from './use_url_state';
const HOST_FILTERS_URL_STATE_KEY = 'controlPanels';
const CONTROL_PANELS_URL_KEY = 'controlPanels';
export const availableControlsPanels = {
HOST_OS_NAME: 'host.os.name',
CLOUD_PROVIDER: 'cloud.provider',
SERVICE_NAME: 'service.name',
};
const PanelRT = rt.intersection([
rt.type({
order: rt.number,
type: rt.string,
}),
rt.partial({
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]);
const controlPanelConfigs: ControlPanels = {
[availableControlsPanels.HOST_OS_NAME]: {
order: 0,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.HOST_OS_NAME,
title: 'Operating System',
},
[availableControlsPanels.CLOUD_PROVIDER]: {
order: 1,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.CLOUD_PROVIDER,
title: 'Cloud Provider',
},
[availableControlsPanels.SERVICE_NAME]: {
order: 2,
width: 'medium',
grow: false,
type: 'optionsListControl',
fieldName: availableControlsPanels.SERVICE_NAME,
title: 'Service Name',
},
};
const ControlPanelRT = rt.record(rt.string, PanelRT);
export type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;
const availableControlPanelFields = Object.values(availableControlsPanels);
export const useControlPanels = (
const getVisibleControlPanels = (
availableControlPanelFields: string[],
dataView: DataView | undefined
): [ControlPanels, (state: ControlPanels) => void] => {
const defaultState = useMemo(() => getVisibleControlPanelsConfig(dataView), [dataView]);
const [controlPanels, setControlPanels] = useUrlState<ControlPanels>({
defaultState,
decodeUrlState,
encodeUrlState,
urlStateKey: HOST_FILTERS_URL_STATE_KEY,
});
/**
* Configure the control panels as
* 1. Available fields from the data view
* 2. Existing filters from the URL parameter (not colliding with allowed fields from data view)
* 3. Enhanced with dataView.id
*/
const controlsPanelsWithId = dataView
? mergeDefaultPanelsWithUrlConfig(dataView, controlPanels)
: ({} as ControlPanels);
return [controlsPanelsWithId, setControlPanels];
};
/**
* Utils
*/
const getVisibleControlPanels = (dataView: DataView | undefined) => {
) => {
return availableControlPanelFields.filter(
(panelKey) => dataView?.fields.getByName(panelKey) !== undefined
);
};
const getVisibleControlPanelsConfig = (dataView: DataView | undefined) => {
return getVisibleControlPanels(dataView).reduce((panelsMap, panelKey) => {
const config = controlPanelConfigs[panelKey];
const getVisibleControlPanelsConfig = (
controlPanelConfigs: ControlPanels,
dataView: DataView | undefined
) => {
return getVisibleControlPanels(Object.keys(controlPanelConfigs), dataView).reduce(
(panelsMap, panelKey) => {
const config = controlPanelConfigs[panelKey];
return { ...panelsMap, [panelKey]: config };
}, {} as ControlPanels);
return { ...panelsMap, [panelKey]: config };
},
{} as ControlPanels
);
};
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
@ -115,9 +79,13 @@ const cleanControlPanels = (controlPanels: ControlPanels) => {
}, {});
};
const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlPanels = {}) => {
const mergeDefaultPanelsWithUrlConfig = (
dataView: DataView,
urlPanels: ControlPanels = {},
controlPanelConfigs: ControlPanels
) => {
// Get default panel configs from existing fields in data view
const visiblePanels = getVisibleControlPanelsConfig(dataView);
const visiblePanels = getVisibleControlPanelsConfig(controlPanelConfigs, dataView);
// Get list of panel which can be overridden to avoid merging additional config from url
const existingKeys = Object.keys(visiblePanels);
@ -130,25 +98,6 @@ const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlP
);
};
const PanelRT = rt.intersection([
rt.type({
order: rt.number,
type: rt.string,
}),
rt.partial({
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]);
const ControlPanelRT = rt.record(rt.string, PanelRT);
type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;
const encodeUrlState = (value: ControlPanels) => {
if (value) {
// Remove the dataView.id on update to make the control panels portable between data views
@ -163,3 +112,32 @@ const decodeUrlState = (value: unknown) => {
return pipe(ControlPanelRT.decode(value), fold(constant({}), identity));
}
};
export const useControlPanels = (
controlPanelConfigs: ControlPanels,
dataView: DataView | undefined
): [ControlPanels, (state: ControlPanels) => void] => {
const defaultState = useMemo(
() => getVisibleControlPanelsConfig(controlPanelConfigs, dataView),
[controlPanelConfigs, dataView]
);
const [controlPanels, setControlPanels] = useUrlState<ControlPanels>({
defaultState,
decodeUrlState,
encodeUrlState,
urlStateKey: CONTROL_PANELS_URL_KEY,
});
/**
* Configure the control panels as
* 1. Available fields from the data view
* 2. Existing filters from the URL parameter (not colliding with allowed fields from data view)
* 3. Enhanced with dataView.id
*/
const controlsPanelsWithId = dataView
? mergeDefaultPanelsWithUrlConfig(dataView, controlPanels, controlPanelConfigs)
: ({} as ControlPanels);
return [controlsPanelsWithId, setControlPanels];
};

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import { parse } from 'query-string';
import { parse, stringify } from 'query-string';
import { Location } from 'history';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { decode, RisonValue } from '@kbn/rison';
import { decode, encode, RisonValue } from '@kbn/rison';
import { useHistory } from 'react-router-dom';
import { replaceStateKeyInQueryString } from '../../common/url_state_storage_service';
import { url } from '@kbn/kibana-utils-plugin/common';
export const useUrlState = <State>({
defaultState,
@ -94,7 +94,7 @@ export const useUrlState = <State>({
return [state, setState] as [typeof state, typeof setState];
};
const decodeRisonUrlState = (value: string | undefined | null): RisonValue | undefined => {
export const decodeRisonUrlState = (value: string | undefined | null): RisonValue | undefined => {
try {
return value ? decode(value) : undefined;
} catch (error) {
@ -124,3 +124,19 @@ const replaceQueryStringInLocation = (location: Location, queryString: string):
};
}
};
const encodeRisonUrlState = (state: any) => encode(state);
const replaceStateKeyInQueryString =
<UrlState extends any>(stateKey: string, urlState: UrlState | undefined) =>
(queryString: string) => {
const previousQueryValues = parse(queryString, { sort: false });
const newValue =
typeof urlState === 'undefined'
? previousQueryValues
: {
...previousQueryValues,
[stateKey]: encodeRisonUrlState(urlState),
};
return stringify(url.encodeQuery(newValue), { sort: false, encode: false });
};

View file

@ -104,3 +104,7 @@ export { BottomBarActions } from './components/bottom_bar_actions/bottom_bar_act
export { FieldValueSelection, FieldValueSuggestions } from './components';
export { AddDataPanel, type AddDataPanelProps } from './components/add_data_panel';
export { useUrlState } from './hooks/use_url_state';
export { type ControlPanels, useControlPanels } from './hooks/use_control_panels_url_state';
export { useKibanaQuerySettings } from './hooks/use_kibana_query_settings';

View file

@ -45,6 +45,7 @@
"@kbn/rule-data-utils",
"@kbn/es-query",
"@kbn/serverless",
"@kbn/data-views-plugin",
],
"exclude": ["target/**/*", ".storybook/**/*.js"]
}

View file

@ -26269,8 +26269,6 @@
"xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "Horodatage des dernières données reçues pour l'entité (entity.lastSeenTimestamp)",
"xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "Type",
"xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "Type d'entité (entity.type)",
"xpack.inventory.entityTypesControls.euiComboBox.accessibleScreenReaderLabel": "Filtre des types d'entités",
"xpack.inventory.entityTypesControls.euiComboBox.placeHolderLabel": "Types",
"xpack.inventory.featureRegistry.inventoryFeatureName": "Inventory",
"xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "Alertes actives",
"xpack.inventory.inventoryLinkTitle": "Inventory",

View file

@ -26240,8 +26240,6 @@
"xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "エンティティで最後に受信したデータのタイムスタンプentity.lastSeenTimestamp",
"xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "型",
"xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "エンティティのタイプentity.type",
"xpack.inventory.entityTypesControls.euiComboBox.accessibleScreenReaderLabel": "エンティティタイプフィルター",
"xpack.inventory.entityTypesControls.euiComboBox.placeHolderLabel": "タイプ",
"xpack.inventory.featureRegistry.inventoryFeatureName": "インベントリ",
"xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "アクティブアラート",
"xpack.inventory.inventoryLinkTitle": "インベントリ",

View file

@ -25767,8 +25767,6 @@
"xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip": "上次接收的实体数据的时间戳 (entity.lastSeenTimestamp)",
"xpack.inventory.entitiesGrid.euiDataGrid.typeLabel": "类型",
"xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip": "实体的类型 (entity.type)",
"xpack.inventory.entityTypesControls.euiComboBox.accessibleScreenReaderLabel": "实体类型筛选",
"xpack.inventory.entityTypesControls.euiComboBox.placeHolderLabel": "类型",
"xpack.inventory.featureRegistry.inventoryFeatureName": "库存",
"xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation": "活动告警",
"xpack.inventory.inventoryLinkTitle": "库存",