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 <gilles.de.mey@gmail.com>
pull/103851/head
Konrad Lalik 9 months ago committed by GitHub
parent 1e669cbb45
commit 0a8dccc19a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      pkg/services/ngalert/api/api_prometheus_test.go
  2. 29
      pkg/services/ngalert/api/prometheus/api_prometheus.go
  3. 15
      pkg/services/ngalert/api/tooling/definitions/prom.go
  4. 30
      public/app/features/alerting/unified/Analytics.ts
  5. 3
      public/app/features/alerting/unified/PanelAlertTabContent.test.tsx
  6. 2
      public/app/features/alerting/unified/api/alertmanagerApi.ts
  7. 12
      public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx
  8. 15
      public/app/features/alerting/unified/rule-list/FilterView.test.tsx
  9. 127
      public/app/features/alerting/unified/rule-list/FilterView.tsx
  10. 41
      public/app/features/alerting/unified/rule-list/FilterViewStatus.tsx
  11. 31
      public/app/features/alerting/unified/rule-list/GrafanaRuleLoader.tsx
  12. 6
      public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx
  13. 4
      public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx
  14. 257
      public/app/features/alerting/unified/rule-list/hooks/filters.test.ts
  15. 144
      public/app/features/alerting/unified/rule-list/hooks/filters.ts
  16. 23
      public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
  17. 195
      public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts
  18. 2
      public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusGroups.tsx
  19. 20
      public/app/features/alerting/unified/utils/rules.ts
  20. 2
      public/app/features/browse-dashboards/BrowseFolderAlertingPage.test.tsx
  21. 10
      public/app/features/browse-dashboards/fixtures/alertRules.fixture.ts
  22. 3
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
  23. 3
      public/app/features/query/state/DashboardQueryRunner/DashboardQueryRunner.test.ts
  24. 31
      public/app/types/unified-alerting-dto.ts
  25. 9
      public/locales/en-US/grafana.json
  26. 42
      public/test/setupTests.ts

@ -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": [{

@ -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 {

@ -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"`

@ -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<TFunc extends (...args: any[]) => Promise<any>>(
type: string,
func: TFunc,
context: Record<string, string>
): (...args: Parameters<TFunc>) => Promise<Awaited<ReturnType<TFunc>>> {
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<TArgs extends unknown[], TReturn>(
func: (...args: TArgs) => Promise<TReturn>,
measurementName: string,
context: Record<string, string> = {}
): (...args: TArgs) => Promise<TReturn> {
return async function (...args: TArgs): Promise<TReturn> {
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
);

@ -117,9 +117,6 @@ const promResponse: PromRulesResponse = {
interval: 20,
},
],
totals: {
alerting: 2,
},
},
};
const rulerResponse = {

@ -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',

@ -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 = <Icon name={'search'} />;
return (

@ -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(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
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(<FilterView filterState={getFilter({ groupName: 'non-existing-group' })} />);
@ -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> = {}): RulesFilter {

@ -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<RuleWithOrigin> 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<RuleWithOrigin> 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<RuleWithOrigin[]>;
abortController: AbortController;
} | null>(null);
const [rules, setRules] = useState<KeyedRuleWithOrigin[]>([]);
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 <FRONTENT_PAGE_SIZE> 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<RuleWithOrigin> 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<FilterProgressState>(() => {
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) {
</>
)}
</ul>
{doneSearching && !noRulesFound && (
<Card>
<Text color="secondary">
<Trans i18nKey="alerting.rule-list.filter-view.no-more-results">
No more results showing {{ numberOfRules }} rules
</Trans>
</Text>
</Card>
{!noRulesFound && (
<FilterStatus state={filterProgressState} numberOfRules={numberOfRules} onCancel={cancelSearch} />
)}
{!doneSearching && !loading && <LoadMoreHelper handleLoad={loadResultPage} />}
{!doneSearching && !loading && !loadingAborted && <LoadMoreHelper handleLoad={loadResultPage} />}
</Stack>
);
}

@ -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 (
<Card>
<Text color="secondary">
{/* done searching everything and found some results */}
{state === 'done' && (
<Trans i18nKey="alerting.rule-list.filter-view.no-more-results">
No more results found {{ numberOfRules }} rules
</Trans>
)}
{/* user has cancelled the search */}
{state === 'aborted' && (
<Trans i18nKey="alerting.rule-list.filter-view.results-with-cancellation">
Search cancelled found {{ numberOfRules }} rules
</Trans>
)}
{/* search is in progress */}
{state === 'searching' && (
<Trans i18nKey="alerting.rule-list.filter-view.results-loading">
Searching found {{ numberOfRules }} rules
</Trans>
)}
</Text>
{state === 'searching' && (
<Button variant="secondary" size="sm" onClick={() => onCancel()}>
{t('alerting.rule-list.filter-view.cancel-search', 'Cancel search')}
</Button>
)}
</Card>
);
}

@ -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 <RulerRuleLoadingError rule={rule} />;
}
if (isError) {
return <RulerRuleLoadingError rule={rule} />;
}
if (isLoading) {
return <AlertRuleListItemSkeleton />;
}
if (!rulerRule) {
return (
<Alert
title={t('alerting.rule-list.cannot-load-rule-details-for', 'Cannot load rule details for {{name}}', {
name: rule.name,
})}
severity="error"
>
<Trans i18nKey="alerting.rule-list.cannot-find-rule-details-for">
Cannot find rule details for {{ uid: rule.uid ?? '<empty uid>' }}
</Trans>
</Alert>
);
}
return (
<GrafanaRuleListItem
rule={rule}

@ -12,7 +12,7 @@ import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
import { usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
@ -25,7 +25,9 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
const { uid, name } = rulesSourceIdentifier;
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true });
const groupsGenerator = useRef(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE));
const groupsGenerator = useRef(
toIndividualRuleGroups(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE))
);
useEffect(() => {
const currentGenerator = groupsGenerator.current;

@ -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;

@ -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);
});
});

