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(() => {}); + } +}