diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx index 9f84aa3096213..659af8ab0744a 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; +import { QuickFiltersMenuItem } from './quick_filters'; import { QueryBarMenuPanels, QueryBarMenuPanelsProps } from './query_bar_menu_panels'; import { FilterEditorWrapper } from './filter_editor_wrapper'; import { popoverDragAndDropCss } from './add_filter_popover.styles'; @@ -58,6 +59,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp hiddenPanelOptions?: QueryBarMenuPanelsProps['hiddenPanelOptions']; onFiltersUpdated?: (filters: Filter[]) => void; filters?: Filter[]; + quickFilters: QuickFiltersMenuItem[]; query?: Query; savedQuery?: SavedQuery; onClearSavedQuery?: () => void; @@ -89,6 +91,7 @@ function QueryBarMenuComponent({ toggleFilterBarMenuPopover, onFiltersUpdated, filters, + quickFilters, query, savedQuery, onClearSavedQuery, @@ -150,6 +153,7 @@ function QueryBarMenuComponent({ const panels = QueryBarMenuPanels({ filters, + quickFilters, savedQuery, language, dateRangeFrom, diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx index b55377d618b3d..d24fcb5eeca8a 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { isEqual } from 'lodash'; import { EuiContextMenuPanelDescriptor, @@ -24,6 +24,7 @@ import { toggleFilterNegated, pinFilter, unpinFilter, + compareFilters, } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -34,6 +35,8 @@ import { UI_SETTINGS, } from '@kbn/data-plugin/common'; import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; +import { EuiContextMenuPanelItemDescriptor } from '@elastic/eui/src/components/context_menu/context_menu'; +import { isQuickFiltersGroup, QuickFiltersMenuItem } from './quick_filters'; import type { IUnifiedSearchPluginServices } from '../types'; import { fromUser } from './from_user'; import { QueryLanguageSwitcher } from './language_switcher'; @@ -134,10 +137,21 @@ export const strings = { i18n.translate('unifiedSearch.filter.options.filterLanguageLabel', { defaultMessage: 'Filter language', }), + getQuickFiltersLabel: () => + i18n.translate('unifiedSearch.filter.options.quickFiltersLabel', { + defaultMessage: 'Quick filters', + }), }; +type ContextMenuItem = + | EuiContextMenuPanelDescriptor + | (EuiContextMenuPanelItemDescriptor & { + width?: number; + }); + export interface QueryBarMenuPanelsProps { filters?: Filter[]; + quickFilters: QuickFiltersMenuItem[]; savedQuery?: SavedQuery; language: string; dateRangeFrom?: string; @@ -162,6 +176,7 @@ export interface QueryBarMenuPanelsProps { export function QueryBarMenuPanels({ filters, + quickFilters, savedQuery, language, dateRangeFrom, @@ -192,6 +207,52 @@ export function QueryBarMenuPanels({ const [hasFiltersOrQuery, setHasFiltersOrQuery] = useState(false); const [savedQueryHasChanged, setSavedQueryHasChanged] = useState(false); + const applyQuickFilter = useCallback( + (filter: Filter) => { + if (!filters?.some((f) => compareFilters(f, filter))) { + onFiltersUpdated?.([...(filters ?? []), filter]); + } + closePopover(); + }, + [closePopover, filters, onFiltersUpdated] + ); + + const quickFiltersContextMenuData = useMemo(() => { + let items = [] as EuiContextMenuPanelItemDescriptor[]; + const panels = [] as EuiContextMenuPanelDescriptor[]; + if (showFilterBar && quickFilters.length > 0) { + let panelsCount = 0; + const quickFiltersItemToContextMenuItem = (qf: QuickFiltersMenuItem) => { + if (isQuickFiltersGroup(qf)) { + const panelId = `quick_filter_group_${panelsCount++}`; + panels.push({ + id: panelId, + title: qf.groupName, + items: qf.items.map(quickFiltersItemToContextMenuItem), + }); + return { + name: qf.groupName, + icon: qf.icon ?? 'filterInCircle', + panel: panelId, + }; + } else { + return { + ...qf, + icon: qf.icon ?? 'filterInCircle', + onClick: () => { + applyQuickFilter(qf.filter); + }, + }; + } + }; + items = quickFilters.map(quickFiltersItemToContextMenuItem); + } + return { + items, + panels, + }; + }, [applyQuickFilter, quickFilters, showFilterBar]); + useEffect(() => { const fetchSavedQueries = async () => { cancelPendingListingRequest.current(); @@ -320,7 +381,7 @@ export function QueryBarMenuPanels({ const luceneLabel = strings.getLuceneLanguageName(); const kqlLabel = strings.getKqlLanguageName(); - const filtersRelatedPanels = [ + const filtersRelatedPanels: ContextMenuItem[] = [ { name: strings.getOptionsAddFilterButtonLabel(), icon: 'plus', @@ -337,7 +398,7 @@ export function QueryBarMenuPanels({ }, ]; - const queryAndFiltersRelatedPanels = [ + const queryAndFiltersRelatedPanels: ContextMenuItem[] = [ { name: savedQuery ? strings.getLoadOtherFilterSetLabel() @@ -359,7 +420,7 @@ export function QueryBarMenuPanels({ { isSeparator: true }, ]; - const items = []; + const items: ContextMenuItem[] = []; // apply to all actions are only shown when there are filters if (showFilterBar) { items.push(...filtersRelatedPanels); @@ -385,6 +446,11 @@ export function QueryBarMenuPanels({ { isSeparator: true } ); } + + if (showFilterBar && quickFilters.length > 0) { + items.push(...[...quickFiltersContextMenuData.items, { isSeparator: true } as const]); + } + // saved queries actions are only shown when the showQueryInput and showFilterBar is true if (showQueryInput && showFilterBar) { items.push(...queryAndFiltersRelatedPanels); @@ -513,6 +579,7 @@ export function QueryBarMenuPanels({ width: 400, content: <div>{manageFilterSetComponent}</div>, }, + ...quickFiltersContextMenuData.panels, ] as EuiContextMenuPanelDescriptor[]; if (hiddenPanelOptions && hiddenPanelOptions.length > 0) { diff --git a/src/plugins/unified_search/public/query_string_input/quick_filters.ts b/src/plugins/unified_search/public/query_string_input/quick_filters.ts new file mode 100644 index 0000000000000..33deb788847ff --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/quick_filters.ts @@ -0,0 +1,30 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { ComponentType } from 'react'; +import { Filter } from '@kbn/es-query'; + +export interface QuickFilter { + name: string; + icon?: Exclude<EuiIconType, ComponentType>; + disabled?: boolean; + filter: Filter; +} + +export interface QuickFiltersGroup { + groupName: string; + icon?: Exclude<EuiIconType, ComponentType>; + items: QuickFiltersMenuItem[]; +} + +export type QuickFiltersMenuItem = QuickFiltersGroup | QuickFilter; + +export const isQuickFiltersGroup = ( + quickFiltersMenuItem: QuickFiltersMenuItem +): quickFiltersMenuItem is QuickFiltersGroup => 'items' in quickFiltersMenuItem; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index 0372775922120..ec13a282e331d 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -33,6 +33,7 @@ import type { SuggestionsListSize, } from '../typeahead/suggestions_component'; import { searchBarStyles } from './search_bar.styles'; +import { QuickFiltersMenuItem } from '../query_string_input/quick_filters'; export interface SearchBarInjectedDeps { kibana: KibanaReactContextValue<IUnifiedSearchPluginServices>; @@ -58,6 +59,7 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> { showDatePicker?: boolean; showAutoRefreshOnly?: boolean; filters?: Filter[]; + quickFilters?: QuickFiltersMenuItem[]; filtersForSuggestions?: Filter[]; hiddenFilterPanelOptions?: QueryBarMenuProps['hiddenPanelOptions']; prependFilterBar?: React.ReactNode; @@ -146,6 +148,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C showSubmitButton: true, showAutoRefreshOnly: false, filtersForSuggestions: [], + quickFilters: [], }; private services = this.props.kibana.services; @@ -502,6 +505,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C openQueryBarMenu={this.state.openQueryBarMenu} onFiltersUpdated={this.props.onFiltersUpdated} filters={this.props.filters} + quickFilters={this.props.quickFilters ?? []} hiddenPanelOptions={this.props.hiddenFilterPanelOptions} query={this.state.query as Query} savedQuery={this.props.savedQuery} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/alerts_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/alerts_app.tsx index a07e4c3488c1a..cd0fb55b55e2d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/alerts_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/alerts_app.tsx @@ -36,7 +36,7 @@ import { import { setDataViewsService } from '../common/lib/data_apis'; import { KibanaContextProvider } from '../common/lib/kibana'; -const Alerts = lazy(() => import('./sections/global_alerts')); +const GlobalAlerts = lazy(() => import('./sections/global_alerts')); export interface TriggersAndActionsUiServices extends CoreStart { actions: ActionsPublicPluginSetup; @@ -60,7 +60,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { unifiedSearch: UnifiedSearchPublicPluginStart; } -export const renderApp = (deps: TriggersAndActionsUiServices) => { +export const renderApp = (deps: Partial<TriggersAndActionsUiServices>) => { const { element } = deps; render(<App deps={deps} />, element); return () => { @@ -80,7 +80,7 @@ export const App = ({ deps }: { deps: TriggersAndActionsUiServices }) => { <KibanaContextProvider services={{ ...deps }}> <Router history={deps.history}> <Routes> - <Route path={`/`} component={suspendedComponentWithProps(Alerts, 'xl')} /> + <Route path={`/`} component={suspendedComponentWithProps(GlobalAlerts, 'xl')} /> </Routes> </Router> </KibanaContextProvider> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.test.tsx index e37808a05d9b2..230e7b70836cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.test.tsx @@ -9,7 +9,7 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; import type { ValidFeatureId } from '@kbn/rule-data-utils'; import { act, renderHook } from '@testing-library/react-hooks'; -import { useAlertDataView } from './use_alert_data_view'; +import { useAlertDataViews } from './use_alert_data_view'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; @@ -76,7 +76,7 @@ describe('useAlertDataView', () => { }; const { result, waitForNextUpdate } = renderHook( - () => useAlertDataView(observabilityAlertFeatureIds), + () => useAlertDataViews(observabilityAlertFeatureIds), { wrapper, } @@ -91,7 +91,7 @@ describe('useAlertDataView', () => { it('fetch index names + fields for the provided o11y featureIds', async () => { await act(async () => { const { waitForNextUpdate } = renderHook( - () => useAlertDataView(observabilityAlertFeatureIds), + () => useAlertDataViews(observabilityAlertFeatureIds), { wrapper, } @@ -107,7 +107,7 @@ describe('useAlertDataView', () => { it('only fetch index names for security featureId', async () => { await act(async () => { - const { waitForNextUpdate } = renderHook(() => useAlertDataView([AlertConsumers.SIEM]), { + const { waitForNextUpdate } = renderHook(() => useAlertDataViews([AlertConsumers.SIEM]), { wrapper, }); @@ -122,7 +122,7 @@ describe('useAlertDataView', () => { it('Do not fetch anything if security and o11y featureIds are mix together', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => useAlertDataView([AlertConsumers.SIEM, AlertConsumers.LOGS]), + () => useAlertDataViews([AlertConsumers.SIEM, AlertConsumers.LOGS]), { wrapper, } @@ -144,7 +144,7 @@ describe('useAlertDataView', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( - () => useAlertDataView(observabilityAlertFeatureIds), + () => useAlertDataViews(observabilityAlertFeatureIds), { wrapper, } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.ts index 7b72e5898d56d..322bab6c88301 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_alert_data_view.ts @@ -15,18 +15,18 @@ import { TriggersAndActionsUiServices } from '../..'; import { fetchAlertIndexNames } from '../lib/rule_api/alert_index'; import { fetchAlertFields } from '../lib/rule_api/alert_fields'; -export interface UserAlertDataView { - dataviews?: DataView[]; +export interface UserAlertDataViews { + dataViews?: DataView[]; loading: boolean; } -export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataView { +export function useAlertDataViews(featureIds: ValidFeatureId[]): UserAlertDataViews { const { http, data: dataService, notifications: { toasts }, } = useKibana<TriggersAndActionsUiServices>().services; - const [dataviews, setDataviews] = useState<DataView[] | undefined>(undefined); + const [dataViews, setDataViews] = useState<DataView[] | undefined>(undefined); const features = featureIds.sort().join(','); const isOnlySecurity = featureIds.length === 1 && featureIds.includes(AlertConsumers.SIEM); @@ -80,12 +80,12 @@ export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataVie useEffect(() => { return () => { - dataviews?.map((dv) => { + dataViews?.map((dv) => { dataService.dataViews.clearInstanceCache(dv.id); }); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataviews]); + }, [dataViews]); // FUTURE ENGINEER this useEffect is for security solution user since // we are using the user privilege to access the security alert index @@ -95,7 +95,7 @@ export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataVie title: (indexNames ?? []).join(','), allowNoIndex: true, }); - setDataviews([localDataview]); + setDataViews([localDataview]); } if (isOnlySecurity && isIndexNameSuccess) { @@ -113,7 +113,7 @@ export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataVie isAlertFieldsSuccess && isIndexNameSuccess ) { - setDataviews([ + setDataViews([ { title: (indexNames ?? []).join(','), fieldFormatMap: {}, @@ -137,7 +137,7 @@ export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataVie return useMemo( () => ({ - dataviews, + dataViews, loading: featureIds.length === 0 || hasSecurityAndO11yFeatureIds ? false @@ -149,7 +149,7 @@ export function useAlertDataView(featureIds: ValidFeatureId[]): UserAlertDataVie isAlertFieldsLoading, }), [ - dataviews, + dataViews, featureIds.length, hasSecurityAndO11yFeatureIds, isOnlySecurity, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx index 039489c57aae0..ed603a7edacb2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/alerts_search_bar.tsx @@ -13,7 +13,7 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; import { NO_INDEX_PATTERNS } from './constants'; import { SEARCH_BAR_PLACEHOLDER } from './translations'; import { AlertsSearchBarProps, QueryLanguageType } from './types'; -import { useAlertDataView } from '../../hooks/use_alert_data_view'; +import { useAlertDataViews } from '../../hooks/use_alert_data_view'; import { TriggersAndActionsUiServices } from '../../..'; import { useRuleAADFields } from '../../hooks/use_rule_aad_fields'; import { useLoadRuleTypesQuery } from '../../hooks/use_load_rule_types_query'; @@ -29,6 +29,7 @@ export function AlertsSearchBar({ ruleTypeId, query, filters, + quickFilters = [], onQueryChange, onQuerySubmit, onFiltersUpdated, @@ -39,6 +40,7 @@ export function AlertsSearchBar({ showSubmitButton = true, placeholder = SEARCH_BAR_PLACEHOLDER, submitOnBlur = false, + filtersForSuggestions, }: AlertsSearchBarProps) { const { unifiedSearch: { @@ -47,11 +49,11 @@ export function AlertsSearchBar({ } = useKibana<TriggersAndActionsUiServices>().services; const [queryLanguage, setQueryLanguage] = useState<QueryLanguageType>('kuery'); - const { dataviews, loading } = useAlertDataView(featureIds ?? []); + const { dataViews, loading } = useAlertDataViews(featureIds ?? []); const { aadFields, loading: fieldsLoading } = useRuleAADFields(ruleTypeId); const indexPatterns = - ruleTypeId && aadFields?.length ? [{ title: ruleTypeId, fields: aadFields }] : dataviews; + ruleTypeId && aadFields?.length ? [{ title: ruleTypeId, fields: aadFields }] : dataViews; const ruleType = useLoadRuleTypesQuery({ filteredRuleTypes: ruleTypeId !== undefined ? [ruleTypeId] : [], @@ -100,6 +102,7 @@ export function AlertsSearchBar({ placeholder={placeholder} query={{ query: query ?? '', language: queryLanguage }} filters={filters} + quickFilters={quickFilters} dateRangeFrom={rangeFrom} dateRangeTo={rangeTo} displayStyle="inPage" @@ -114,6 +117,7 @@ export function AlertsSearchBar({ submitOnBlur={submitOnBlur} onQueryChange={onSearchQueryChange} suggestionsAbstraction={isSecurity ? undefined : SA_ALERTS} + filtersForSuggestions={filtersForSuggestions} /> ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/constants.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/constants.ts index e1eca7239cc9d..116e72cfdb3bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/constants.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/constants.ts @@ -6,5 +6,9 @@ */ import { DataView } from '@kbn/data-views-plugin/common'; +import { AlertConsumers } from '@kbn/rule-data-utils'; export const NO_INDEX_PATTERNS: DataView[] = []; +export const ALERTS_URL_STORAGE_KEY = '_a'; +export const ALL_FEATURE_IDS = Object.values(AlertConsumers); +export const NON_SIEM_FEATURE_IDS = ALL_FEATURE_IDS.filter((fid) => fid !== AlertConsumers.SIEM); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/types.ts index 1f7acc2282bd7..94381fb0356d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/types.ts @@ -7,6 +7,7 @@ import { Filter } from '@kbn/es-query'; import { ValidFeatureId } from '@kbn/rule-data-utils'; +import { QuickFilter } from '@kbn/unified-search-plugin/public/search_bar/search_bar'; export type QueryLanguageType = 'lucene' | 'kuery'; @@ -18,6 +19,7 @@ export interface AlertsSearchBarProps { rangeTo?: string; query?: string; filters?: Filter[]; + quickFilters?: QuickFilter[]; showFilterBar?: boolean; showDatePicker?: boolean; showSubmitButton?: boolean; @@ -33,4 +35,5 @@ export interface AlertsSearchBarProps { query?: string; }) => void; onFiltersUpdated?: (filters: Filter[]) => void; + filtersForSuggestions?: Filter[]; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/url_synced_alerts_search_bar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/url_synced_alerts_search_bar.tsx new file mode 100644 index 0000000000000..7b3d2eb6a2001 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/url_synced_alerts_search_bar.tsx @@ -0,0 +1,116 @@ +/* + * 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, { useCallback, useEffect } from 'react'; +import { BoolQuery, FILTERS, PhraseFilter } from '@kbn/es-query'; +import { ALERT_RULE_PRODUCER, AlertConsumers } from '@kbn/rule-data-utils'; +import { buildEsQuery } from '../global_alerts'; +import { useKibana } from '../../..'; +import { useAlertSearchBarStateContainer } from './use_alert_search_bar_state_container'; +import { ALERTS_URL_STORAGE_KEY, NON_SIEM_FEATURE_IDS } from './constants'; +import { AlertsSearchBarProps } from './types'; +import AlertsSearchBar from './alerts_search_bar'; + +export type UrlSyncedAlertsSearchBarProps = Omit< + AlertsSearchBarProps, + 'query' | 'rangeFrom' | 'rangeTo' | 'filters' | 'onQuerySubmit' +> & { + onEsQueryChange: (esQuery: { bool: BoolQuery }) => void; + onFeatureIdsChange: (featureIds: AlertConsumers[]) => void; + onFilteringBySolutionChange?: (value: boolean) => void; +}; + +export const UrlSyncedAlertsSearchBar = ({ + onEsQueryChange, + onFeatureIdsChange, + onFilteringBySolutionChange, + ...rest +}: UrlSyncedAlertsSearchBarProps) => { + const { + data: { + query: { + timefilter: { timefilter: timeFilterService }, + }, + }, + } = useKibana().services; + const { + kuery, + rangeFrom, + rangeTo, + filters, + onKueryChange, + onRangeFromChange, + onRangeToChange, + onFiltersChange, + } = useAlertSearchBarStateContainer(ALERTS_URL_STORAGE_KEY); + + const syncEsQuery = useCallback(() => { + try { + const solutionFilters = filters.filter( + (f) => + f.meta.key === ALERT_RULE_PRODUCER && + (f.meta.type === FILTERS.PHRASE || f.meta.type === FILTERS.PHRASES) + ); + onFilteringBySolutionChange?.(solutionFilters.length > 0); + onFeatureIdsChange( + !solutionFilters.length || + solutionFilters.filter( + (f) => (f as PhraseFilter).meta.params?.query !== AlertConsumers.SIEM + ).length > 0 + ? NON_SIEM_FEATURE_IDS + : [AlertConsumers.SIEM] + ); + const newQuery = buildEsQuery({ + timeRange: { + to: rangeTo, + from: rangeFrom, + }, + kuery, + filters, + }); + onEsQueryChange(newQuery); + } catch (e) { + // TODO show message? + } + }, [ + filters, + kuery, + onEsQueryChange, + onFeatureIdsChange, + onFilteringBySolutionChange, + rangeFrom, + rangeTo, + ]); + + useEffect(() => { + syncEsQuery(); + }, [syncEsQuery]); + + const onQueryChange = useCallback<Exclude<AlertsSearchBarProps['onQueryChange'], undefined>>( + ({ query, dateRange }) => { + timeFilterService.setTime(dateRange); + onKueryChange(query ?? ''); + onRangeFromChange(dateRange.from); + onRangeToChange(dateRange.to); + + syncEsQuery(); + }, + [onKueryChange, onRangeFromChange, onRangeToChange, syncEsQuery, timeFilterService] + ); + + return ( + <AlertsSearchBar + rangeFrom={rangeFrom} + rangeTo={rangeTo} + query={kuery} + onQuerySubmit={onQueryChange} + filters={filters} + onFiltersUpdated={onFiltersChange} + {...rest} + /> + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/use_alert_search_bar_state_container.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/use_alert_search_bar_state_container.tsx new file mode 100644 index 0000000000000..475bd9b13845c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_search_bar/use_alert_search_bar_state_container.tsx @@ -0,0 +1,234 @@ +/* + * 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 { isRight } from 'fp-ts/Either'; +import { pipe } from 'fp-ts/pipeable'; +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { TimefilterContract } from '@kbn/data-plugin/public'; +import { + createKbnUrlStateStorage, + syncState, + IKbnUrlStateStorage, + useContainerSelector, + createStateContainer, + createStateContainerReactHelpers, +} from '@kbn/kibana-utils-plugin/public'; + +import * as t from 'io-ts'; +import { + ALERT_STATUS_ACTIVE, + ALERT_STATUS_RECOVERED, + ALERT_STATUS_UNTRACKED, +} from '@kbn/rule-data-utils'; +import { datemathStringRt } from '@kbn/io-ts-utils'; +import { Filter } from '@kbn/es-query'; +import { useKibana } from '../../../common/lib/kibana'; + +const ALERT_STATUS_ALL = 'all'; + +export type AlertStatus = + | typeof ALERT_STATUS_ACTIVE + | typeof ALERT_STATUS_RECOVERED + | typeof ALERT_STATUS_UNTRACKED + | typeof ALERT_STATUS_ALL; + +interface AlertSearchBarContainerState { + rangeFrom: string; + rangeTo: string; + kuery: string; + status: AlertStatus; + filters: Filter[]; +} + +interface AlertSearchBarStateTransitions { + setRangeFrom: ( + state: AlertSearchBarContainerState + ) => (rangeFrom: string) => AlertSearchBarContainerState; + setRangeTo: ( + state: AlertSearchBarContainerState + ) => (rangeTo: string) => AlertSearchBarContainerState; + setKuery: ( + state: AlertSearchBarContainerState + ) => (kuery: string) => AlertSearchBarContainerState; + setStatus: ( + state: AlertSearchBarContainerState + ) => (status: AlertStatus) => AlertSearchBarContainerState; + setFilters: ( + state: AlertSearchBarContainerState + ) => (filters: Filter[]) => AlertSearchBarContainerState; +} + +const defaultState: AlertSearchBarContainerState = { + rangeFrom: 'now-15m', + rangeTo: 'now', + kuery: '', + status: ALERT_STATUS_ALL, + filters: [], +}; + +const transitions: AlertSearchBarStateTransitions = { + setRangeFrom: (state) => (rangeFrom) => ({ ...state, rangeFrom }), + setRangeTo: (state) => (rangeTo) => ({ ...state, rangeTo }), + setKuery: (state) => (kuery) => ({ ...state, kuery }), + setStatus: (state) => (status) => ({ ...state, status }), + setFilters: (state) => (filters) => ({ ...state, filters }), +}; + +export const alertSearchBarStateContainer = createStateContainer(defaultState, transitions); + +type AlertSearchBarStateContainer = typeof alertSearchBarStateContainer; + +export const { Provider, useContainer } = + createStateContainerReactHelpers<AlertSearchBarStateContainer>(); + +export function useAlertSearchBarStateContainer( + urlStorageKey: string, + { replace }: { replace?: boolean } = {} +) { + const stateContainer = useContainer(); + + useUrlStateSyncEffect(stateContainer, urlStorageKey, replace); + + const { setRangeFrom, setRangeTo, setKuery, setStatus, setFilters } = stateContainer.transitions; + const { rangeFrom, rangeTo, kuery, status, filters } = useContainerSelector( + stateContainer, + (state) => state + ); + + return { + kuery, + onKueryChange: setKuery, + onRangeFromChange: setRangeFrom, + onRangeToChange: setRangeTo, + onStatusChange: setStatus, + onFiltersChange: setFilters, + filters, + rangeFrom, + rangeTo, + status, + }; +} + +function useUrlStateSyncEffect( + stateContainer: AlertSearchBarStateContainer, + urlStorageKey: string, + replace: boolean = true +) { + const history = useHistory(); + const { + data: { + query: { + timefilter: { timefilter: timeFilterService }, + }, + }, + } = useKibana().services; + + useEffect(() => { + const urlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + }); + const { start, stop } = setupUrlStateSync( + stateContainer, + urlStateStorage, + urlStorageKey, + replace + ); + + start(); + + syncUrlStateWithInitialContainerState( + timeFilterService, + stateContainer, + urlStateStorage, + urlStorageKey, + replace + ); + + return stop; + }, [stateContainer, history, timeFilterService, urlStorageKey, replace]); +} + +function setupUrlStateSync( + stateContainer: AlertSearchBarStateContainer, + urlStateStorage: IKbnUrlStateStorage, + urlStorageKey: string, + replace: boolean = true +) { + // This handles filling the state when an incomplete URL set is provided + const setWithDefaults = (changedState: Partial<AlertSearchBarContainerState> | null) => { + stateContainer.set({ ...defaultState, ...changedState }); + }; + + return syncState({ + storageKey: urlStorageKey, + stateContainer: { + ...stateContainer, + set: setWithDefaults, + }, + stateStorage: { + ...urlStateStorage, + set: <AlertSearchBarStateContainer,>(key: string, state: AlertSearchBarStateContainer) => + urlStateStorage.set(key, state, { replace }), + }, + }); +} + +export const alertSearchBarState = t.partial({ + rangeFrom: datemathStringRt, + rangeTo: datemathStringRt, + kuery: t.string, + status: t.union([ + t.literal(ALERT_STATUS_ACTIVE), + t.literal(ALERT_STATUS_RECOVERED), + t.literal(ALERT_STATUS_ALL), + ]), + // filters: t.UnknownArray, +}); + +function syncUrlStateWithInitialContainerState( + timefilterService: TimefilterContract, + stateContainer: AlertSearchBarStateContainer, + urlStateStorage: IKbnUrlStateStorage, + urlStorageKey: string, + replace: boolean = true +) { + const urlState = alertSearchBarState.decode( + urlStateStorage.get<Partial<AlertSearchBarContainerState>>(urlStorageKey) + ); + + if (isRight(urlState)) { + const newState = { + ...defaultState, + ...pipe(urlState).right, + }; + + stateContainer.set(newState); + urlStateStorage.set(urlStorageKey, stateContainer.get(), { + replace: true, + }); + return; + } else if (timefilterService.isTimeTouched()) { + const { from, to } = timefilterService.getTime(); + const newState = { + ...defaultState, + rangeFrom: from, + rangeTo: to, + }; + stateContainer.set(newState); + } else { + // Reset the state container when no URL state or timefilter range is set to avoid accidentally + // re-using state set on a previous visit to the page in the same session + stateContainer.set(defaultState); + } + + urlStateStorage.set(urlStorageKey, stateContainer.get(), { + replace, + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index 5c6cd492b6f88..0ff3da06f75bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -301,7 +301,7 @@ const AlertsTable: React.FunctionComponent<AlertsTableProps> = (props: AlertsTab } return ( - <EuiFlexGroup gutterSize="none" responsive={false}> + <EuiFlexGroup gutterSize="none" responsive={false} alignItems="center"> {renderCustomActionsRow({ alert: alerts[visibleRowIndex], ecsAlert: ecsAlertsData[visibleRowIndex], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index 7103dfc8bea6b..1e98865664c5a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -255,6 +255,8 @@ const AlertsTableStateWithQueryProvider = ({ skip: false, }); + console.log('Alerts', alerts); + const { data: mutedAlerts } = useGetMutedAlerts([ ...new Set(alerts.map((a) => a['kibana.alert.rule.uuid']![0])), ]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/cells/render_cell_value.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/cells/render_cell_value.tsx index 28ef1439c8849..1b9ef960f1a20 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/cells/render_cell_value.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/cells/render_cell_value.tsx @@ -7,12 +7,19 @@ import { isEmpty } from 'lodash'; import React, { type ReactNode } from 'react'; -import { ALERT_DURATION, TIMESTAMP } from '@kbn/rule-data-utils'; +import { + ALERT_DURATION, + ALERT_RULE_PRODUCER, + AlertConsumers, + TIMESTAMP, +} from '@kbn/rule-data-utils'; import { FIELD_FORMAT_IDS, FieldFormatParams, FieldFormatsRegistry, } from '@kbn/field-formats-plugin/common'; +import { EuiBadge } from '@elastic/eui'; +import { alertProducersData } from '../constants'; import { GetRenderCellValue } from '../../../../types'; import { useKibana } from '../../../../common/lib/kibana'; @@ -107,6 +114,9 @@ export function getAlertFormatters(fieldFormats: FieldFormatsRegistry) { })(value) || '--'} </> ); + case ALERT_RULE_PRODUCER: + const consumerData = alertProducersData[value as AlertConsumers]; + return <EuiBadge iconType={consumerData.icon}>{consumerData.displayName}</EuiBadge>; default: return <>{value}</>; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/configuration.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/configuration.tsx index 4151e366e9257..d1c9c28a554a7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/configuration.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/configuration.tsx @@ -29,7 +29,7 @@ const columns = [ defaultMessage: 'Alert Status', }), id: ALERT_STATUS, - initialWidth: 110, + initialWidth: 140, }, { columnHeaderType: 'not-filtered', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/constants.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/constants.ts new file mode 100644 index 0000000000000..576d5edd25794 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/constants.ts @@ -0,0 +1,122 @@ +/* + * 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 { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { i18n } from '@kbn/i18n'; +import { AlertConsumers } from '@kbn/rule-data-utils'; + +export const OBSERVABILITY_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.observability', + { + defaultMessage: 'Observability', + } +); + +export const SECURITY_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.security', + { + defaultMessage: 'Security', + } +); + +export const STACK_MANAGEMENT_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.stackManagement', + { + defaultMessage: 'Stack management', + } +); + +export const UPTIME_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.uptime', + { + defaultMessage: 'Uptime', + } +); + +export const APM_DISPLAY_NAME = i18n.translate('xpack.triggersActionsUI.sections.alertsTable.apm', { + defaultMessage: 'APM', +}); + +export const INFRASTRUCTURE_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.infrastructure', + { + defaultMessage: 'Infrastructure', + } +); + +export const SLO_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.slos', + { + defaultMessage: 'SLOs', + } +); + +export const LOGS_DISPLAY_NAME = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsTable.logs', + { + defaultMessage: 'Logs', + } +); + +export const ML_DISPLAY_NAME = i18n.translate('xpack.triggersActionsUI.sections.alertsTable.ml', { + defaultMessage: 'Machine Learning', +}); + +interface AlertProducerData { + displayName: string; + icon: EuiIconType; +} + +export const observabilityProducers = [ + AlertConsumers.OBSERVABILITY, + AlertConsumers.APM, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.SLO, + AlertConsumers.UPTIME, +]; + +export const [_, ...observabilityApps] = observabilityProducers; + +export const alertProducersData: Record<AlertConsumers, AlertProducerData> = { + [AlertConsumers.OBSERVABILITY]: { + displayName: OBSERVABILITY_DISPLAY_NAME, + icon: 'logoObservability', + }, + [AlertConsumers.APM]: { + displayName: APM_DISPLAY_NAME, + icon: 'apmApp', + }, + [AlertConsumers.INFRASTRUCTURE]: { + displayName: INFRASTRUCTURE_DISPLAY_NAME, + icon: 'logoObservability', + }, + [AlertConsumers.LOGS]: { + displayName: LOGS_DISPLAY_NAME, + icon: 'logsApp', + }, + [AlertConsumers.SLO]: { + displayName: SLO_DISPLAY_NAME, + icon: 'logoObservability', + }, + [AlertConsumers.UPTIME]: { + displayName: UPTIME_DISPLAY_NAME, + icon: 'uptimeApp', + }, + [AlertConsumers.ML]: { + displayName: ML_DISPLAY_NAME, + icon: 'machineLearningApp', + }, + [AlertConsumers.SIEM]: { + displayName: SECURITY_DISPLAY_NAME, + icon: 'securityApp', + }, + [AlertConsumers.STACK_ALERTS]: { + displayName: STACK_MANAGEMENT_DISPLAY_NAME, + icon: 'managementApp', + }, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/row_actions/alert_actions_cell.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/row_actions/alert_actions_cell.tsx index dd748bb60648e..70be067c1c002 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/row_actions/alert_actions_cell.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/row_actions/alert_actions_cell.tsx @@ -49,9 +49,36 @@ export function AlertActionsCell(alertActionsProps: AlertActionsProps) { // TODO re-enable view in app when it works const actionsMenuItems = [DefaultRowActions]; + // const producer = alertActionsProps.alert[ALERT_RULE_PRODUCER]![0] as AlertConsumers; + // const producerData = ruleProducersData[producer]; + // const alertUrl = alertActionsProps.alert[ALERT_URL]?.[0] as string | undefined; return ( <> + {/* <EuiToolTip*/} + {/* content={*/} + {/* alertUrl*/} + {/* ? i18n.translate('xpack.triggersActionsUI.sections.alertsTable.viewInApp', {*/} + {/* defaultMessage: 'View in {app}',*/} + {/* values: { app: producerData.displayName },*/} + {/* })*/} + {/* : producerData.displayName*/} + {/* }*/} + {/* >*/} + {/* <EuiLink*/} + {/* href={alertUrl}*/} + {/* css={css`*/} + {/* display: block;*/} + {/* line-height: 0;*/} + {/* background-color: ${euiThemeVars.euiColorEmptyShade};*/} + {/* border-radius: 50%;*/} + {/* padding: ${euiThemeVars.euiSizeXS};*/} + {/* ${useEuiShadow('xs')}*/} + {/* `}*/} + {/* >*/} + {/* <EuiIcon size="s" type={producerIcons[producer]} />*/} + {/* </EuiLink>*/} + {/* </EuiToolTip>*/} <EuiFlexItem> <EuiPopover anchorPosition="downLeft" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/global_alerts/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/global_alerts/index.tsx new file mode 100644 index 0000000000000..b5fad6b6c4839 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/global_alerts/index.tsx @@ -0,0 +1,412 @@ +/* + * 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, { lazy, useEffect, useMemo, useState } from 'react'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty, EuiDataGridColumn, EuiFlexGroup, EuiStat } from '@elastic/eui'; +import styled from '@emotion/styled'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { + ALERT_RULE_PRODUCER, + ALERT_STATUS, + ALERT_STATUS_ACTIVE, + ALERT_STATUS_RECOVERED, + ALERT_STATUS_UNTRACKED, + ALERT_TIME_RANGE, + AlertConsumers, + DefaultAlertFieldName, +} from '@kbn/rule-data-utils'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { + BoolQuery, + buildEsQuery as kbnBuildEsQuery, + EsQueryConfig, + Filter, + FILTERS, + PhraseFilter, + PhrasesFilter, + Query, + TimeRange, +} from '@kbn/es-query'; +import { getTime } from '@kbn/data-plugin/common'; +import { QuickFiltersMenuItem } from '@kbn/unified-search-plugin/public/query_string_input/quick_filters'; +import { NON_SIEM_FEATURE_IDS } from '../alerts_search_bar/constants'; +import { + alertProducersData, + observabilityApps, + observabilityProducers, +} from '../alerts_table/constants'; +import { UrlSyncedAlertsSearchBar } from '../alerts_search_bar/url_synced_alerts_search_bar'; +import { ALERT_TABLE_GENERIC_CONFIG_ID } from '../../../../common'; +import { AlertTableConfigRegistry } from '../../alert_table_config_registry'; +import { loadRuleAggregations } from '../../..'; +import { useKibana } from '../../../common/lib/kibana'; +import { alertsTableQueryClient } from '../alerts_table/query_client'; +import { + alertSearchBarStateContainer, + Provider, +} from '../alerts_search_bar/use_alert_search_bar_state_container'; +const AlertsTable = lazy(() => import('../alerts_table/alerts_table_state')); + +const Stat = styled(EuiStat)` + .euiText { + line-height: 1; + } +`; + +interface StatsProps { + stats: { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; + }; + loading: boolean; + manageRulesHref: string; +} + +const Divider = styled.div` + border-right: 1px solid ${euiThemeVars.euiColorLightShade}; + height: 100%; +`; + +const SOLUTION_OR_APP_TITLE = i18n.translate( + 'xpack.triggersActionsUI.sections.globalAlerts.quickFilters.solutionOrApp', + { + defaultMessage: 'Solution/app', + } +); + +const getStatNodes = ({ stats, loading, manageRulesHref }: StatsProps) => { + const disabledStatsComponent = ( + <Stat + title={stats.disabled} + description={i18n.translate('xpack.triggersActionsUI.globalAlerts.alertStats.disabled', { + defaultMessage: 'Disabled', + })} + color="primary" + titleColor={stats.disabled > 0 ? 'primary' : ''} + titleSize="xs" + isLoading={loading} + data-test-subj="statDisabled" + /> + ); + + const snoozedStatsComponent = ( + <Stat + title={stats.muted + stats.snoozed} + description={i18n.translate('xpack.triggersActionsUI.globalAlerts.alertStats.muted', { + defaultMessage: 'Snoozed', + })} + color="primary" + titleColor={stats.muted + stats.snoozed > 0 ? 'primary' : ''} + titleSize="xs" + isLoading={loading} + data-test-subj="statMuted" + /> + ); + + const errorStatsComponent = ( + <Stat + title={stats.error} + description={i18n.translate('xpack.triggersActionsUI.globalAlerts.alertStats.errors', { + defaultMessage: 'Errors', + })} + color="primary" + titleColor={stats.error > 0 ? 'primary' : ''} + titleSize="xs" + isLoading={loading} + data-test-subj="statErrors" + /> + ); + + return [ + <Stat + title={stats.total} + description={i18n.translate('xpack.triggersActionsUI.globalAlerts.alertStats.ruleCount', { + defaultMessage: 'Rule count', + })} + color="primary" + titleSize="xs" + isLoading={loading} + data-test-subj="statRuleCount" + />, + disabledStatsComponent, + snoozedStatsComponent, + errorStatsComponent, + <Divider />, + <EuiButtonEmpty data-test-subj="manageRulesPageButton" href={manageRulesHref}> + {i18n.translate('xpack.triggersActionsUI.globalAlerts.manageRulesButtonLabel', { + defaultMessage: 'Manage Rules', + })} + </EuiButtonEmpty>, + ].reverse(); +}; + +interface BuildEsQueryArgs { + timeRange?: TimeRange; + kuery?: string; + queries?: Query[]; + config?: EsQueryConfig; + filters?: Filter[]; +} + +export function buildEsQuery({ + timeRange, + kuery, + filters = [], + queries = [], + config = {}, +}: BuildEsQueryArgs) { + const timeFilter = + timeRange && + getTime(undefined, timeRange, { + fieldName: ALERT_TIME_RANGE, + }); + const filtersToUse = [...(timeFilter ? [timeFilter] : []), ...filters]; + const kueryFilter = kuery ? [{ query: kuery, language: 'kuery' }] : []; + const queryToUse = [...kueryFilter, ...queries]; + return kbnBuildEsQuery(undefined, queryToUse, filtersToUse, config); +} + +const createMatchPhraseFilter = (field: DefaultAlertFieldName, value: unknown) => + ({ + meta: { + field, + type: FILTERS.PHRASE, + key: field, + alias: null, + disabled: false, + index: undefined, + negate: false, + params: { query: value }, + value: undefined, + }, + query: { + match_phrase: { + [field]: value, + }, + }, + } as PhraseFilter); + +const createRuleProducerFilter = (producer: AlertConsumers) => + createMatchPhraseFilter(ALERT_RULE_PRODUCER, producer); + +export const GlobalAlerts = () => { + const { + http, + chrome: { docTitle, setBreadcrumbs }, + notifications: { toasts }, + alertsTableConfigurationRegistry, + } = useKibana().services; + const [esQuery, setEsQuery] = useState({ bool: {} } as { bool: BoolQuery }); + const [statsLoading, setStatsLoading] = useState<boolean>(false); + const [featureIds, setFeatureIds] = useState(NON_SIEM_FEATURE_IDS); + const [filteringBySolution, setFilteringBySolution] = useState(false); + const [stats, setStats] = useState({ + total: 0, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }); + const manageRulesHref = http.basePath.prepend( + '/app/management/insightsAndAlerting/triggersActions/rules' + ); + const browsingSiem = useMemo( + () => featureIds.length === 1 && featureIds[0] === AlertConsumers.SIEM, + [featureIds] + ); + const quickFilters = useMemo<QuickFiltersMenuItem[]>( + () => [ + { + groupName: SOLUTION_OR_APP_TITLE, + items: [ + { + name: alertProducersData[AlertConsumers.SIEM].displayName, + icon: 'logoSecurity', + filter: createRuleProducerFilter(AlertConsumers.SIEM), + disabled: filteringBySolution && !browsingSiem, + }, + { + name: alertProducersData[AlertConsumers.OBSERVABILITY].displayName, + icon: 'logoObservability', + disabled: filteringBySolution && browsingSiem, + filter: { + meta: { + field: ALERT_RULE_PRODUCER, + type: FILTERS.PHRASES, + key: ALERT_RULE_PRODUCER, + alias: null, + disabled: false, + index: undefined, + negate: false, + params: observabilityProducers, + value: undefined, + }, + query: { + bool: { + minimum_should_match: 1, + should: observabilityProducers.map((p) => ({ + match_phrase: { + [ALERT_RULE_PRODUCER]: p, + }, + })), + }, + }, + } as PhrasesFilter, + }, + ...observabilityApps.map((oa) => { + const { displayName, icon } = alertProducersData[oa]; + return { + name: displayName, + icon, + filter: createRuleProducerFilter(oa), + disabled: filteringBySolution && browsingSiem, + }; + }), + { + name: alertProducersData[AlertConsumers.ML].displayName, + icon: 'machineLearningApp', + filter: createRuleProducerFilter('ml'), + disabled: filteringBySolution && browsingSiem, + }, + { + name: alertProducersData[AlertConsumers.STACK_ALERTS].displayName, + icon: 'managementApp', + filter: createRuleProducerFilter('stackAlerts'), + disabled: filteringBySolution && browsingSiem, + }, + ], + }, + { + groupName: i18n.translate( + 'xpack.triggersActionsUI.sections.globalAlerts.quickFilters.status', + { + defaultMessage: 'Status', + } + ), + items: [ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED, ALERT_STATUS_UNTRACKED].map((s) => ({ + name: s, + filter: createMatchPhraseFilter(ALERT_STATUS, s), + })), + }, + ], + [browsingSiem, filteringBySolution] + ); + const columns = useMemo<EuiDataGridColumn[]>(() => { + const [first, ...otherCols] = alertsTableConfigurationRegistry.get( + ALERT_TABLE_GENERIC_CONFIG_ID + ).columns; + return [ + first, + { + displayAsText: SOLUTION_OR_APP_TITLE, + id: ALERT_RULE_PRODUCER, + schema: 'string', + initialWidth: 180, + }, + ...otherCols, + ]; + }, [alertsTableConfigurationRegistry]); + + async function loadRuleStats() { + setStatsLoading(true); + try { + const response = await loadRuleAggregations({ + http, + }); + const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = + response; + if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) { + const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0); + const { disabled } = ruleEnabledStatus; + const { muted } = ruleMutedStatus; + const { error } = ruleExecutionStatus; + const { snoozed } = ruleSnoozedStatus; + setStats({ + ...stats, + total, + disabled, + muted, + error, + snoozed, + }); + } + setStatsLoading(false); + } catch (_e) { + toasts.addDanger({ + title: i18n.translate('xpack.triggersActionsUI.globalAlerts.alertStats.loadError', { + defaultMessage: 'Unable to load rule stats', + }), + }); + setStatsLoading(false); + } + } + + useEffect(() => { + loadRuleStats(); + setBreadcrumbs([ + { + text: i18n.translate('xpack.triggersActionsUI.globalAlerts.alerts', { + defaultMessage: 'Alerts', + }), + }, + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <Provider value={alertSearchBarStateContainer}> + <QueryClientProvider client={alertsTableQueryClient}> + <KibanaPageTemplate + pageHeader={{ + pageTitle: ( + <> + {i18n.translate('xpack.triggersActionsUI.globalAlerts.title', { + defaultMessage: 'Alerts', + })}{' '} + </> + ), + rightSideItems: getStatNodes({ stats, loading: statsLoading, manageRulesHref }), + }} + > + <KibanaPageTemplate.Section> + <EuiFlexGroup gutterSize="m" direction="column"> + <UrlSyncedAlertsSearchBar + appName="test" + featureIds={featureIds} + onFeatureIdsChange={setFeatureIds} + showFilterBar + quickFilters={quickFilters} + onFilteringBySolutionChange={setFilteringBySolution} + onEsQueryChange={setEsQuery} + /> + <AlertsTable + id="rule-detail-alerts-table" + configurationId={ALERT_TABLE_GENERIC_CONFIG_ID} + alertsTableConfigurationRegistry={ + alertsTableConfigurationRegistry as AlertTableConfigRegistry + } + columns={columns} + featureIds={featureIds} + query={esQuery} + showAlertStatusWithFlapping + pageSize={20} + /> + </EuiFlexGroup> + </KibanaPageTemplate.Section> + </KibanaPageTemplate> + </QueryClientProvider> + </Provider> + ); +}; + +// eslint-disable-next-line import/no-default-export +export default GlobalAlerts; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 862a6561833f0..fcd33c9630efc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -35,7 +35,6 @@ import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { ServerlessPluginStart } from '@kbn/serverless/public'; import { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; -import { getSectionsServiceStartPrivate } from '@kbn/management-plugin/public/management_sections_service'; import { getAlertsTableDefaultAlertActionsLazy } from './common/get_alerts_table_default_row_actions'; import type { AlertActionsProps } from './types'; import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar'; @@ -290,23 +289,38 @@ export class Plugin // updater$: this.appUpdater, async mount(params: AppMountParameters) { const { renderApp } = await import('./application/alerts_app'); - const [coreStart, deps] = await core.getStartServices(); + const [coreStart, pluginsStart] = (await core.getStartServices()) as [ + CoreStart, + PluginsStart, + unknown + ]; + let kibanaFeatures: KibanaFeature[]; + try { + kibanaFeatures = await pluginsStart.features.getFeatures(); + } catch (err) { + kibanaFeatures = []; + } - return renderApp(params, { - sections: getSectionsServiceStartPrivate(), - kibanaVersion, - coreStart, - setBreadcrumbs: (newBreadcrumbs) => { - if (deps.serverless) { - // drop the root management breadcrumb in serverless because it comes from the navigation tree - const [, ...trailingBreadcrumbs] = newBreadcrumbs; - deps.serverless.setBreadcrumbs(trailingBreadcrumbs); - } else { - coreStart.chrome.setBreadcrumbs(newBreadcrumbs); - } - }, - isSidebarEnabled$: managementPlugin.isSidebarEnabled$, - cardsNavigationConfig$: managementPlugin.cardsNavigationConfig$, + return renderApp({ + ...coreStart, + actions: plugins.actions, + dashboard: pluginsStart.dashboard, + data: pluginsStart.data, + dataViews: pluginsStart.dataViews, + dataViewEditor: pluginsStart.dataViewEditor, + charts: pluginsStart.charts, + alerting: pluginsStart.alerting, + spaces: pluginsStart.spaces, + unifiedSearch: pluginsStart.unifiedSearch, + isCloud: Boolean(plugins.cloud?.isCloudEnabled), + element: params.element, + theme$: params.theme$, + storage: new Storage(window.localStorage), + history: params.history, + actionTypeRegistry, + ruleTypeRegistry, + alertsTableConfigurationRegistry, + kibanaFeatures, }); }, });