mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
d6b8f9b619
commit
4a16e910e9
62 changed files with 784 additions and 758 deletions
|
@ -119,7 +119,7 @@ pageLoadAssetSize:
|
|||
observabilityAiAssistantManagement: 19279
|
||||
observabilityLogsExplorer: 46650
|
||||
observabilityOnboarding: 19573
|
||||
observabilityShared: 80000
|
||||
observabilityShared: 111036
|
||||
osquery: 107090
|
||||
painlessLab: 179748
|
||||
presentationPanel: 55463
|
||||
|
|
|
@ -54,7 +54,9 @@ export function useAbortableAsync<T>(
|
|||
})
|
||||
.catch((err) => {
|
||||
setValue(undefined);
|
||||
setError(err);
|
||||
if (!controller.signal.aborted) {
|
||||
setError(err);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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(
|
||||
|
|
|
@ -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]: (
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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> & {
|
||||
|
|
|
@ -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('');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"ruleRegistry",
|
||||
"share"
|
||||
],
|
||||
"requiredBundles": ["kibanaReact"],
|
||||
"requiredBundles": ["kibanaReact","controls"],
|
||||
"optionalPlugins": ["spaces", "cloud"],
|
||||
"extraPublicDirs": []
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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 };
|
||||
}
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -56,6 +56,8 @@
|
|||
"@kbn/zod",
|
||||
"@kbn/dashboard-plugin",
|
||||
"@kbn/deeplinks-analytics",
|
||||
"@kbn/controls-plugin",
|
||||
"@kbn/securitysolution-io-ts-types",
|
||||
"@kbn/react-hooks"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
|
@ -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];
|
||||
};
|
|
@ -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 });
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
"@kbn/rule-data-utils",
|
||||
"@kbn/es-query",
|
||||
"@kbn/serverless",
|
||||
"@kbn/data-views-plugin",
|
||||
],
|
||||
"exclude": ["target/**/*", ".storybook/**/*.js"]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "インベントリ",
|
||||
|
|
|
@ -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": "库存",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue