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>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
});
|
||||
return iteration.current.rulesBatchIterator;
|
||||
}, [filterState, getFilteredRulesIterator]);
|
||||
|
||||
/* When we unmount the component we make sure to abort all iterables */
|
||||
useEffect(() => {
|
||||
const currentAbortController = controller.current;
|
||||
/* This function will fetch a page of results from the iterable */
|
||||
const [{ execute: loadResultPage }, state] = useAsync(
|
||||
withPerformanceLogging(async () => {
|
||||
const rulesIterator = getRulesBatchIterator();
|
||||
|
||||
return () => {
|
||||
currentAbortController.abort();
|
||||
};
|
||||
}, [controller]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
144
public/app/features/alerting/unified/rule-list/hooks/filters.ts
Normal file
144
public/app/features/alerting/unified/rule-list/hooks/filters.ts
Normal file
@@ -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 ruleSourcesToFetchFrom = filterState.dataSourceNames.length
|
||||
? filterState.dataSourceNames.map<DataSourceRulesSourceIdentifier>((ds) => ({
|
||||
name: ds,
|
||||
uid: getDatasourceAPIUid(ds),
|
||||
ruleSourceType: 'datasource',
|
||||
}))
|
||||
: 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)),
|
||||
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 sourceIterables = ruleSourcesToFetchFrom.map((ds) => {
|
||||
const generator = prometheusGroupsGenerator(ds, groupLimit);
|
||||
return from(generator).pipe(
|
||||
map((group) => [ds, group] as const),
|
||||
// Determine which data sources to use
|
||||
const externalRulesSourcesToFetchFrom = hasDataSourceFilterActive
|
||||
? getRulesSourcesFromFilter(filterState)
|
||||
: allExternalRulesSources;
|
||||
|
||||
// If no data sources, just return Grafana rules
|
||||
if (isEmpty(externalRulesSourcesToFetchFrom)) {
|
||||
return { iterable: grafanaRulesGenerator, abortController };
|
||||
}
|
||||
|
||||
// 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())
|
||||
);
|
||||
|
||||
return promGroupsGenerator;
|
||||
});
|
||||
|
||||
// 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 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>(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user