@ -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 }
);

@ -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<TGroup extends PromRuleGroupDTO>(
generator: AsyncGenerator<TGroup[], void, unknown>
): AsyncGenerator<TGroup, void, unknown> {
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<TGroup>(
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;
}
}

@ -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<RuleWithOrigin>;
abortController: AbortController;
}
export function useFilteredRulesIteratorProvider() {
const allExternalRulesSources = getExternalRulesSources();
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const grafanaGroupsGenerator = useGrafanaGroupsGenerator();
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number): AsyncIterableX<RuleWithOrigin> => {
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<DataSourceRulesSourceIdentifier>((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<RuleWithOrigin>(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<DataSourceRulesSourceIdentifier[]>((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 };
}
}

@ -14,7 +14,7 @@ import { isLoading, useAsync } from '../../hooks/useAsync';
* @returns Pagination state and controls for navigating through rule groups
*/
export function usePaginatedPrometheusGroups<TGroup extends PromRuleGroupDTO>(
groupsGenerator: AsyncGenerator<TGroup, void, unknown>,
groupsGenerator: AsyncIterator<TGroup>,
pageSize: number
) {
const [currentPage, setCurrentPage] = useState(1);

@ -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));
}

@ -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 });

@ -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,

@ -134,9 +134,6 @@ const promResponse: PromRulesResponse = {
interval: 20,
},
],
totals: {
alerting: 2,
},
},
};

@ -115,9 +115,6 @@ function getTestContext() {
file: 'my-namespace',
},
],
totals: {
alerting: 2,
},
},
};

@ -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<TRule = PromRuleDTO> {
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<GrafanaPromRuleDTO> {
@ -185,11 +185,14 @@ export interface PromResponse<T> {
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 {

@ -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!",

@ -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<typeof window.performance.mark>((markName: string) => {
return {
name: markName,
entryType: 'mark',
startTime: 0,
duration: 0,
detail: null,
toJSON: () => ({}),
};
});
}
if (!window.performance.measure) {
window.performance.measure = jest.mocked<typeof window.performance.measure>((measureName: string) => {
return {
name: measureName,
entryType: 'measure',
startTime: 0,
duration: 100,
detail: null,
toJSON: () => ({}),
};
});
}
if (!window.performance.getEntriesByName) {
window.performance.getEntriesByName = jest.mocked<typeof window.performance.getEntriesByName>(() => []);
}
if (!window.performance.clearMarks) {
window.performance.clearMarks = jest.mocked<typeof window.performance.clearMarks>(() => {});
}
if (!window.performance.clearMeasures) {
window.performance.clearMeasures = jest.mocked<typeof window.performance.clearMeasures>(() => {});
}
}

Loading…
Cancel
Save