From 0a8dccc19aff649728f5d7c2e4fce97f08faa3c7 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Fri, 11 Apr 2025 10:02:34 +0200 Subject: [PATCH] Alerting: New alert list filter improvements (#103107) * Move filtering code to generators for performance reasons Discarding rules and groups early in the iterable chain limits the number of promises we need to wait for which improves performance significantly * Add error handling for generators * Add support for data source filter for GMA rules * search WIP fix * Fix datasource filter * Move filtering back to filtered rules hook, use paged groups for improved performance * Add queriedDatasources field to grafana managed rules and update filtering logic to rely on it - Introduced a new field `queriedDatasources` in the AlertingRule struct to track data sources used in rules. - Updated the Prometheus API to populate `queriedDatasources` when creating alerting rules. - Modified filtering logic in the ruleFilter function to utilize the new `queriedDatasources` field for improved data source matching. - Adjusted related tests to reflect changes in rule structure and filtering behavior. * Add FilterView performance logging * Improve GMA Prometheus types, rename queried datasources property * Use custom generator helpers for flattening and filtering rule groups * Fix lint errors, add missing translations * Revert test condition * Refactor api prom changes * Fix lint errors * Update backend tests * Refactor rule list components to improve error handling and data source management - Enhanced error handling in FilterViewResults by logging errors before returning an empty iterable. - Simplified conditional rendering in GrafanaRuleLoader for better readability. - Updated data source handling in PaginatedDataSourceLoader and PaginatedGrafanaLoader to use new individual rule group generator. - Renamed toPageless function to toIndividualRuleGroups for clarity in prometheusGroupsGenerator. - Improved filtering logic in useFilteredRulesIterator to utilize a dedicated function for data source type validation. - Added isRulesDataSourceType utility function for better data source type checks. - Removed commented-out code in PromRuleDTOBase for cleaner interface definition. * Fix abort controller on FilterView * Improve generators filtering * fix abort controller * refactor cancelSearch * make states exclusive * Load full page in one loadResultPage call * Update tests, update translations * Refactor filter status into separate component * hoist hook * Use the new function for supported rules source type --------- Co-authored-by: Gilles De Mey --- .../ngalert/api/api_prometheus_test.go | 3 + .../ngalert/api/prometheus/api_prometheus.go | 29 +- .../ngalert/api/tooling/definitions/prom.go | 15 +- .../features/alerting/unified/Analytics.ts | 30 +- .../unified/PanelAlertTabContent.test.tsx | 3 - .../alerting/unified/api/alertmanagerApi.ts | 2 +- .../rules/Filter/RulesFilter.v1.tsx | 12 +- .../unified/rule-list/FilterView.test.tsx | 15 +- .../alerting/unified/rule-list/FilterView.tsx | 127 +++++---- .../unified/rule-list/FilterViewStatus.tsx | 41 +++ .../unified/rule-list/GrafanaRuleLoader.tsx | 31 ++- .../rule-list/PaginatedDataSourceLoader.tsx | 6 +- .../rule-list/PaginatedGrafanaLoader.tsx | 4 +- .../unified/rule-list/hooks/filters.test.ts | 257 ++++++++++++++++++ .../unified/rule-list/hooks/filters.ts | 144 ++++++++++ .../hooks/prometheusGroupsGenerator.ts | 23 +- .../hooks/useFilteredRulesIterator.ts | 195 ++++++------- .../hooks/usePaginatedPrometheusGroups.tsx | 2 +- .../features/alerting/unified/utils/rules.ts | 20 +- .../BrowseFolderAlertingPage.test.tsx | 2 +- .../fixtures/alertRules.fixture.ts | 10 +- .../PanelDataAlertingTab.test.tsx | 3 - .../DashboardQueryRunner.test.ts | 3 - public/app/types/unified-alerting-dto.ts | 31 ++- public/locales/en-US/grafana.json | 9 +- public/test/setupTests.ts | 42 +++ 26 files changed, 815 insertions(+), 244 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx create mode 100644 public/app/features/alerting/unified/rule-list/hooks/filters.test.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/filters.ts diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 1ba3422cecb..f1429bb01af 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -368,6 +368,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "folderUid": "namespaceUID", "uid": "RuleUID", "query": "vector(1)", + "queriedDatasourceUIDs": ["AUID"], "alerts": [{ "labels": { "job": "prometheus" @@ -433,6 +434,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "state": "inactive", "name": "AlwaysFiring", "query": "vector(1)", + "queriedDatasourceUIDs": ["AUID"], "folderUid": "namespaceUID", "uid": "RuleUID", "alerts": [{ @@ -499,6 +501,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { "state": "inactive", "name": "AlwaysFiring", "query": "vector(1) | vector(1)", + "queriedDatasourceUIDs": ["AUID", "BUID"], "folderUid": "namespaceUID", "uid": "RuleUID", "alerts": [{ diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index d130274ced0..076fa35a171 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/folder" @@ -544,13 +545,16 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe } } + queriedDatasourceUIDs := extractDatasourceUIDs(rule) + alertingRule := apimodels.AlertingRule{ - State: "inactive", - Name: rule.Title, - Query: ruleToQuery(log, rule), - Duration: rule.For.Seconds(), - KeepFiringFor: rule.KeepFiringFor.Seconds(), - Annotations: apimodels.LabelsFromMap(rule.Annotations), + State: "inactive", + Name: rule.Title, + Query: ruleToQuery(log, rule), + QueriedDatasourceUIDs: queriedDatasourceUIDs, + Duration: rule.For.Seconds(), + KeepFiringFor: rule.KeepFiringFor.Seconds(), + Annotations: apimodels.LabelsFromMap(rule.Annotations), } newRule := apimodels.Rule{ @@ -663,6 +667,19 @@ func toRuleGroup(log log.Logger, manager state.AlertInstanceManager, sr StatusRe return newGroup, rulesTotals } +// extractDatasourceUIDs extracts datasource UIDs from a rule +func extractDatasourceUIDs(rule *ngmodels.AlertRule) []string { + queriedDatasourceUIDs := make([]string, 0, len(rule.Data)) + for _, query := range rule.Data { + // Skip expression datasources (UID -100 or __expr__) + if expr.IsDataSource(query.DatasourceUID) { + continue + } + queriedDatasourceUIDs = append(queriedDatasourceUIDs, query.DatasourceUID) + } + return queriedDatasourceUIDs +} + // ruleToQuery attempts to extract the datasource queries from the alert query model. // Returns the whole JSON model as a string if it fails to extract a minimum of 1 query. func ruleToQuery(logger log.Logger, rule *ngmodels.AlertRule) string { diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index 108d5e3cf0b..20130c9f331 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -152,9 +152,10 @@ type AlertingRule struct { // required: true Name string `json:"name,omitempty"` // required: true - Query string `json:"query,omitempty"` - Duration float64 `json:"duration,omitempty"` - KeepFiringFor float64 `json:"keepFiringFor,omitempty"` + Query string `json:"query,omitempty"` + QueriedDatasourceUIDs []string `json:"queriedDatasourceUIDs,omitempty"` + Duration float64 `json:"duration,omitempty"` + KeepFiringFor float64 `json:"keepFiringFor,omitempty"` // required: true Annotations promlabels.Labels `json:"annotations,omitempty"` // required: true @@ -168,12 +169,10 @@ type AlertingRule struct { // adapted from cortex // swagger:model type Rule struct { + UID string `json:"uid,omitempty"` // required: true - UID string `json:"uid"` - // required: true - Name string `json:"name"` - // required: true - FolderUID string `json:"folderUid"` + Name string `json:"name"` + FolderUID string `json:"folderUid,omitempty"` // required: true Query string `json:"query"` Labels promlabels.Labels `json:"labels,omitempty"` diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index e5f0ce7e597..ee7ba077400 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -34,23 +34,27 @@ const { logInfo, logError, logMeasurement, logWarning } = createMonitoringLogger export { logError, logInfo, logMeasurement, logWarning }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withPerformanceLogging Promise>( - type: string, - func: TFunc, - context: Record -): (...args: Parameters) => Promise>> { - return async function (...args) { - const startLoadingTs = performance.now(); +/** + * Utility function to measure performance of async operations + * @param func Function to measure + * @param measurementName Name of the measurement for logging + * @param context Context for logging + */ +export function withPerformanceLogging( + func: (...args: TArgs) => Promise, + measurementName: string, + context: Record = {} +): (...args: TArgs) => Promise { + return async function (...args: TArgs): Promise { + const startMark = `${measurementName}:start`; + performance.mark(startMark); const response = await func(...args); - const loadTimesMs = performance.now() - startLoadingTs; + const loadTimeMeasure = performance.measure(measurementName, startMark); logMeasurement( - type, - { - loadTimesMs, - }, + measurementName, + { duration: loadTimeMeasure.duration, loadTimesMs: loadTimeMeasure.duration }, context ); diff --git a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx index d48f5de0341..9e01d77d060 100644 --- a/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx +++ b/public/app/features/alerting/unified/PanelAlertTabContent.test.tsx @@ -117,9 +117,6 @@ const promResponse: PromRulesResponse = { interval: 20, }, ], - totals: { - alerting: 2, - }, }, }; const rulerResponse = { diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index a6d1a718d83..0d57ea8b9ee 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -197,8 +197,8 @@ export const alertmanagerApi = alertingApi.injectEndpoints({ // wrap our fetchConfig function with some performance logging functions const fetchAMconfigWithLogging = withPerformanceLogging( - 'unifiedalerting/fetchAmConfig', fetchAlertManagerConfig, + 'unifiedalerting/fetchAmConfig', { dataSourceName: alertmanagerSourceName, thunk: 'unifiedalerting/fetchAmConfig', diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index f2848617a65..d2039aa1d96 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -18,6 +18,7 @@ import { trackRulesSearchComponentInteraction, trackRulesSearchInputInteraction, } from '../../../Analytics'; +import { shouldUseAlertingListViewV2 } from '../../../featureToggles'; import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; import { RuleHealth } from '../../../search/rulesSearchParser'; @@ -40,6 +41,13 @@ const RuleHealthOptions: SelectableValue[] = [ { label: 'Error', value: RuleHealth.Error }, ]; +// Contact point selector is not supported in Alerting ListView V2 yet +const canRenderContactPointSelector = + (contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead) && + config.featureToggles.alertingSimplifiedRouting && + shouldUseAlertingListViewV2() === false) ?? + false; + interface RulesFilerProps { onClear?: () => void; } @@ -122,10 +130,6 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => { trackRulesSearchComponentInteraction('contactPoint'); }; - const canRenderContactPointSelector = - (contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead) && - config.featureToggles.alertingSimplifiedRouting) ?? - false; const searchIcon = ; return ( diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index 307954bf189..b9120e57eb1 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -40,17 +40,18 @@ beforeEach(() => { const io = mockIntersectionObserver(); describe('RuleList - FilterView', () => { - jest.setTimeout(60 * 1000); - jest.retryTimes(2); - it('should render multiple pages of results', async () => { render(); await loadMoreResults(); - expect(await screen.findAllByRole('treeitem')).toHaveLength(100); + const onePageResults = await screen.findAllByRole('treeitem'); + // FilterView loads rules in batches so it can load more than 100 rules for one page + expect(onePageResults.length).toBeGreaterThanOrEqual(100); await loadMoreResults(); - expect(await screen.findAllByRole('treeitem')).toHaveLength(200); + const twoPageResults = await screen.findAllByRole('treeitem'); + expect(twoPageResults.length).toBeGreaterThanOrEqual(200); + expect(twoPageResults.length).toBeGreaterThan(onePageResults.length); }); it('should filter results by group and rule name ', async () => { @@ -89,7 +90,7 @@ describe('RuleList - FilterView', () => { expect(matchingPrometheusRule).toBeInTheDocument(); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); - }, 90000); + }); it('should display empty state when no rules are found', async () => { render(); @@ -104,7 +105,7 @@ async function loadMoreResults() { act(() => { io.enterNode(screen.getByTestId('load-more-helper')); }); - await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 80000 }); + await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader')); } function getFilter(overrides: Partial = {}): RulesFilter { diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 900106ff86d..1e7253457b9 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -1,15 +1,17 @@ -import { empty } from 'ix/asynciterable'; -import { catchError, take, tap, withAbort } from 'ix/asynciterable/operators'; -import { useEffect, useRef, useState, useTransition } from 'react'; +import { bufferCountOrTime, tap } from 'ix/asynciterable/operators'; +import { useCallback, useMemo, useRef, useState, useTransition } from 'react'; +import { useUnmount } from 'react-use'; -import { Card, EmptyState, Stack, Text } from '@grafana/ui'; +import { EmptyState, Stack } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; +import { withPerformanceLogging } from '../Analytics'; import { isLoading, useAsync } from '../hooks/useAsync'; import { RulesFilter } from '../search/rulesSearchParser'; import { hashRule } from '../utils/rule-id'; import { DataSourceRuleLoader } from './DataSourceRuleLoader'; +import { FilterProgressState, FilterStatus } from './FilterViewStatus'; import { GrafanaRuleLoader } from './GrafanaRuleLoader'; import LoadMoreHelper from './LoadMoreHelper'; import { UnknownRuleListItem } from './components/AlertRuleListItem'; @@ -54,54 +56,85 @@ function FilterViewResults({ filterState }: FilterViewProps) { const [transitionPending, startTransition] = useTransition(); /* this hook returns a function that creates an AsyncIterable which we will use to populate the front-end */ - const { getFilteredRulesIterator } = useFilteredRulesIteratorProvider(); + const getFilteredRulesIterator = useFilteredRulesIteratorProvider(); - /* this is the abort controller that allows us to stop an AsyncIterable */ - const controller = useRef(new AbortController()); - - /** - * This an iterator that we can use to populate the search results. - * It also uses the signal from the AbortController above to cancel retrieving more results and sets up a - * callback function to detect when we've exhausted the source. - * This is the main AsyncIterable we will use for the search results */ - const rulesIterator = useRef( - getFilteredRulesIterator(filterState, API_PAGE_SIZE).pipe( - withAbort(controller.current.signal), - onFinished(() => setDoneSearching(true)) - ) - ); + const iteration = useRef<{ + rulesBatchIterator: AsyncIterator; + abortController: AbortController; + } | null>(null); const [rules, setRules] = useState([]); const [doneSearching, setDoneSearching] = useState(false); - /* This function will fetch a page of results from the iterable */ - const [{ execute: loadResultPage }, state] = useAsync(async () => { - for await (const rule of rulesIterator.current.pipe( - // grab from the rules iterable - take(FRONTENT_PAGE_SIZE), - // if an error occurs trying to fetch a page, return an empty iterable so the front-end isn't caught in an infinite loop - catchError(() => empty()) - )) { - startTransition(() => { - // Rule key could be computed on the fly, but we do it here to avoid recalculating it with each render - // It's a not trivial computation because it involves hashing the rule - setRules((rules) => rules.concat({ key: getRuleKey(rule), ...rule })); - }); + // Lazy initialization of useRef + // https://18.react.dev/reference/react/useRef#how-to-avoid-null-checks-when-initializing-use-ref-later + const getRulesBatchIterator = useCallback(() => { + if (!iteration.current) { + /** + * This an iterator that we can use to populate the search results. + * It also uses the signal from the AbortController above to cancel retrieving more results and sets up a + * callback function to detect when we've exhausted the source. + * This is the main AsyncIterable we will use for the search results + * + * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint + * and the iterator will allow us to exhaust the iterable in a stateful way + */ + const { iterable, abortController } = getFilteredRulesIterator(filterState, API_PAGE_SIZE); + const rulesBatchIterator = iterable + .pipe( + bufferCountOrTime(FRONTENT_PAGE_SIZE, 1000), + onFinished(() => setDoneSearching(true)) + ) + [Symbol.asyncIterator](); + iteration.current = { rulesBatchIterator: rulesBatchIterator, abortController }; } - }); - - /* When we unmount the component we make sure to abort all iterables */ - useEffect(() => { - const currentAbortController = controller.current; + return iteration.current.rulesBatchIterator; + }, [filterState, getFilteredRulesIterator]); - return () => { - currentAbortController.abort(); - }; - }, [controller]); + /* This function will fetch a page of results from the iterable */ + const [{ execute: loadResultPage }, state] = useAsync( + withPerformanceLogging(async () => { + const rulesIterator = getRulesBatchIterator(); + + let loadedRulesCount = 0; + + while (loadedRulesCount < FRONTENT_PAGE_SIZE) { + const nextRulesBatch = await rulesIterator.next(); + if (nextRulesBatch.done) { + return; + } + if (nextRulesBatch.value) { + startTransition(() => { + setRules((rules) => rules.concat(nextRulesBatch.value.map((rule) => ({ key: getRuleKey(rule), ...rule })))); + }); + } + loadedRulesCount += nextRulesBatch.value.length; + } + }, 'alerting.rule-list.filter-view.load-result-page') + ); const loading = isLoading(state) || transitionPending; const numberOfRules = rules.length; const noRulesFound = numberOfRules === 0 && !loading; + const loadingAborted = iteration.current?.abortController.signal.aborted; + const cancelSearch = useCallback(() => { + iteration.current?.abortController.abort(); + }, []); + + /* When we unmount the component we make sure to abort all iterables and stop making HTTP requests */ + useUnmount(() => { + cancelSearch(); + }); + + // track the state of the filter progress, which is either searching, done or aborted + const filterProgressState = useMemo(() => { + if (loadingAborted) { + return 'aborted'; + } else if (doneSearching) { + return 'done'; + } + return 'searching'; + }, [doneSearching, loadingAborted]); /* If we don't have any rules and have exhausted all sources, show a EmptyState */ if (noRulesFound && doneSearching) { @@ -150,16 +183,10 @@ function FilterViewResults({ filterState }: FilterViewProps) { )} - {doneSearching && !noRulesFound && ( - - - - No more results – showing {{ numberOfRules }} rules - - - + {!noRulesFound && ( + )} - {!doneSearching && !loading && } + {!doneSearching && !loading && !loadingAborted && } ); } diff --git a/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx b/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx new file mode 100644 index 00000000000..c72a5a72ce8 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx @@ -0,0 +1,41 @@ +import { Button, Card, Text } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; + +export type FilterProgressState = 'searching' | 'done' | 'aborted'; +interface FilterStatusProps { + numberOfRules: number; + state: FilterProgressState; + onCancel: () => void; +} + +export function FilterStatus({ state, numberOfRules, onCancel }: FilterStatusProps) { + return ( + + + {/* done searching everything and found some results */} + {state === 'done' && ( + + No more results – found {{ numberOfRules }} rules + + )} + {/* user has cancelled the search */} + {state === 'aborted' && ( + + Search cancelled – found {{ numberOfRules }} rules + + )} + {/* search is in progress */} + {state === 'searching' && ( + + Searching – found {{ numberOfRules }} rules + + )} + + {state === 'searching' && ( + + )} + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx index 7444e303fb8..5cb1b7ebf83 100644 --- a/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx @@ -1,3 +1,5 @@ +import { Alert } from '@grafana/ui'; +import { Trans, t } from 'app/core/internationalization'; import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; import { GrafanaPromRuleDTO, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; @@ -26,21 +28,40 @@ interface GrafanaRuleLoaderProps { } export function GrafanaRuleLoader({ rule, groupIdentifier, namespaceName }: GrafanaRuleLoaderProps) { - const { data: rulerRuleGroup, isError } = useGetGrafanaRulerGroupQuery({ + const { + data: rulerRuleGroup, + isError, + isLoading, + } = useGetGrafanaRulerGroupQuery({ folderUid: groupIdentifier.namespace.uid, groupName: groupIdentifier.groupName, }); const rulerRule = rulerRuleGroup?.rules.find((rulerRule) => rulerRule.grafana_alert.uid === rule.uid); - if (!rulerRule) { - if (isError) { - return ; - } + if (isError) { + return ; + } + if (isLoading) { return ; } + if (!rulerRule) { + return ( + + + Cannot find rule details for {{ uid: rule.uid ?? '' }} + + + ); + } + return ( { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 59ecd1f31ac..755937c2773 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -14,7 +14,7 @@ import { LazyPagination } from './components/LazyPagination'; import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu'; -import { useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; +import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups'; const GRAFANA_GROUP_PAGE_SIZE = 40; @@ -22,7 +22,7 @@ const GRAFANA_GROUP_PAGE_SIZE = 40; export function PaginatedGrafanaLoader() { const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true }); - const groupsGenerator = useRef(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE)); + const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE))); useEffect(() => { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts new file mode 100644 index 00000000000..18e4af4f40f --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts @@ -0,0 +1,257 @@ +import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; +import { RuleHealth } from '../../search/rulesSearchParser'; +import { Annotation } from '../../utils/constants'; +import * as datasourceUtils from '../../utils/datasource'; +import { getFilter } from '../../utils/search'; + +import { groupFilter, ruleFilter } from './filters'; + +describe('groupFilter', () => { + it('should filter by namespace (file path)', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({ namespace: 'production' }))).toBe(true); + expect(groupFilter(group, getFilter({ namespace: 'staging' }))).toBe(false); + }); + + it('should filter by group name', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({ groupName: 'cpu' }))).toBe(true); + expect(groupFilter(group, getFilter({ groupName: 'memory' }))).toBe(false); + }); + + it('should return true when no filters are applied', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + expect(groupFilter(group, getFilter({}))).toBe(true); + }); +}); + +describe('ruleFilter', () => { + it('should filter by free form words in rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false); + }); + + it('should filter by rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }))).toBe(true); + expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false); + }); + + it('should filter by labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + expect(ruleFilter(rule, getFilter({ labels: ['severity=critical'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['severity=warning'] }))).toBe(false); + expect(ruleFilter(rule, getFilter({ labels: ['team=ops'] }))).toBe(true); + }); + + it('should filter by alert instance labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical' }, + alerts: [ + { + labels: { instance: 'server-1', env: 'production' }, + state: PromAlertingRuleState.Firing, + value: '100', + activeAt: '', + annotations: {}, + }, + ], + }); + + expect(ruleFilter(rule, getFilter({ labels: ['instance=server-1'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['env=production'] }))).toBe(true); + expect(ruleFilter(rule, getFilter({ labels: ['instance=server-2'] }))).toBe(false); + }); + + it('should filter by rule type', () => { + const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + expect(ruleFilter(alertingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Recording }))).toBe(true); + expect(ruleFilter(recordingRule, getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + }); + + it('should filter by rule state', () => { + const firingRule = mockPromAlertingRule({ + name: 'Firing Alert', + state: PromAlertingRuleState.Firing, + }); + + const pendingRule = mockPromAlertingRule({ + name: 'Pending Alert', + state: PromAlertingRuleState.Pending, + }); + + expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(true); + expect(ruleFilter(firingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); + expect(ruleFilter(pendingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(true); + }); + + it('should filter out recording rules when filtering by rule state', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + }); + + // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Pending }))).toBe(false); + expect(ruleFilter(recordingRule, getFilter({ ruleState: PromAlertingRuleState.Inactive }))).toBe(false); + }); + + it('should filter by rule health', () => { + const healthyRule = mockPromAlertingRule({ + name: 'Healthy Rule', + health: RuleHealth.Ok, + }); + + const errorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: RuleHealth.Error, + }); + + expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(true); + expect(ruleFilter(healthyRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(false); + expect(ruleFilter(errorRule, getFilter({ ruleHealth: RuleHealth.Error }))).toBe(true); + }); + + it('should filter by dashboard UID', () => { + const ruleDashboardA = mockPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-a' }))).toBe(true); + expect(ruleFilter(ruleDashboardA, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(false); + expect(ruleFilter(ruleDashboardB, getFilter({ dashboardUid: 'dashboard-b' }))).toBe(true); + }); + + it('should filter out recording rules when filtering by dashboard UID', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + // Recording rules cannot have dashboard UIDs because they don't have annotations + }); + + // Dashboard UID filter should filter out recording rules + expect(ruleFilter(recordingRule, getFilter({ dashboardUid: 'any-dashboard' }))).toBe(false); + }); + + describe('dataSourceNames filter', () => { + let getDataSourceUIDSpy: jest.SpyInstance; + + beforeEach(() => { + getDataSourceUIDSpy = jest.spyOn(datasourceUtils, 'getDatasourceAPIUid').mockImplementation((ruleSourceName) => { + if (ruleSourceName === 'prometheus') { + return 'datasource-uid-1'; + } + if (ruleSourceName === 'loki') { + return 'datasource-uid-3'; + } + throw new Error(`Unknown datasource name: ${ruleSourceName}`); + }); + }); + + afterEach(() => { + // Clean up + getDataSourceUIDSpy.mockRestore(); + }); + + it('should match rules that use the filtered datasource', () => { + // Create a Grafana rule with matching datasource + const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1'], + }); + + // 'prometheus' resolves to 'datasource-uid-1' which is in the rule + expect(ruleFilter(ruleWithMatchingDatasource, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + }); + + it("should filter out rules that don't use the filtered datasource", () => { + // Create a Grafana rule without the target datasource + const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + }); + + // 'loki' resolves to 'datasource-uid-3' which is not in the rule + expect(ruleFilter(ruleWithoutMatchingDatasource, getFilter({ dataSourceNames: ['loki'] }))).toBe(false); + }); + + it('should return false when there is an error parsing the query', () => { + const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ + query: 'not-valid-json', + }); + + expect(ruleFilter(ruleWithInvalidQuery, getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); + }); + }); + + it('should combine multiple filters with AND logic', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=critical', 'environment=production'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + expect(ruleFilter(rule, filter)).toBe(true); + }); + + it('should return false if any filter does not match', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + alerts: [], + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=warning'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + expect(ruleFilter(rule, filter)).toBe(false); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts new file mode 100644 index 00000000000..89eb4c96f6b --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -0,0 +1,144 @@ +import { attempt, compact, isString } from 'lodash'; +import memoize from 'micro-memoize'; + +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; +import { labelsMatchMatchers } from '../../utils/alertmanager'; +import { Annotation } from '../../utils/constants'; +import { getDatasourceAPIUid } from '../../utils/datasource'; +import { parseMatcher } from '../../utils/matchers'; +import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; + +/** + * @returns True if the group matches the filter, false otherwise. Keeps rules intact + */ +export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { + const { name, file } = group; + + // Add fuzzy search for namespace + if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { + return false; + } + + // Add fuzzy search for group name + if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { + return false; + } + + return true; +} + +/** + * @returns True if the rule matches the filter, false otherwise + */ +export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { + const { name, labels = {}, health, type } = rule; + + const nameLower = name.toLowerCase(); + + // Free form words filter (matches if any word is part of the rule name) + if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { + return false; + } + + // Rule name filter (exact match) + if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { + return false; + } + + // Labels filter + if (filterState.labels.length > 0) { + const matchers = compact(filterState.labels.map(looseParseMatcher)); + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); + + // Also check alerts if they exist + const doAlertsContainMatchingLabels = + matchers.length > 0 && + prometheusRuleType.alertingRule(rule) && + rule.alerts && + rule.alerts.some((alert) => labelsMatchMatchers(alert.labels || {}, matchers)); + + if (!doRuleLabelsMatchQuery && !doAlertsContainMatchingLabels) { + return false; + } + } + + // Rule type filter + if (filterState.ruleType && type !== filterState.ruleType) { + return false; + } + + // Rule state filter (for alerting rules only) + if (filterState.ruleState) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + if (rule.state !== filterState.ruleState) { + return false; + } + } + + // Rule health filter + if (filterState.ruleHealth && health !== filterState.ruleHealth) { + return false; + } + + // Dashboard UID filter + if (filterState.dashboardUid) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + + const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; + if (dashboardAnnotation !== filterState.dashboardUid) { + return false; + } + } + + // Plugins filter - hide plugin-provided rules when set to 'hide' + if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { + return false; + } + + // Note: We can't implement these filters from reduceGroups because they rely on rulerRule property + // which is not available in PromRuleDTO: + // - contactPoint filter + // - dataSourceNames filter + if (filterState.dataSourceNames.length > 0) { + const isGrafanaRule = prometheusRuleType.grafana.rule(rule); + if (isGrafanaRule) { + try { + const filterDatasourceUids = mapDataSourceNamesToUids(filterState.dataSourceNames); + const queriedDatasourceUids = rule.queriedDatasourceUIDs || []; + + const queryIncludesDataSource = queriedDatasourceUids.some((uid) => filterDatasourceUids.includes(uid)); + if (!queryIncludesDataSource) { + return false; + } + } catch (error) { + return false; + } + } + } + + return true; +} + +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times +const mapDataSourceNamesToUids = memoize( + (names: string[]): string[] => { + return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString); + }, + { maxSize: 1 } +); diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index 39339caa91e..2087e31c1c4 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useDispatch } from 'app/types/store'; import { DataSourceRulesSourceIdentifier } from 'app/types/unified-alerting'; +import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { alertRuleApi } from '../../api/alertRuleApi'; import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi'; @@ -95,6 +96,23 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = ); } +/** + * Converts a Prometheus groups generator yielding arrays of groups to a generator yielding groups one by one + * @param generator - The paginated generator to convert + * @returns A non-paginated generator that yields all groups from the original generator one by one + */ +export function toIndividualRuleGroups( + generator: AsyncGenerator +): AsyncGenerator { + return (async function* () { + for await (const batch of generator) { + for (const item of batch) { + yield item; + } + } + })(); +} + // Generator lazily provides groups one by one only when needed // This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources // For unpaginated data sources we fetch everything in one go @@ -104,14 +122,13 @@ async function* genericGroupsGenerator( groupLimit: number ) { let response = await fetchGroups({ groupLimit }); - yield* response.data.groups; + yield response.data.groups; let lastToken: string | undefined = response.data?.groupNextToken; while (lastToken) { response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit }); - - yield* response.data.groups; + yield response.data.groups; lastToken = response.data?.groupNextToken; } } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 8c494c0545a..50be4d9054d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -1,9 +1,8 @@ import { AsyncIterableX, empty, from } from 'ix/asynciterable'; import { merge } from 'ix/asynciterable/merge'; -import { catchError, filter, flatMap, map } from 'ix/asynciterable/operators'; -import { compact } from 'lodash'; +import { catchError, concatMap, withAbort } from 'ix/asynciterable/operators'; +import { isEmpty } from 'lodash'; -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, @@ -17,12 +16,14 @@ import { } from 'app/types/unified-alerting-dto'; import { RulesFilter } from '../../search/rulesSearchParser'; -import { labelsMatchMatchers } from '../../utils/alertmanager'; -import { Annotation } from '../../utils/constants'; -import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource'; -import { parseMatcher } from '../../utils/matchers'; -import { prometheusRuleType } from '../../utils/rules'; +import { + getDataSourceByUid, + getDatasourceAPIUid, + getExternalRulesSources, + isSupportedExternalRulesSourceType, +} from '../../utils/datasource'; +import { groupFilter, ruleFilter } from './filters'; import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -44,54 +45,97 @@ export interface PromRuleWithOrigin { origin: 'datasource'; } +interface GetIteratorResult { + iterable: AsyncIterableX; + abortController: AbortController; +} + export function useFilteredRulesIteratorProvider() { const allExternalRulesSources = getExternalRulesSources(); const prometheusGroupsGenerator = usePrometheusGroupsGenerator(); const grafanaGroupsGenerator = useGrafanaGroupsGenerator(); - const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number): AsyncIterableX => { + const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => { + /* this is the abort controller that allows us to stop an AsyncIterable */ + const abortController = new AbortController(); + const normalizedFilterState = normalizeFilterState(filterState); + const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length); + + const grafanaRulesGenerator = from(grafanaGroupsGenerator(groupLimit)).pipe( + withAbort(abortController.signal), + concatMap((groups) => + groups + .filter((group) => groupFilter(group, normalizedFilterState)) + .flatMap((group) => group.rules.map((rule) => [group, rule] as const)) + .filter(([, rule]) => ruleFilter(rule, normalizedFilterState)) + .map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)) + ), + catchError(() => empty()) + ); - const ruleSourcesToFetchFrom = filterState.dataSourceNames.length - ? filterState.dataSourceNames.map((ds) => ({ - name: ds, - uid: getDatasourceAPIUid(ds), - ruleSourceType: 'datasource', - })) + // Determine which data sources to use + const externalRulesSourcesToFetchFrom = hasDataSourceFilterActive + ? getRulesSourcesFromFilter(filterState) : allExternalRulesSources; - const grafanaIterator = from(grafanaGroupsGenerator(groupLimit)).pipe( - filter((group) => groupFilter(group, normalizedFilterState)), - flatMap((group) => group.rules.map((rule) => [group, rule] as const)), - filter(([_, rule]) => ruleFilter(rule, normalizedFilterState)), - map(([group, rule]) => mapGrafanaRuleToRuleWithOrigin(group, rule)), - catchError(() => empty()) - ); + // If no data sources, just return Grafana rules + if (isEmpty(externalRulesSourcesToFetchFrom)) { + return { iterable: grafanaRulesGenerator, abortController }; + } - const sourceIterables = ruleSourcesToFetchFrom.map((ds) => { - const generator = prometheusGroupsGenerator(ds, groupLimit); - return from(generator).pipe( - map((group) => [ds, group] as const), + // Create a generator for each data source + const dataSourceGenerators = externalRulesSourcesToFetchFrom.map((dataSourceIdentifier) => { + const promGroupsGenerator = from(prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)).pipe( + withAbort(abortController.signal), + concatMap((groups) => + groups + .filter((group) => groupFilter(group, normalizedFilterState)) + .flatMap((group) => group.rules.map((rule) => [group, rule] as const)) + .filter(([, rule]) => ruleFilter(rule, normalizedFilterState)) + .map(([group, rule]) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule)) + ), catchError(() => empty()) ); - }); - // if we have no prometheus data sources, use an empty async iterable - const source = sourceIterables.at(0) ?? empty(); - const otherIterables = sourceIterables.slice(1); - - const dataSourcesIterator = merge(source, ...otherIterables).pipe( - filter(([_, group]) => groupFilter(group, normalizedFilterState)), - flatMap(([rulesSource, group]) => group.rules.map((rule) => [rulesSource, group, rule] as const)), - filter(([_, __, rule]) => ruleFilter(rule, filterState)), - map(([rulesSource, group, rule]) => mapRuleToRuleWithOrigin(rulesSource, group, rule)) - ); + return promGroupsGenerator; + }); - return merge(grafanaIterator, dataSourcesIterator); + // Merge all generators + return { + iterable: merge(grafanaRulesGenerator, ...dataSourceGenerators), + abortController, + }; }; - return { getFilteredRulesIterator }; + return getFilteredRulesIterable; +} + +/** + * Finds all data sources that the user might want to filter by. + * Only allows Prometheus and Loki data source types. + */ +function getRulesSourcesFromFilter(filter: RulesFilter): DataSourceRulesSourceIdentifier[] { + return filter.dataSourceNames.reduce((acc, dataSourceName) => { + // since "getDatasourceAPIUid" can throw we'll omit any non-existing data sources + try { + const uid = getDatasourceAPIUid(dataSourceName); + const type = getDataSourceByUid(uid)?.type; + + if (type === undefined || isSupportedExternalRulesSourceType(type) === false) { + return acc; + } + + acc.push({ + name: dataSourceName, + uid, + ruleSourceType: 'datasource', + }); + } catch {} + + return acc; + }, []); } function mapRuleToRuleWithOrigin( @@ -127,70 +171,6 @@ function mapGrafanaRuleToRuleWithOrigin( }; } -/** - * Returns a new group with only the rules that match the filter. - * @returns A new group with filtered rules, or undefined if the group does not match the filter or all rules are filtered out. - */ -function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { - const { name, file } = group; - - // TODO Add fuzzy filtering or not - if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { - return false; - } - - if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { - return false; - } - - return true; -} - -function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { - const { name, labels = {}, health, type } = rule; - - const nameLower = name.toLowerCase(); - - if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { - return false; - } - - if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { - return false; - } - - if (filterState.labels.length > 0) { - const matchers = compact(filterState.labels.map(looseParseMatcher)); - const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers); - if (!doRuleLabelsMatchQuery) { - return false; - } - } - - if (filterState.ruleType && type !== filterState.ruleType) { - return false; - } - - if (filterState.ruleState) { - if (!prometheusRuleType.alertingRule(rule)) { - return false; - } - if (rule.state !== filterState.ruleState) { - return false; - } - } - - if (filterState.ruleHealth && health !== filterState.ruleHealth) { - return false; - } - - if (filterState.dashboardUid) { - return rule.labels ? rule.labels[Annotation.dashboardUID] === filterState.dashboardUid : false; - } - - return true; -} - /** * Lowercase free form words, rule name, group name and namespace */ @@ -203,12 +183,3 @@ function normalizeFilterState(filterState: RulesFilter): RulesFilter { namespace: filterState.namespace?.toLowerCase(), }; } - -function looseParseMatcher(matcherQuery: string): Matcher | undefined { - try { - return parseMatcher(matcherQuery); - } catch { - // Try to createa a matcher than matches all values for a given key - return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; - } -} diff --git a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx index 81e269396f4..18da7186fb5 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx +++ b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx @@ -14,7 +14,7 @@ import { isLoading, useAsync } from '../../hooks/useAsync'; * @returns Pagination state and controls for navigating through rule groups */ export function usePaginatedPrometheusGroups( - groupsGenerator: AsyncGenerator, + groupsGenerator: AsyncIterator, pageSize: number ) { const [currentPage, setCurrentPage] = useState(1); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index c0eb608e2d1..88bc70a2cb8 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -26,9 +26,12 @@ import { GrafanaAlertState, GrafanaAlertStateWithReason, GrafanaAlertingRuleDefinition, + GrafanaPromAlertingRuleDTO, + GrafanaPromRecordingRuleDTO, GrafanaRecordingRuleDefinition, PostableRuleDTO, PromAlertingRuleState, + PromRuleDTO, PromRuleType, RulerAlertingRuleDTO, RulerCloudRuleDTO, @@ -97,6 +100,14 @@ function isRecordingRule(rule?: Rule): rule is RecordingRule { return typeof rule === 'object' && rule.type === PromRuleType.Recording; } +function isGrafanaPromAlertingRule(rule?: Rule): rule is GrafanaPromAlertingRuleDTO { + return isAlertingRule(rule) && 'folderUid' in rule && 'uid' in rule; +} + +function isGrafanaPromRecordingRule(rule?: Rule): rule is GrafanaPromRecordingRuleDTO { + return isRecordingRule(rule) && 'folderUid' in rule && 'uid' in rule; +} + export const rulerRuleType = { grafana: { rule: isGrafanaRulerRule, @@ -118,6 +129,11 @@ export const prometheusRuleType = { rule: (rule?: Rule) => isAlertingRule(rule) || isRecordingRule(rule), alertingRule: isAlertingRule, recordingRule: isRecordingRule, + grafana: { + rule: (rule?: Rule) => isGrafanaPromAlertingRule(rule) || isGrafanaPromRecordingRule(rule), + alertingRule: isGrafanaPromAlertingRule, + recordingRule: isGrafanaPromRecordingRule, + }, }; export function alertInstanceKey(alert: Alert): string { @@ -212,7 +228,7 @@ export interface RulePluginOrigin { pluginId: string; } -export function getRulePluginOrigin(rule?: Rule | RulerRuleDTO): RulePluginOrigin | undefined { +export function getRulePluginOrigin(rule?: Rule | PromRuleDTO | RulerRuleDTO): RulePluginOrigin | undefined { if (!rule) { return undefined; } @@ -245,7 +261,7 @@ export function isPluginProvidedGroup(group: RulerRuleGroupDTO): boolean { return group.rules.some((rule) => isPluginProvidedRule(rule)); } -export function isPluginProvidedRule(rule?: Rule | RulerRuleDTO): boolean { +export function isPluginProvidedRule(rule?: Rule | PromRuleDTO | RulerRuleDTO): boolean { return Boolean(getRulePluginOrigin(rule)); } diff --git a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx index d730e70ebc4..727793d04d2 100644 --- a/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx @@ -34,7 +34,7 @@ const mockFolderUid = '12345'; const random = Chance(1); const rule_uid = random.guid(); const mockRulerRulesResponse = getRulerRulesResponse(mockFolderName, mockFolderUid, rule_uid); -const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, rule_uid); +const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, mockFolderUid, rule_uid); describe('browse-dashboards BrowseFolderAlertingPage', () => { (useParams as jest.Mock).mockReturnValue({ uid: mockFolderUid }); diff --git a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts index fca05a8c63f..be447f4fa6c 100644 --- a/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts @@ -2,8 +2,8 @@ import { Chance } from 'chance'; import { GrafanaAlertStateDecision, + GrafanaPromRulesResponse, PromAlertingRuleState, - PromRulesResponse, PromRuleType, RulerRulesConfigDTO, } from 'app/types/unified-alerting-dto'; @@ -57,7 +57,11 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, rul }; } -export function getPrometheusRulesResponse(folderName: string, rule_uid: string): PromRulesResponse { +export function getPrometheusRulesResponse( + folderName: string, + folderUid: string, + rule_uid: string +): GrafanaPromRulesResponse { const random = Chance(1); return { status: 'success', @@ -66,6 +70,7 @@ export function getPrometheusRulesResponse(folderName: string, rule_uid: string) { name: 'foo', file: folderName, + folderUid: folderUid, rules: [ { alerts: [], @@ -80,6 +85,7 @@ export function getPrometheusRulesResponse(folderName: string, rule_uid: string) lastEvaluation: '0001-01-01T00:00:00Z', evaluationTime: 0, uid: rule_uid, + folderUid: folderUid, }, ], interval: 60, diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c7a1d1fec86..fb0d3eb0524 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -134,9 +134,6 @@ const promResponse: PromRulesResponse = { interval: 20, }, ], - totals: { - alerting: 2, - }, }, }; diff --git a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts index cc4ae72f2cd..3c07c21bf5c 100644 --- a/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts +++ b/public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts @@ -115,9 +115,6 @@ function getTestContext() { file: 'my-namespace', }, ], - totals: { - alerting: 2, - }, }, }; diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 14e350d26b6..c96d35b3736 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -124,7 +124,12 @@ interface PromRuleDTOBase { evaluationTime?: number; lastEvaluation?: string; lastError?: string; - uid?: string; +} + +interface GrafanaPromRuleDTOBase extends PromRuleDTOBase { + uid: string; + folderUid: string; + queriedDatasourceUIDs?: string[]; } export interface PromAlertingRuleDTO extends PromRuleDTOBase { @@ -162,15 +167,10 @@ export interface PromRuleGroupDTO { lastEvaluation?: string; } -export interface GrafanaPromAlertingRuleDTO extends PromAlertingRuleDTO { - uid: string; - folderUid: string; -} +export interface GrafanaPromAlertingRuleDTO extends GrafanaPromRuleDTOBase, PromAlertingRuleDTO {} + +export interface GrafanaPromRecordingRuleDTO extends GrafanaPromRuleDTOBase, PromRecordingRuleDTO {} -export interface GrafanaPromRecordingRuleDTO extends PromRecordingRuleDTO { - uid: string; - folderUid: string; -} export type GrafanaPromRuleDTO = GrafanaPromAlertingRuleDTO | GrafanaPromRecordingRuleDTO; export interface GrafanaPromRuleGroupDTO extends PromRuleGroupDTO { @@ -185,11 +185,14 @@ export interface PromResponse { warnings?: string[]; } -export type PromRulesResponse = PromResponse<{ - groups: PromRuleGroupDTO[]; - groupNextToken?: string; - totals?: AlertGroupTotals; -}>; +export interface PromRulesResponse extends PromResponse<{ groups: PromRuleGroupDTO[]; groupNextToken?: string }> {} + +export interface GrafanaPromRulesResponse + extends PromResponse<{ + groups: GrafanaPromRuleGroupDTO[]; + groupNextToken?: string; + totals?: AlertGroupTotals; + }> {} // Ruler rule DTOs interface RulerRuleBaseDTO { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4c99d311a6b..823fddaec55 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1729,6 +1729,8 @@ "title-inspect-alert-rule": "Inspect Alert rule" }, "rule-list": { + "cannot-find-rule-details-for": "Cannot find rule details for {{uid}}", + "cannot-load-rule-details-for": "Cannot load rule details for {{name}}", "configure-datasource": "Configure", "draft-new-rule": "Draft a new rule", "ds-error-boundary": { @@ -1736,8 +1738,11 @@ "title": "Unable to load rules from this data source" }, "filter-view": { - "no-more-results": "No more results – showing {{numberOfRules}} rules", - "no-rules-found": "No alert or recording rules matched your current set of filters." + "cancel-search": "Cancel search", + "no-more-results": "No more results – found {{numberOfRules}} rules", + "no-rules-found": "No alert or recording rules matched your current set of filters.", + "results-loading": "Searching – found {{numberOfRules}} rules", + "results-with-cancellation": "Search cancelled – found {{numberOfRules}} rules" }, "import-to-gma": { "new-badge": "New!", diff --git a/public/test/setupTests.ts b/public/test/setupTests.ts index 242236ece98..3f053609138 100644 --- a/public/test/setupTests.ts +++ b/public/test/setupTests.ts @@ -37,3 +37,45 @@ jest.mock('app/features/dashboard-scene/saving/createDetectChangesWorker.ts'); // our tests are heavy in CI due to parallelisation and monaco and kusto // so we increase the default timeout to 2secs to avoid flakiness configure({ asyncUtilTimeout: 2000 }); + +// Mock Performance API methods not implemented in jsdom +if (window.performance) { + // Type-safe spies with proper return type definitions + if (!window.performance.mark) { + window.performance.mark = jest.mocked((markName: string) => { + return { + name: markName, + entryType: 'mark', + startTime: 0, + duration: 0, + detail: null, + toJSON: () => ({}), + }; + }); + } + + if (!window.performance.measure) { + window.performance.measure = jest.mocked((measureName: string) => { + return { + name: measureName, + entryType: 'measure', + startTime: 0, + duration: 100, + detail: null, + toJSON: () => ({}), + }; + }); + } + + if (!window.performance.getEntriesByName) { + window.performance.getEntriesByName = jest.mocked(() => []); + } + + if (!window.performance.clearMarks) { + window.performance.clearMarks = jest.mocked(() => {}); + } + + if (!window.performance.clearMeasures) { + window.performance.clearMeasures = jest.mocked(() => {}); + } +}