mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
c23eceaa2b
commit
42000ba733
5 changed files with 110 additions and 165 deletions
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -21,7 +21,7 @@ export interface HostsViewQuerySubmittedParams {
|
|||
control_filters: string[];
|
||||
filters: string[];
|
||||
interval: string;
|
||||
query: string;
|
||||
query: string | { [key: string]: any };
|
||||
}
|
||||
|
||||
export interface HostEntryClickedParams {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue