Skip to content

Commit

Permalink
[Infrastructure UI] Host filtering controls (elastic#145935)
Browse files Browse the repository at this point in the history
Closes [elastic#140445](elastic#140445)

## Summary
This PR adds 2 filters (Operating System and Cloud Provider) using
[Kibana Controls
API](https://github.com/elastic/kibana/tree/main/src/plugins/controls)
to the Host view.

## Testing
- Open Host View
- The Operating System and Cloud Provider filters should be visible
under the search bar. Supported values:
   - Filter Include/exclude OS name / Cloud Provider name
   - Exist / Does not exist
   - Any (also when clearing the filters) 
- The control filters should update the possible values when the other
control filter or unified search query/filters are changes
- When the control group filters are updated the table is loading the
filtered result.
- Combination with unified search query/filters should be possible.

![image](https://user-images.githubusercontent.com/14139027/203373557-f9220f22-53ee-4fe0-9bdd-cdc08ce31156.png)
- Copy the url after adding the filters and paste it into a separate tab
   - The control group AND the other filters/query should be prefilled

## 🎉  UPDATE the control panels are prefilled from the URL 
### The Workaround:
Together with @ThomThomson we found a way to prefill the control group
selections from the URL state by adding the panels' objects to the URL
state (using a separate key to avoid the infinite loop issue) and
keeping the output filters (used for updating the table) separately.

## Discovered issues with persisting the new filters to the URL state
~~⚠️ This PR does not support persisting those filters in the URL state.
The reason behind this is that if we persist those filters inside the
other filters parameter it will create an infinite loop (as those
controls are relying on the filters to adjust the possible values).~~
In order to avoid that we can persist them in a different parameter
(instead of adding them to the existing `_a` we can add a new one for
example named `controlFilters`. This will work with filtering the table
results.
BUT If we go with the solution to persist them in another `urlStateKey`
we also need to prefill those selections from the url state to the
control filters (Operating System and Cloud Provide).
Currently, the controls API supports setting `selectedOptions` as a
string array.
### Workaraound Ideas
Option 1: I tried first on a[ separate branch
](elastic/kibana@main...jennypavlova:kibana:140445-host-filtering-controls-with-url-state)
- Persist the filters as an array of filter options. 
- on load prefill the control filters 
- extract the string values from the filters and set them as
`selectedOptions` inside the control group input `panel` (based on the
field name for example)

Option 2 (Suggestion from Devon ) 
- on load pass in the selections from the URL to the control group input
- Don't render the table right away
- Wait until control group is ready .then
   - Get the filters from the control group output
- Set filters from controls in the use state by doing
controls.getOutput().filters
- Render the table with ...unifiedSearchFilters, ...filtersFromControls

❌  The issue with both 1 & 2
- With `selectedOptions` we can prefill only **strings** so `Exist` and
`Negate` won't be supported
  • Loading branch information
jennypavlova authored Nov 30, 2022
1 parent 8382355 commit a44304e
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 22 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/infra/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"kibanaUtils",
"kibanaReact",
"ml",
"embeddable"
"embeddable",
"controls"
],
"owner": {
"name": "Logs and Metrics UI",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { useEffect, useState } from 'react';
import { ControlGroupContainer, CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { Filter, TimeRange, compareFilters } from '@kbn/es-query';
import { isEqual } from 'lodash';
import { LazyControlsRenderer } from './lazy_controls_renderer';
import { useControlPanels } from '../hooks/use_control_panels_url_state';

interface Props {
timeRange: TimeRange;
dataViewId: string;
filters: Filter[];
query: {
language: string;
query: string;
};
setPanelFilters: React.Dispatch<React.SetStateAction<null | Filter[]>>;
}

// Disable refresh, allow our timerange changes to refresh the embeddable.
const REFRESH_CONFIG = {
pause: true,
value: 0,
};

export const ControlsContent: React.FC<Props> = ({
timeRange,
dataViewId,
query,
filters,
setPanelFilters,
}) => {
const [controlPanel, setControlPanels] = useControlPanels(dataViewId);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer | undefined>();

useEffect(() => {
if (!controlGroup) {
return;
}
if (
!isEqual(controlGroup.getInput().timeRange, timeRange) ||
!compareFilters(controlGroup.getInput().filters ?? [], filters) ||
!isEqual(controlGroup.getInput().query, query)
) {
controlGroup.updateInput({
timeRange,
query,
filters,
});
}
}, [query, filters, controlGroup, timeRange]);

return (
<LazyControlsRenderer
getCreationOptions={async ({ addDataControlFromField }) => ({
id: dataViewId,
type: CONTROL_GROUP_TYPE,
timeRange,
refreshConfig: REFRESH_CONFIG,
viewMode: ViewMode.VIEW,
filters: [...filters],
query,
chainingSystem: 'HIERARCHICAL',
controlStyle: 'oneLine',
defaultControlWidth: 'small',
panels: controlPanel,
})}
onEmbeddableLoad={(newControlGroup) => {
setControlGroup(newControlGroup);
newControlGroup.onFiltersPublished$.subscribe((newFilters) => {
setPanelFilters([...newFilters]);
});
newControlGroup.getInput$().subscribe(({ panels, filters: currentFilters }) => {
setControlPanels(panels);
if (currentFilters?.length === 0) {
setPanelFilters([]);
}
});
}}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const HOST_METRICS: Array<{ type: SnapshotMetricType }> = [

export const HostsTable = () => {
const { sourceId } = useSourceContext();
const { buildQuery, dateRangeTimestamp } = useUnifiedSearchContext();
const { buildQuery, dateRangeTimestamp, panelFilters } = useUnifiedSearchContext();

const timeRange: InfraTimerangeInput = {
from: dateRangeTimestamp.from,
Expand Down Expand Up @@ -61,7 +61,7 @@ export const HostsTable = () => {

return (
<>
{loading ? (
{loading || !panelFilters ? (
<InfraLoadingPanel
height="100%"
width="auto"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 { LazyControlGroupRenderer } from '@kbn/controls-plugin/public';
import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui';
import React from 'react';

export const LazyControlsRenderer = (
props: React.ComponentProps<typeof LazyControlGroupRenderer>
) => (
<EuiErrorBoundary>
<React.Suspense fallback={<EuiLoadingSpinner />}>
<LazyControlGroupRenderer {...props} />
</React.Suspense>
</EuiErrorBoundary>
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import type { SavedQuery } from '@kbn/data-plugin/public';
import type { InfraClientStartDeps } from '../../../../types';
import { useUnifiedSearchContext } from '../hooks/use_unified_search';
import { ControlsContent } from './controls_content';

interface Props {
dataView: DataView;
Expand All @@ -28,6 +29,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
onSubmit,
saveQuery,
clearSavedQuery,
setPanelFilters,
} = useUnifiedSearchContext();

const { SearchBar } = unifiedSearch.ui;
Expand Down Expand Up @@ -59,20 +61,29 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
};

return (
<SearchBar
appName={'Infra Hosts'}
indexPatterns={[dataView]}
query={unifiedSearchQuery}
dateRangeFrom={unifiedSearchDateRange.from}
dateRangeTo={unifiedSearchDateRange.to}
filters={unifiedSearchFilters}
onQuerySubmit={onQuerySubmit}
onSaved={onQuerySave}
onSavedQueryUpdated={onQuerySave}
onClearSavedQuery={onClearSavedQuery}
showSaveQuery
showQueryInput
onFiltersUpdated={onFilterChange}
/>
<>
<SearchBar
appName={'Infra Hosts'}
indexPatterns={[dataView]}
query={unifiedSearchQuery}
dateRangeFrom={unifiedSearchDateRange.from}
dateRangeTo={unifiedSearchDateRange.to}
filters={unifiedSearchFilters}
onQuerySubmit={onQuerySubmit}
onSaved={onQuerySave}
onSavedQueryUpdated={onQuerySave}
onClearSavedQuery={onClearSavedQuery}
showSaveQuery
showQueryInput
onFiltersUpdated={onFilterChange}
/>
<ControlsContent
timeRange={unifiedSearchDateRange}
dataViewId={dataView.id ?? ''}
query={unifiedSearchQuery}
filters={unifiedSearchFilters}
setPanelFilters={setPanelFilters}
/>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 * 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 { ControlGroupInput } from '@kbn/controls-plugin/common';
import { useUrlState } from '../../../../utils/use_url_state';

export const getDefaultPanels = (dataViewId: string): ControlGroupInput['panels'] =>
({
osPanel: {
order: 0,
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: 'osPanel',
dataViewId,
fieldName: 'host.os.name',
title: 'Operating System',
},
},
cloudProviderPanel: {
order: 1,
width: 'medium',
grow: false,
type: 'optionsListControl',
explicitInput: {
id: 'cloudProviderPanel',
dataViewId,
fieldName: 'cloud.provider',
title: 'Cloud Provider',
},
},
} as unknown as ControlGroupInput['panels']);
const HOST_FILTERS_URL_STATE_KEY = 'controlPanels';

export const useControlPanels = (dataViewId: string) => {
return useUrlState<ControlPanels>({
defaultState: getDefaultPanels(dataViewId),
decodeUrlState,
encodeUrlState,
urlStateKey: HOST_FILTERS_URL_STATE_KEY,
});
};

const PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
}),
]),
});

const ControlPanelRT = rt.record(rt.string, PanelRT);

type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;
const encodeUrlState = ControlPanelRT.encode;
const decodeUrlState = (value: unknown) => {
if (value) {
return pipe(ControlPanelRT.decode(value), fold(constant({}), identity));
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { useKibana } from '@kbn/kibana-react-plugin/public';
import createContainer from 'constate';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { SavedQuery } from '@kbn/data-plugin/public';
import { debounce } from 'lodash';
Expand All @@ -16,6 +16,8 @@ import { useSyncKibanaTimeFilterTime } from '../../../../hooks/use_kibana_timefi
import { useHostsUrlState, INITIAL_DATE_RANGE } from './use_hosts_url_state';

export const useUnifiedSearch = () => {
const [panelFilters, setPanelFilters] = useState<Filter[] | null>(null);

const { state, dispatch, getRangeInTimestamp, getTime } = useHostsUrlState();
const { metricsDataView } = useMetricsDataViewContext();
const { services } = useKibana<InfraClientStartDeps>();
Expand Down Expand Up @@ -49,7 +51,7 @@ export const useUnifiedSearch = () => {
});
}
},
[filterManager, getRangeInTimestamp, getTime, dispatch]
[getTime, dispatch, filterManager, getRangeInTimestamp]
);

// This won't prevent onSubmit from being fired twice when `clear filters` is clicked,
Expand Down Expand Up @@ -87,8 +89,11 @@ export const useUnifiedSearch = () => {
if (!metricsDataView) {
return null;
}
return buildEsQuery(metricsDataView, state.query, state.filters);
}, [metricsDataView, state.filters, state.query]);
if (Array.isArray(panelFilters) && panelFilters.length > 0) {
return buildEsQuery(metricsDataView, state.query, [...state.filters, ...panelFilters]);
}
return buildEsQuery(metricsDataView, state.query, [...state.filters]);
}, [metricsDataView, panelFilters, state.filters, state.query]);

return {
dateRangeTimestamp: state.dateRangeTimestamp,
Expand All @@ -99,6 +104,8 @@ export const useUnifiedSearch = () => {
unifiedSearchQuery: state.query,
unifiedSearchDateRange: getTime(),
unifiedSearchFilters: state.filters,
setPanelFilters,
panelFilters,
};
};

Expand Down

0 comments on commit a44304e

Please sign in to comment.