Skip to content

Commit

Permalink
[Infrastructure UI] Add URL state to Hosts View (#144181)
Browse files Browse the repository at this point in the history
Closes  [#141492](#141492)
## Summary
This PR adds `query`, `timeRange` and `filters` parameters to the URL
state on the hosts view. URL parameters are updated after search filters
are applied (after click on the "update" button.

## Testing
Different cases:
- Add new search criteria ( filter / time range / query ) and click on
"update" - the URL should update
- Save a query and reload
- Load a saved query 
- Change an existing query


![image](https://user-images.githubusercontent.com/14139027/199047590-29e375fb-6909-424b-89c4-ef9193a77b10.png)


![image](https://user-images.githubusercontent.com/14139027/199046342-29fbfa76-0314-462b-b593-2c535112be09.png)


![image](https://user-images.githubusercontent.com/14139027/199046201-76ace0fa-8d17-4e1f-b36f-54a2419fb6af.png)

- Open the URL in a new browser tab/window - the filters should be added

Co-authored-by: Carlos Crespo <carloshenrique.leonelcrespo@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nathan L Smith <nathan.smith@elastic.co>
  • Loading branch information
4 people authored Nov 14, 2022
1 parent bebcd35 commit 20e2fb5
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 60 deletions.
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 { esQuery, dateRangeTimestamp } = useUnifiedSearchContext();
const { buildQuery, dateRangeTimestamp } = useUnifiedSearchContext();

const timeRange: InfraTimerangeInput = {
from: dateRangeTimestamp.from,
Expand All @@ -38,6 +38,8 @@ export const HostsTable = () => {
ignoreLookback: true,
};

const esQuery = buildQuery();

// Snapshot endpoint internally uses the indices stored in source.configuration.metricAlias.
// For the Unified Search, we create a data view, which for now will be built off of source.configuration.metricAlias too
// if we introduce data view selection, we'll have to change this hook and the endpoint to accept a new parameter for the indices
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
const {
unifiedSearchDateRange,
unifiedSearchQuery,
submitFilterChange,
unifiedSearchFilters,
onSubmit,
saveQuery,
clearSavedQUery,
clearSavedQuery,
} = useUnifiedSearchContext();

const { SearchBar } = unifiedSearch.ui;
Expand All @@ -40,7 +41,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
};

const onClearSavedQuery = () => {
clearSavedQUery();
clearSavedQuery();
};

const onQuerySave = (savedQuery: SavedQuery) => {
Expand All @@ -54,7 +55,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
payload?: { dateRange: TimeRange; query?: Query };
filters?: Filter[];
}) => {
submitFilterChange(payload?.query, payload?.dateRange, filters);
onSubmit(payload?.query, payload?.dateRange, filters);
};

return (
Expand All @@ -64,6 +65,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => {
query={unifiedSearchQuery}
dateRangeFrom={unifiedSearchDateRange.from}
dateRangeTo={unifiedSearchDateRange.to}
filters={unifiedSearchFilters}
onQuerySubmit={onQuerySubmit}
onSaved={onQuerySave}
onSavedQueryUpdated={onQuerySave}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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 { useCallback, useEffect, useReducer } from 'react';
import { TimeRange } from '@kbn/es-query';
import DateMath from '@kbn/datemath';
import deepEqual from 'fast-deep-equal';
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 { 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';

const DEFAULT_QUERY = {
language: 'kuery',
query: '',
};
const DEFAULT_FROM_MINUTES_VALUE = 15;
const INITIAL_DATE = new Date();
export const INITIAL_DATE_RANGE = { from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, to: 'now' };
const CALCULATED_DATE_RANGE_FROM = new Date(
INITIAL_DATE.getMinutes() - DEFAULT_FROM_MINUTES_VALUE
).getTime();
const CALCULATED_DATE_RANGE_TO = INITIAL_DATE.getTime();

const INITIAL_HOSTS_STATE: HostsState = {
query: DEFAULT_QUERY,
filters: [],
// for unified search
dateRange: { ...INITIAL_DATE_RANGE },
// for useSnapshot
dateRangeTimestamp: {
from: CALCULATED_DATE_RANGE_FROM,
to: CALCULATED_DATE_RANGE_TO,
},
};

type Action =
| {
type: 'setQuery';
payload: rt.TypeOf<typeof SetQueryType>;
}
| { type: 'setFilter'; payload: rt.TypeOf<typeof HostsFiltersRT> };

const reducer = (state: HostsState, action: Action): HostsState => {
switch (action.type) {
case 'setFilter':
return { ...state, filters: [...action.payload] };
case 'setQuery':
const { filters, query, ...payload } = action.payload;
const newFilters = !filters ? state.filters : filters;
const newQuery = !query ? state.query : query;
return {
...state,
...payload,
filters: [...newFilters],
query: { ...newQuery },
};
default:
throw new Error();
}
};

export const useHostsUrlState = () => {
const [urlState, setUrlState] = useUrlState<HostsState>({
defaultState: INITIAL_HOSTS_STATE,
decodeUrlState,
encodeUrlState,
urlStateKey: '_a',
});

const [state, dispatch] = useReducer(reducer, urlState);

const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE);

const getRangeInTimestamp = useCallback(({ from, to }: TimeRange) => {
const fromTS = DateMath.parse(from)?.valueOf() ?? CALCULATED_DATE_RANGE_FROM;
const toTS = DateMath.parse(to)?.valueOf() ?? CALCULATED_DATE_RANGE_TO;

return {
from: fromTS,
to: toTS,
};
}, []);

useEffect(() => {
if (!deepEqual(state, urlState)) {
setUrlState(state);
}
}, [setUrlState, state, urlState]);

return {
state,
dispatch,
getRangeInTimestamp,
getTime,
};
};

const HostsFilterRT = rt.intersection([
rt.partial({
$state: rt.type({
store: enumeration('FilterStateStore', FilterStateStore),
}),
}),
rt.type({
meta: rt.partial({
alias: rt.union([rt.null, rt.string]),
disabled: rt.boolean,
negate: rt.boolean,
controlledBy: rt.string,
group: rt.string,
index: rt.string,
isMultiIndex: rt.boolean,
type: rt.string,
key: rt.string,
params: rt.any,
value: rt.any,
}),
}),
rt.partial({
query: rt.record(rt.string, rt.any),
}),
]);

const HostsFiltersRT = rt.array(HostsFilterRT);

export const HostsQueryStateRT = rt.type({
language: rt.string,
query: rt.any,
});

export const StringDateRangeRT = rt.type({
from: rt.string,
to: rt.string,
});

export const DateRangeRT = rt.type({
from: rt.number,
to: rt.number,
});

export const HostsStateRT = rt.type({
filters: HostsFiltersRT,
query: HostsQueryStateRT,
dateRange: StringDateRangeRT,
dateRangeTimestamp: DateRangeRT,
});

export type HostsState = rt.TypeOf<typeof HostsStateRT>;

const SetQueryType = rt.partial({
query: HostsQueryStateRT,
dateRange: StringDateRangeRT,
filters: HostsFiltersRT,
dateRangeTimestamp: DateRangeRT,
});

const encodeUrlState = HostsStateRT.encode;
const decodeUrlState = (value: unknown) => {
return pipe(HostsStateRT.decode(value), fold(constant(undefined), identity));
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,98 +6,99 @@
*/
import { useKibana } from '@kbn/kibana-react-plugin/public';
import createContainer from 'constate';
import { useCallback, useReducer } from 'react';
import { useCallback } from 'react';
import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import DateMath from '@kbn/datemath';
import type { SavedQuery } from '@kbn/data-plugin/public';
import { debounce } from 'lodash';
import type { InfraClientStartDeps } from '../../../../types';
import { useMetricsDataViewContext } from './use_data_view';
import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time';

const DEFAULT_FROM_MINUTES_VALUE = 15;
import { useSyncKibanaTimeFilterTime } from '../../../../hooks/use_kibana_timefilter_time';
import { useHostsUrlState, INITIAL_DATE_RANGE } from './use_hosts_url_state';

export const useUnifiedSearch = () => {
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);

const { state, dispatch, getRangeInTimestamp, getTime } = useHostsUrlState();
const { metricsDataView } = useMetricsDataViewContext();
const { services } = useKibana<InfraClientStartDeps>();
const {
data: { query: queryManager },
} = services;

const [getTime, setTime] = useKibanaTimefilterTime({
from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`,
to: 'now',
useSyncKibanaTimeFilterTime(INITIAL_DATE_RANGE, {
from: state.dateRange.from,
to: state.dateRange.to,
});
const { queryString, filterManager } = queryManager;

const currentDate = new Date();
const fromTS =
DateMath.parse(getTime().from)?.valueOf() ??
new Date(currentDate.getMinutes() - DEFAULT_FROM_MINUTES_VALUE).getTime();
const toTS = DateMath.parse(getTime().to)?.valueOf() ?? currentDate.getTime();

const currentTimeRange = {
from: fromTS,
to: toTS,
};
const { filterManager } = queryManager;

const submitFilterChange = useCallback(
const onSubmit = useCallback(
(query?: Query, dateRange?: TimeRange, filters?: Filter[]) => {
if (filters) {
filterManager.setFilters(filters);
if (query || dateRange || filters) {
const newDateRange = dateRange ?? getTime();

if (filters) {
filterManager.setFilters(filters);
}
dispatch({
type: 'setQuery',
payload: {
query,
filters: filters ? filterManager.getFilters() : undefined,
dateRange: newDateRange,
dateRangeTimestamp: getRangeInTimestamp(newDateRange),
},
});
}

setTime({
...getTime(),
...dateRange,
});

queryString.setQuery({ ...queryString.getQuery(), ...query });
// Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values
// This can be removed once we get the state from the URL
forceUpdate();
},
[filterManager, queryString, getTime, setTime]
[filterManager, getRangeInTimestamp, getTime, dispatch]
);

// This won't prevent onSubmit from being fired twice when `clear filters` is clicked,
// that happens because both onQuerySubmit and onFiltersUpdated are internally triggered on same event by SearchBar.
// This just delays potential duplicate onSubmit calls
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceOnSubmit = useCallback(debounce(onSubmit, 100), [onSubmit]);

const saveQuery = useCallback(
(newSavedQuery: SavedQuery) => {
const savedQueryFilters = newSavedQuery.attributes.filters ?? [];
const globalFilters = filterManager.getGlobalFilters();
filterManager.setFilters([...savedQueryFilters, ...globalFilters]);

// Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values
// This can be removed once we get the state from the URL
forceUpdate();
const query = newSavedQuery.attributes.query;

dispatch({
type: 'setQuery',
payload: {
query,
filters: [...savedQueryFilters, ...globalFilters],
},
});
},
[filterManager]
[filterManager, dispatch]
);

const clearSavedQUery = useCallback(() => {
filterManager.setFilters(filterManager.getGlobalFilters());

// Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values
// This can be removed once we get the state from the URL
forceUpdate();
}, [filterManager]);
const clearSavedQuery = useCallback(() => {
dispatch({
type: 'setFilter',
payload: filterManager.getGlobalFilters(),
});
}, [filterManager, dispatch]);

const buildQuery = useCallback(() => {
if (!metricsDataView) {
return null;
}
return buildEsQuery(metricsDataView, queryString.getQuery(), filterManager.getFilters());
}, [filterManager, metricsDataView, queryString]);
return buildEsQuery(metricsDataView, state.query, state.filters);
}, [metricsDataView, state.filters, state.query]);

return {
dateRangeTimestamp: currentTimeRange,
esQuery: buildQuery(),
submitFilterChange,
dateRangeTimestamp: state.dateRangeTimestamp,
buildQuery,
onSubmit: debounceOnSubmit,
saveQuery,
clearSavedQUery,
unifiedSearchQuery: queryString.getQuery() as Query,
clearSavedQuery,
unifiedSearchQuery: state.query,
unifiedSearchDateRange: getTime(),
unifiedSearchFilters: filterManager.getFilters(),
unifiedSearchFilters: state.filters,
};
};

Expand Down

0 comments on commit 20e2fb5

Please sign in to comment.