[Infrastructure UI] Update host filter management to sync with AlertSummary (#154373)

## 📓 Summary

Closes #153360 

To correctly sync the brush selection with the time service there was
the need for a refactor on how we handle the host filters, simplifying a
bit the code implementation and re-using existing abstractions.

## 🧪 Testing

- Navigate to Hosts View
- Create an Inventory Alert that can easily trigger
- Reload the search until the alerts are triggered
- Click on the alerts tab to preview them
- Select a time range on the chart and see the changes reflected on the
chart and the time range globally applied.


https://user-images.githubusercontent.com/34506779/230015011-1b48deba-7c05-47df-80b1-37e8ee046b05.mov

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Antonio Ghiani 2023-04-11 09:22:47 +02:00 committed by GitHub
parent c23eceaa2b
commit 42000ba733
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 110 additions and 165 deletions

View file

@ -6,7 +6,6 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
calculateTimeRangeBucketSize,
getAlertSummaryTimeRange,
@ -14,8 +13,9 @@ import {
} from '@kbn/observability-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { TimeRange } from '@kbn/es-query';
import { BrushEndListener, XYBrushEvent } from '@elastic/charts';
import { useKibanaContextForPlugin } from '../../../../../../hooks/use_kibana';
import { HeightRetainer } from '../../../../../../components/height_retainer';
import type { InfraClientCoreStart, InfraClientStartDeps } from '../../../../../../types';
import { useUnifiedSearchContext } from '../../../hooks/use_unified_search';
import {
@ -27,14 +27,14 @@ import {
} from '../config';
import { AlertsEsQuery, useAlertsQuery } from '../../../hooks/use_alerts_query';
import AlertsStatusFilter from './alerts_status_filter';
import { HostsState } from '../../../hooks/use_unified_search_url_state';
import { HostsState, HostsStateUpdater } from '../../../hooks/use_unified_search_url_state';
export const AlertsTabContent = () => {
const { services } = useKibana<InfraClientCoreStart & InfraClientStartDeps>();
const { services } = useKibanaContextForPlugin();
const { alertStatus, setAlertStatus, alertsEsQueryByStatus } = useAlertsQuery();
const { searchCriteria } = useUnifiedSearchContext();
const { onSubmit, searchCriteria } = useUnifiedSearchContext();
const { triggersActionsUi } = services;
@ -53,6 +53,7 @@ export const AlertsTabContent = () => {
<MemoAlertSummaryWidget
alertsQuery={alertsEsQueryByStatus}
dateRange={searchCriteria.dateRange}
onRangeSelection={onSubmit}
/>
</EuiFlexItem>
{alertsEsQueryByStatus && (
@ -78,20 +79,34 @@ export const AlertsTabContent = () => {
interface MemoAlertSummaryWidgetProps {
alertsQuery: AlertsEsQuery;
dateRange: HostsState['dateRange'];
onRangeSelection: HostsStateUpdater;
}
const MemoAlertSummaryWidget = React.memo(
({ alertsQuery, dateRange }: MemoAlertSummaryWidgetProps) => {
const { services } = useKibana<InfraClientStartDeps>();
({ alertsQuery, dateRange, onRangeSelection }: MemoAlertSummaryWidgetProps) => {
const { services } = useKibanaContextForPlugin();
const summaryTimeRange = useSummaryTimeRange(dateRange);
const { charts, triggersActionsUi } = services;
const { getAlertSummaryWidget: AlertSummaryWidget } = triggersActionsUi;
const onBrushEnd: BrushEndListener = (brushEvent) => {
const { x } = brushEvent as XYBrushEvent;
if (x) {
const [start, end] = x;
const from = new Date(start).toISOString();
const to = new Date(end).toISOString();
onRangeSelection({ dateRange: { from, to } });
}
};
const chartProps = {
theme: charts.theme.useChartsTheme(),
baseTheme: charts.theme.useChartsBaseTheme(),
onBrushEnd,
};
return (

View file

@ -6,27 +6,21 @@
*/
import React from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
compareFilters,
COMPARE_ALL_OPTIONS,
type Filter,
type Query,
type TimeRange,
} from '@kbn/es-query';
import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { EuiFlexGrid, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiHorizontalRule } from '@elastic/eui';
import type { InfraClientStartDeps } from '../../../../types';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { useUnifiedSearchContext } from '../hooks/use_unified_search';
import { ControlsContent } from './controls_content';
import { useMetricsDataViewContext } from '../hooks/use_data_view';
import { HostsSearchPayload } from '../hooks/use_unified_search_url_state';
export const UnifiedSearchBar = () => {
const {
services: { unifiedSearch, application },
} = useKibana<InfraClientStartDeps>();
} = useKibanaContextForPlugin();
const { dataView } = useMetricsDataViewContext();
const { searchCriteria, onSubmit } = useUnifiedSearchContext();
@ -34,24 +28,14 @@ export const UnifiedSearchBar = () => {
const onPanelFiltersChange = (panelFilters: Filter[]) => {
if (!compareFilters(searchCriteria.panelFilters, panelFilters, COMPARE_ALL_OPTIONS)) {
onQueryChange({ panelFilters });
onSubmit({ panelFilters });
}
};
const onQueryChange = ({
payload,
panelFilters,
}: {
payload?: { dateRange: TimeRange; query?: Query };
panelFilters?: Filter[];
}) => {
onSubmit({ query: payload?.query, dateRange: payload?.dateRange, panelFilters });
};
const handleRefresh = (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => {
const handleRefresh = (payload: HostsSearchPayload, isUpdate?: boolean) => {
// This makes sure `onQueryChange` is only called when the submit button is clicked
if (isUpdate === false) {
onQueryChange({ payload });
onSubmit(payload);
}
};

View file

@ -4,18 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useKibana } from '@kbn/kibana-react-plugin/public';
import createContainer from 'constate';
import { useCallback, useEffect } from 'react';
import { buildEsQuery, type Filter, type Query, type TimeRange } from '@kbn/es-query';
import DateMath from '@kbn/datemath';
import { buildEsQuery, type Query } from '@kbn/es-query';
import { map, skip, startWith } from 'rxjs/operators';
import { combineLatest } from 'rxjs';
import deepEqual from 'fast-deep-equal';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana';
import { telemetryTimeRangeFormatter } from '../../../../../common/formatters/telemetry_time_range';
import type { InfraClientStartDeps } from '../../../../types';
import { useMetricsDataViewContext } from './use_data_view';
import {
HostsSearchPayload,
useHostsUrlState,
type HostsState,
type StringDateRangeTimestamp,
@ -34,72 +35,60 @@ const buildQuerySubmittedPayload = (
};
};
const DEFAULT_FROM_IN_MILLISECONDS = 15 * 60000;
const getDefaultTimestamps = () => {
const now = Date.now();
return {
from: now - DEFAULT_FROM_IN_MILLISECONDS,
to: now,
};
};
export const useUnifiedSearch = () => {
const { state, dispatch, getTime, getDateRangeAsTimestamp } = useHostsUrlState();
const [searchCriteria, setSearch] = useHostsUrlState();
const { dataView } = useMetricsDataViewContext();
const { services } = useKibana<InfraClientStartDeps>();
const { services } = useKibanaContextForPlugin();
const {
data: {
query: {
filterManager: filterManagerService,
timefilter: timeFilterService,
queryString: queryStringService,
timefilter: timeFilterService,
},
},
telemetry,
} = services;
const onSubmit = useCallback(
(data?: {
query?: Query;
dateRange?: TimeRange;
filters?: Filter[];
panelFilters?: Filter[];
}) => {
const {
panelFilters,
query,
// Makes sure default values are set in case `onSubmit` is called outside the unified search observables subscription
// and prevents their state values from being cleared.
dateRange = getTime(),
filters = filterManagerService.getFilters(),
} = data ?? {};
const onSubmit = (params?: HostsSearchPayload) => setSearch(params ?? {});
dispatch({
type: 'setQuery',
payload: {
query,
filters,
dateRange,
panelFilters,
},
});
},
[dispatch, filterManagerService, getTime]
);
const getDateRangeAsTimestamp = useCallback(() => {
const defaults = getDefaultTimestamps();
const loadFiltersFromState = useCallback(() => {
if (!deepEqual(filterManagerService.getFilters(), state.filters)) {
filterManagerService.setFilters(state.filters);
}
}, [filterManagerService, state.filters]);
const from = DateMath.parse(searchCriteria.dateRange.from)?.valueOf() ?? defaults.from;
const to =
DateMath.parse(searchCriteria.dateRange.to, { roundUp: true })?.valueOf() ?? defaults.to;
const loadQueryFromState = useCallback(() => {
if (!deepEqual(queryStringService.getQuery(), state.query)) {
queryStringService.setQuery(state.query);
}
}, [queryStringService, state.query]);
return { from, to };
}, [searchCriteria.dateRange]);
const loadDateRangeFromState = useCallback(() => {
if (!deepEqual(timeFilterService.timefilter.getTime(), state.dateRange)) {
timeFilterService.timefilter.setTime(state.dateRange);
}
}, [timeFilterService, state.dateRange]);
const buildQuery = useCallback(() => {
return buildEsQuery(dataView, searchCriteria.query, [
...searchCriteria.filters,
...searchCriteria.panelFilters,
]);
}, [dataView, searchCriteria.query, searchCriteria.filters, searchCriteria.panelFilters]);
useEffectOnce(() => {
loadFiltersFromState();
loadQueryFromState();
loadDateRangeFromState();
// Sync filtersService from state
if (!deepEqual(filterManagerService.getFilters(), searchCriteria.filters)) {
filterManagerService.setFilters(searchCriteria.filters);
}
// Sync queryService from state
if (!deepEqual(queryStringService.getQuery(), searchCriteria.query)) {
queryStringService.setQuery(searchCriteria.query);
}
});
useEffect(() => {
@ -113,51 +102,31 @@ export const useUnifiedSearch = () => {
map(() => queryStringService.getQuery() as Query)
);
const dateRange$ = timeFilterService.timefilter.getTimeUpdate$().pipe(
startWith(undefined),
map(() => getTime())
);
const subscription = combineLatest({
filters: filters$,
query: query$,
dateRange: dateRange$,
})
.pipe(skip(1))
.subscribe(({ filters, query, dateRange }) => {
onSubmit({
query,
filters,
dateRange,
});
});
.subscribe(setSearch);
return () => {
subscription.unsubscribe();
};
}, [filterManagerService, getTime, onSubmit, queryStringService, timeFilterService.timefilter]);
}, [filterManagerService, setSearch, queryStringService, timeFilterService.timefilter]);
// Track telemetry event on query/filter/date changes
useEffect(() => {
const dateRangeTimestamp = getDateRangeAsTimestamp();
telemetry.reportHostsViewQuerySubmitted(
buildQuerySubmittedPayload({ ...state, dateRangeTimestamp })
buildQuerySubmittedPayload({ ...searchCriteria, dateRangeTimestamp })
);
}, [getDateRangeAsTimestamp, state, telemetry]);
const getAllFilters = useCallback(
() => [...state.filters, ...state.panelFilters],
[state.filters, state.panelFilters]
);
const buildQuery = useCallback(() => {
return buildEsQuery(dataView, state.query, getAllFilters());
}, [dataView, state.query, getAllFilters]);
}, [getDateRangeAsTimestamp, searchCriteria, telemetry]);
return {
buildQuery,
onSubmit,
getDateRangeAsTimestamp,
searchCriteria: { ...state },
searchCriteria,
};
};

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import { useCallback, useEffect, useReducer } from 'react';
import DateMath from '@kbn/datemath';
import { useReducer } from 'react';
import deepEqual from 'fast-deep-equal';
import * as rt from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
@ -15,53 +14,38 @@ import { constant, identity } from 'fp-ts/lib/function';
import { enumeration } from '@kbn/securitysolution-io-ts-types';
import { FilterStateStore } from '@kbn/es-query';
import { useUrlState } from '../../../../utils/use_url_state';
import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time';
import {
useKibanaTimefilterTime,
useSyncKibanaTimeFilterTime,
} from '../../../../hooks/use_kibana_timefilter_time';
const DEFAULT_QUERY = {
language: 'kuery',
query: '',
};
const DEFAULT_FROM_MINUTES_VALUE = 15;
const DEFAULT_FROM_IN_MILLISECONDS = DEFAULT_FROM_MINUTES_VALUE * 60000;
export const INITIAL_DATE_RANGE = { from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, to: 'now' };
const getDefaultFromTimestamp = () => Date.now() - DEFAULT_FROM_IN_MILLISECONDS;
const getDefaultToTimestamp = () => Date.now();
const INITIAL_DATE_RANGE = { from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, to: 'now' };
const INITIAL_HOSTS_STATE: HostsState = {
query: DEFAULT_QUERY,
filters: [],
panelFilters: [],
// for unified search
dateRange: INITIAL_DATE_RANGE,
};
type Action =
| {
type: 'setQuery';
payload: rt.TypeOf<typeof SetQueryType>;
}
| { type: 'setFilter'; payload: rt.TypeOf<typeof HostsFiltersRT> };
const reducer = (prevState: HostsState, params: HostsSearchPayload) => {
const payload = Object.fromEntries(Object.entries(params).filter(([_, v]) => !!v));
const reducer = (state: HostsState, action: Action): HostsState => {
switch (action.type) {
case 'setFilter':
return { ...state, filters: [...action.payload] };
case 'setQuery':
const payload = Object.fromEntries(Object.entries(action.payload).filter(([_, v]) => !!v));
return {
...state,
...payload,
};
default:
throw new Error();
}
return {
...prevState,
...payload,
};
};
export const useHostsUrlState = () => {
export const useHostsUrlState = (): [HostsState, HostsStateUpdater] => {
const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);
const [urlState, setUrlState] = useUrlState<HostsState>({
defaultState: { ...INITIAL_HOSTS_STATE, dateRange: getTime() },
decodeUrlState,
@ -70,28 +54,16 @@ export const useHostsUrlState = () => {
writeDefaultState: true,
});
const [state, dispatch] = useReducer(reducer, urlState);
const [search, setSearch] = useReducer(reducer, urlState);
if (!deepEqual(search, urlState)) {
setUrlState(search);
}
const getDateRangeAsTimestamp = useCallback(() => {
const from = DateMath.parse(state.dateRange.from)?.valueOf() ?? getDefaultFromTimestamp();
const to =
DateMath.parse(state.dateRange.to, { roundUp: true })?.valueOf() ?? getDefaultToTimestamp();
useSyncKibanaTimeFilterTime(INITIAL_DATE_RANGE, urlState.dateRange, (dateRange) =>
setSearch({ dateRange })
);
return { from, to };
}, [state.dateRange]);
useEffect(() => {
if (!deepEqual(state, urlState)) {
setUrlState(state);
}
}, [setUrlState, state, urlState]);
return {
dispatch,
getDateRangeAsTimestamp,
getTime,
state,
};
return [search, setSearch];
};
const HostsFilterRT = rt.intersection([
@ -122,13 +94,16 @@ const HostsFiltersRT = rt.array(HostsFilterRT);
const HostsQueryStateRT = rt.type({
language: rt.string,
query: rt.any,
query: rt.union([rt.string, rt.record(rt.string, rt.any)]),
});
const StringDateRangeRT = rt.type({
from: rt.string,
to: rt.string,
});
const StringDateRangeRT = rt.intersection([
rt.type({
from: rt.string,
to: rt.string,
}),
rt.partial({ mode: rt.union([rt.literal('absolute'), rt.literal('relative')]) }),
]);
const HostsStateRT = rt.type({
filters: HostsFiltersRT,
@ -139,13 +114,15 @@ const HostsStateRT = rt.type({
export type HostsState = rt.TypeOf<typeof HostsStateRT>;
export type HostsSearchPayload = Partial<HostsState>;
export type HostsStateUpdater = (params: HostsSearchPayload) => void;
export interface StringDateRangeTimestamp {
from: number;
to: number;
}
const SetQueryType = rt.partial(HostsStateRT.props);
const encodeUrlState = HostsStateRT.encode;
const decodeUrlState = (value: unknown) => {
return pipe(HostsStateRT.decode(value), fold(constant(undefined), identity));

View file

@ -21,7 +21,7 @@ export interface HostsViewQuerySubmittedParams {
control_filters: string[];
filters: string[];
interval: string;
query: string;
query: string | { [key: string]: any };
}
export interface HostEntryClickedParams {