Alerting: Alert list - pagination and filtering part 1 (#96423)

* Add basic token-based paginated fetching

* Add 1:many relation between UI and API pages

* Fix pagination arrows

* Add pagination  to hierarchical view

* Add multidatasource filtering

* Improve flushing filtered rules, add better identifiers

* Fix pagination for data sources not supporting server side pagination

* Use alert rule loader on the filter view

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>

* use useTransition and update loader

* types

* Update ruleGruopIdentifier. Add actions and location to recording rules

* Update to the new API parameters

* Refactor iterator code

* Use ix to merge iterators

* Improve perf

* use AbortController to cancel loading pages

* remove iterops for now

* add comments

* add application and rulesource information to list view

* update test

* update list view functionality

* add emptystate

* automatically load more items when we get to the bottom of the page

* reduce number of loaders

* separate hook for useFilteredRulesIteratorProvider

* use useDeepCompareEffect to track filter state changes

* fix weird no results loading glitch

* fix rare case where changing filters wouldn't update the list

* add number of results to component

* Simplify FilterView rerendering

* add filter for dashboard

* Add tests for filtered view, use data source UID instead of names in the interator code

* Improve HTML semantics, extract a separate GroupedView component

* Split RuleList.v2 into multiple files

* Split tests into Filtered and Grouped view files

* PR feedback

* Improve error handling, add tests for GroupedView

* Improve types, small refactoring

* Improve rules setup

* Small improvements, v1 and v2 versions of the view type selector

* Remove yarn cache changes

* Import from test-utils

* Move groupIdentifiers, improve state param parsing

* reorder imports

* reorder imports

* update yarn resolution

* i18n

* Improve API mock, increase timeout limit

* Add tests for RuleList.v2

* Update tests

* Fix mocks in test

* Fix lint

* Fix data sources mock

---------

Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
Konrad Lalik
2024-12-12 14:18:52 +01:00
committed by GitHub
co-authored by Gilles De Mey Tom Ratcliffe
parent 65dfbd7731
commit 8055d69ad2
48 changed files with 1835 additions and 531 deletions
+3
View File
@@ -192,6 +192,7 @@
"eslint-scope": "^8.1.0",
"eslint-webpack-plugin": "4.2.0",
"expose-loader": "5.0.0",
"fishery": "^2.2.2",
"fork-ts-checker-webpack-plugin": "9.0.2",
"glob": "11.0.0",
"html-loader": "5.1.0",
@@ -207,6 +208,7 @@
"jest-junit": "16.0.0",
"jest-matcher-utils": "29.7.0",
"jest-watch-typeahead": "^2.2.2",
"jsdom-testing-mocks": "^1.13.1",
"knip": "^5.10.0",
"lerna": "8.1.8",
"mini-css-extract-plugin": "2.9.2",
@@ -334,6 +336,7 @@
"i18next-browser-languagedetector": "^7.0.2",
"immer": "10.1.1",
"immutable": "4.3.7",
"ix": "^7.0.0",
"jquery": "3.7.1",
"js-yaml": "^4.1.0",
"json-markup": "^1.1.0",
@@ -1,16 +1,11 @@
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
import { GrafanaRulesSourceSymbol, RulerDataSourceConfig, RulesSourceUid } from 'app/types/unified-alerting';
import {
AlertmanagerApiFeatures,
PromApplication,
RulesSourceApplication,
} from '../../../../types/unified-alerting-dto';
import {
GRAFANA_RULES_SOURCE_NAME,
getDataSourceUID,
getRulesDataSourceByUID,
isGrafanaRulesSource,
} from '../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME, getDataSourceUID, getRulesDataSourceByUID } from '../utils/datasource';
import { alertingApi } from './alertingApi';
import { discoverAlertmanagerFeatures, discoverFeaturesByUid } from './buildInfo';
@@ -40,14 +35,14 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
},
}),
discoverDsFeatures: build.query<RulesSourceFeatures, { rulesSourceName: string } | { uid: string }>({
discoverDsFeatures: build.query<RulesSourceFeatures, { rulesSourceName: string } | { uid: RulesSourceUid }>({
queryFn: async (rulesSourceIdentifier) => {
const dataSourceUID = getDataSourceUID(rulesSourceIdentifier);
if (!dataSourceUID) {
return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) };
}
if (isGrafanaRulesSource(dataSourceUID)) {
if (dataSourceUID === GrafanaRulesSourceSymbol) {
return {
data: {
name: GRAFANA_RULES_SOURCE_NAME,
@@ -0,0 +1,41 @@
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { alertingApi } from './alertingApi';
interface PromRulesResponse {
status: string;
data: {
groups: PromRuleGroupDTO[];
groupNextToken?: string;
};
errorType?: string;
error?: string;
}
interface PromRulesOptions {
ruleSource: { uid: string };
namespace?: string;
groupName?: string;
ruleName?: string;
groupLimit?: number;
excludeAlerts?: boolean;
groupNextToken?: string;
}
export const prometheusApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
groups: build.query<PromRulesResponse, PromRulesOptions>({
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
params: {
'file[]': namespace,
'group[]': groupName,
'rule[]': ruleName,
exclude_alerts: excludeAlerts?.toString(),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
},
}),
}),
}),
});
@@ -1,7 +1,7 @@
import { Menu } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
@@ -10,7 +10,7 @@ import { stringifyErrorLike } from '../utils/misc';
interface Props {
rule: RulerRuleDTO;
groupIdentifier: RuleGroupIdentifier;
groupIdentifier: GrafanaRuleGroupIdentifier;
/**
* Method invoked after the request to change the paused state has completed
*/
@@ -4,7 +4,7 @@ import appEvents from 'app/core/app_events';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import MoreButton from 'app/features/alerting/unified/components/MoreButton';
import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions';
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
@@ -18,9 +18,9 @@ interface Props {
promRule: Rule;
rulerRule?: RulerRuleDTO;
identifier: RuleIdentifier;
groupIdentifier: RuleGroupIdentifier;
groupIdentifier: RuleGroupIdentifierV2;
handleSilence: () => void;
handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void;
handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void;
handleDuplicateRule: (identifier: RuleIdentifier) => void;
onPauseChange?: () => void;
buttonSize?: ComponentSize;
@@ -86,7 +86,7 @@ const AlertRuleMenu = ({
const menuItems = (
<>
{canPause && rulerRule && (
{canPause && rulerRule && groupIdentifier.groupOrigin === 'grafana' && (
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
)}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
@@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
import { locationService } from '@grafana/runtime';
import { ConfirmModal } from '@grafana/ui';
import { dispatch } from 'app/store/store';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
@@ -13,8 +13,8 @@ import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
import { isCloudRuleIdentifier } from '../../utils/rules';
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void, () => void];
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifier } | undefined;
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void, () => void];
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifierV2 } | undefined;
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
@@ -27,7 +27,7 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
setRuleToDelete(undefined);
}, []);
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => {
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => {
setRuleToDelete({ rule, groupIdentifier });
}, []);
@@ -38,18 +38,24 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
const { rule, groupIdentifier } = ruleToDelete;
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifier, rule);
await deleteRuleFromGroup.execute(groupIdentifier, ruleIdentifier);
const groupIdentifierV1: RuleGroupIdentifier = {
dataSourceName: groupIdentifier.rulesSource.name,
namespaceName:
'uid' in groupIdentifier.namespace ? groupIdentifier.namespace.uid : groupIdentifier.namespace.name,
groupName: groupIdentifier.groupName,
};
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifierV1, rule);
await deleteRuleFromGroup.execute(groupIdentifierV1, ruleIdentifier);
// refetch rules for this rules source
// @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name }));
if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) {
await waitForRemoval(ruleIdentifier);
} else {
// Without this the delete popup will close and the user will still see the deleted rule
await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name }));
}
dismissModal();
@@ -14,6 +14,7 @@ import {
getGrafanaRule,
getVanillaPromRule,
grantUserPermissions,
mockCombinedCloudRuleNamespace,
mockDataSource,
mockPluginLinkExtension,
mockPromAlertingRule,
@@ -62,7 +63,7 @@ const ELEMENTS = {
};
setupMswServer();
setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'mimir-1' }));
setPluginLinksHook(() => ({
links: [
mockPluginLinkExtension({ pluginId: 'grafana-slo-app', title: 'SLO dashboard', path: '/a/grafana-slo-app' }),
@@ -102,6 +103,19 @@ beforeAll(() => {
]);
});
const dataSources = {
am: mockDataSource<AlertManagerDataSourceJsonData>(
{
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
jsonData: { handleGrafanaManagedAlerts: true },
},
{ module: 'core:plugin/alertmanager' }
),
mimir: mockDataSource({ uid: 'mimir', name: 'Mimir' }, { module: 'core:plugin/prometheus' }),
prometheus: mockDataSource({ uid: 'prometheus', name: 'Prometheus' }, { module: 'core:plugin/prometheus' }),
};
describe('RuleViewer', () => {
describe('Grafana managed alert rule', () => {
const mockRule = getGrafanaRule(
@@ -140,17 +154,6 @@ describe('RuleViewer', () => {
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingInstancesExternalWrite,
]);
const dataSources = {
am: mockDataSource<AlertManagerDataSourceJsonData>({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
jsonData: {
handleGrafanaManagedAlerts: true,
},
}),
};
setupDataSources(dataSources.am);
});
it('should render a Grafana managed alert rule', async () => {
@@ -199,12 +202,17 @@ describe('RuleViewer', () => {
});
describe('Data source managed alert rule', () => {
const mockRule = getCloudRule({
name: 'cloud test alert',
annotations: { [Annotation.summary]: 'cloud summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
group: { name: 'Cloud group', interval: '15m', rules: [], totals: { alerting: 1 } },
});
const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule);
const { mimir } = dataSources;
const mockRule = getCloudRule(
{
name: 'cloud test alert',
annotations: { [Annotation.summary]: 'cloud summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
group: { name: 'Cloud group', interval: '15m', rules: [], totals: { alerting: 1 } },
},
{ rulesSource: mimir }
);
const mockRuleIdentifier = ruleId.fromCombinedRule(mimir.name, mockRule);
beforeAll(() => {
grantUserPermissions([
@@ -213,6 +221,10 @@ describe('RuleViewer', () => {
]);
});
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
it('should render a data source managed alert rule', () => {
renderRuleViewer(mockRule, mockRuleIdentifier);
@@ -226,11 +238,11 @@ describe('RuleViewer', () => {
});
it('should render custom plugin actions for a plugin-provided rule', async () => {
const sloRule = getCloudRule({
name: 'slo test alert',
labels: { __grafana_origin: 'plugin/grafana-slo-app' },
});
const sloRuleIdentifier = ruleId.fromCombinedRule('mimir-1', sloRule);
const sloRule = getCloudRule(
{ name: 'slo test alert', labels: { __grafana_origin: 'plugin/grafana-slo-app' } },
{ rulesSource: mimir }
);
const sloRuleIdentifier = ruleId.fromCombinedRule(mimir.name, sloRule);
const user = userEvent.setup();
@@ -247,11 +259,11 @@ describe('RuleViewer', () => {
});
it('should render different custom plugin actions for a different plugin-provided rule', async () => {
const assertsRule = getCloudRule({
name: 'asserts test alert',
labels: { __grafana_origin: 'plugin/grafana-asserts-app' },
});
const assertsRuleIdentifier = ruleId.fromCombinedRule('mimir-1', assertsRule);
const assertsRule = getCloudRule(
{ name: 'asserts test alert', labels: { __grafana_origin: 'plugin/grafana-asserts-app' } },
{ rulesSource: mimir }
);
const assertsRuleIdentifier = ruleId.fromCombinedRule(mimir.name, assertsRule);
renderRuleViewer(assertsRule, assertsRuleIdentifier);
@@ -267,8 +279,11 @@ describe('RuleViewer', () => {
});
describe('Vanilla Prometheus rule', () => {
const { prometheus } = dataSources;
const mockRule = getVanillaPromRule({
name: 'prom test alert',
namespace: mockCombinedCloudRuleNamespace({ name: 'prometheus' }, prometheus.name),
annotations: { [Annotation.summary]: 'prom summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
promRule: {
...mockPromAlertingRule(),
@@ -276,7 +291,7 @@ describe('RuleViewer', () => {
},
});
const mockRuleIdentifier = ruleId.fromCombinedRule('prometheus', mockRule);
const mockRuleIdentifier = ruleId.fromCombinedRule(prometheus.name, mockRule);
it('should render pending period for vanilla Prometheus alert rule', async () => {
renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Details);
@@ -15,12 +15,10 @@ import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-
import {
LogMessages,
logInfo,
trackRulesListViewChange,
trackRulesSearchComponentInteraction,
trackRulesSearchInputInteraction,
} from '../../../Analytics';
import { useRulesFilter } from '../../../hooks/useFilteredRules';
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions';
import { RuleHealth } from '../../../search/rulesSearchParser';
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
@@ -29,33 +27,11 @@ import { alertStateToReadable } from '../../../utils/rules';
import { PopupCard } from '../../HoverCard';
import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker';
const ViewOptions: SelectableValue[] = [
{
icon: 'folder',
label: 'Grouped',
value: 'grouped',
},
{
icon: 'list-ul',
label: 'List',
value: 'list',
},
{
icon: 'heart-rate',
label: 'State',
value: 'state',
},
];
import { RulesViewModeSelector } from './RulesViewModeSelector';
const RuleTypeOptions: SelectableValue[] = [
{
label: 'Alert ',
value: PromRuleType.Alerting,
},
{
label: 'Recording ',
value: PromRuleType.Recording,
},
{ label: 'Alert ', value: PromRuleType.Alerting },
{ label: 'Recording ', value: PromRuleType.Recording },
];
const RuleHealthOptions: SelectableValue[] = [
@@ -75,7 +51,6 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]
const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
const styles = useStyles2(getStyles);
const [queryParams, updateQueryParams] = useURLSearchParams();
const { pluginsFilterEnabled } = usePluginsFilterStatus();
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
@@ -142,11 +117,6 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
setTimeout(() => setFilterKey(filterKey + 1), 100);
};
const handleViewChange = (view: string) => {
updateQueryParams({ view });
trackRulesListViewChange({ view });
};
const handleContactPointChange = (contactPoint: string) => {
updateFilters({ ...filterState, contactPoint });
trackRulesSearchComponentInteraction('contactPoint');
@@ -318,11 +288,7 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
</form>
<div>
<Label>View as</Label>
<RadioButtonGroup
options={ViewOptions}
value={queryParams.get('view') ?? ViewOptions[0].value}
onChange={handleViewChange}
/>
<RulesViewModeSelector />
</div>
</Stack>
{hasActiveFilters && (
@@ -0,0 +1,71 @@
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { RadioButtonGroup } from '@grafana/ui';
import { trackRulesListViewChange } from '../../../Analytics';
import { useRulesFilter } from '../../../hooks/useFilteredRules';
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
export type SupportedView = 'list' | 'grouped';
type LegacySupportedView = 'list' | 'grouped' | 'state';
const ViewOptions: Array<SelectableValue<SupportedView>> = [
{ icon: 'folder', label: 'Grouped', value: 'grouped' },
{ icon: 'list-ul', label: 'List', value: 'list' },
];
function RulesViewModeSelectorV2() {
const [queryParams, updateQueryParams] = useURLSearchParams();
const { hasActiveFilters } = useRulesFilter();
const wantsListView = queryParams.get('view') === 'list';
const selectedViewOption = hasActiveFilters || wantsListView ? 'list' : 'grouped';
/* If we change to the grouped view, we just remove the "list" and "search" params */
const handleViewChange = (view: SupportedView) => {
if (view === 'list') {
updateQueryParams({ view });
trackRulesListViewChange({ view });
} else {
updateQueryParams({ view: undefined, search: undefined });
}
};
return <RadioButtonGroup options={ViewOptions} value={selectedViewOption} onChange={handleViewChange} />;
}
const LegacyViewOptions: Array<SelectableValue<LegacySupportedView>> = [
{ label: 'Grouped', value: 'grouped' },
{ label: 'List', value: 'list' },
{ label: 'State', value: 'state' },
];
function RulesViewModeSelectorV1() {
const [queryParams, updateQueryParams] = useURLSearchParams();
const viewParam = queryParams.get('view');
const currentView = viewParamToLegacyView(viewParam);
const handleViewChange = (view: LegacySupportedView) => {
updateQueryParams({ view });
};
return <RadioButtonGroup options={LegacyViewOptions} value={currentView} onChange={handleViewChange} />;
}
function viewParamToLegacyView(viewParam: string | null): LegacySupportedView {
if (viewParam === 'list') {
return 'list';
}
if (viewParam === 'state') {
return 'state';
}
return 'grouped';
}
export const RulesViewModeSelector = config.featureToggles.alertingListViewV2
? RulesViewModeSelectorV2
: RulesViewModeSelectorV1;
@@ -67,7 +67,8 @@ setPluginLinksHook(() => ({
}));
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
setupDataSources(mimirDs);
const prometheusDs = mockDataSource({ uid: 'prometheus', name: 'Prometheus' });
setupDataSources(mimirDs, prometheusDs);
const clickCopyLink = async () => {
const user = userEvent.setup();
@@ -122,7 +123,7 @@ describe('RuleActionsButtons', () => {
document.addEventListener('click', interceptLinkClicks);
grantAllPermissions();
const mockRule = getCloudRule({ name: 'special !@#$%^&*() chars' });
const mockRule = getCloudRule({ name: 'special !@#$%^&*() chars' }, { rulesSource: mimirDs });
const { user } = render(<RuleActionsButtons rule={mockRule} rulesSource={mimirDs} showViewButton />, {
renderWithRouter: true,
});
@@ -185,16 +186,14 @@ describe('RuleActionsButtons', () => {
});
it('copies correct URL for cloud rule', async () => {
const promDataSource = mockDataSource({ name: 'Prometheus-2' });
const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' }, { rulesSource: prometheusDs });
const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' });
render(<RuleActionsButtons rule={mockRule} rulesSource={promDataSource} />);
render(<RuleActionsButtons rule={mockRule} rulesSource={prometheusDs} />);
await clickCopyLink();
expect(await navigator.clipboard.readText()).toBe(
'http://localhost:3000/sub/alerting/Prometheus-2/pod-1-cpu-firing/find'
'http://localhost:3000/sub/alerting/Prometheus/pod-1-cpu-firing/find'
);
});
});
@@ -14,9 +14,10 @@ import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-ale
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
import { groupIdentifier } from '../../utils/groupIdentifier';
import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { getRuleGroupLocationFromCombinedRule, isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { createRelativeUrl } from '../../utils/url';
import { RedirectToCloneRule } from './CloneRule';
@@ -64,7 +65,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromCombinedRule(sourceName, rule);
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
const groupId = groupIdentifier.fromCombinedRule(rule);
if (showViewButton) {
buttons.push(
@@ -104,10 +105,10 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
rulerRule={rule.rulerRule}
promRule={rule.promRule}
identifier={identifier}
groupIdentifier={groupIdentifier}
groupIdentifier={groupId}
handleDelete={() => {
if (rule.rulerRule) {
showDeleteModal(rule.rulerRule, groupIdentifier);
showDeleteModal(rule.rulerRule, groupId);
}
}}
handleSilence={() => setShowSilenceDrawer(true)}
@@ -6,6 +6,7 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AlertRuleAction, useAlertRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { mimirDataSource } from '../../mocks/server/configure';
import { RulesTable } from './RulesTable';
@@ -38,6 +39,8 @@ const ui = {
const user = userEvent.setup();
setupMswServer();
const { dataSource: mimirDs } = mimirDataSource();
describe('RulesTable RBAC', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -152,7 +155,7 @@ describe('RulesTable RBAC', () => {
});
describe('Cloud rules action buttons', () => {
const cloudRule = getCloudRule({ name: 'Cloud' });
const cloudRule = getCloudRule({ name: 'Cloud' }, { rulesSource: mimirDs });
it('Should not render Edit button for users without the update permission', async () => {
mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
@@ -11,7 +11,7 @@ import { grantUserPermissions, mockCombinedRule, mockCombinedRuleGroup, mockGraf
import { grafanaRulerGroupName, grafanaRulerNamespace, grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { setUpdateRulerRuleNamespaceHandler } from '../../mocks/server/configure';
import { captureRequests, serializeRequests } from '../../mocks/server/events';
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
import { groupIdentifier } from '../../utils/groupIdentifier';
import { SerializeState } from '../useAsync';
import { usePauseRuleInGroup } from './usePauseAlertRule';
@@ -83,9 +83,13 @@ const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => {
rulerRule,
group: mockCombinedRuleGroup(grafanaRulerGroupName, []),
});
const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule);
const ruleGroupID = groupIdentifier.fromCombinedRule(rule);
const onClick = () => {
if (ruleGroupID.groupOrigin !== 'grafana') {
throw new Error('not a Grafana rule');
}
// always handle your errors!
pauseRule.execute(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {});
};
@@ -1,5 +1,5 @@
import { t } from 'app/core/internationalization';
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
import { GrafanaRuleGroupIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
import { pauseRuleAction } from '../../reducers/ruler/ruleGroups';
@@ -18,15 +18,18 @@ export function usePauseRuleInGroup() {
const rulePausedMessage = t('alerting.rules.pause-rule.success', 'Rule evaluation paused');
const ruleResumedMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
return useAsync(async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
const { namespaceName } = ruleGroup;
return useAsync(async (ruleGroup: GrafanaRuleGroupIdentifier, uid: string, pause: boolean) => {
const groupIdentifierV1: RuleGroupIdentifier = {
dataSourceName: ruleGroup.rulesSource.name,
namespaceName: ruleGroup.namespace.uid,
groupName: ruleGroup.groupName,
};
const action = pauseRuleAction({ uid, pause });
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, action);
return upsertRuleGroup({
rulerConfig,
namespace: namespaceName,
namespace: ruleGroup.namespace.uid,
payload: newRuleGroupDefinition,
notificationOptions: {
successMessage: pause ? rulePausedMessage : ruleResumedMessage,
@@ -197,7 +197,10 @@ describe('AlertRule abilities', () => {
});
it('should report no permissions while we are loading data for cloud rule', async () => {
const rule = getCloudRule();
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
setupDataSources(mimirDs);
const rule = getCloudRule({}, { rulesSource: mimirDs });
const { result } = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: wrapper() });
@@ -13,7 +13,7 @@ import {
import { useFolder } from 'app/features/alerting/unified/hooks/useFolder';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { CombinedRule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { alertmanagerApi } from '../api/alertmanagerApi';
@@ -165,7 +165,7 @@ export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleActi
export function useRulerRuleAbility(
rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifier,
groupIdentifier: RuleGroupIdentifierV2,
action: AlertRuleAction
): Ability {
const abilities = useAllRulerRuleAbilities(rule, groupIdentifier);
@@ -177,7 +177,7 @@ export function useRulerRuleAbility(
export function useRulerRuleAbilities(
rule: RulerRuleDTO,
groupIdentifier: RuleGroupIdentifier,
groupIdentifier: RuleGroupIdentifierV2,
actions: AlertRuleAction[]
): Ability[] {
const abilities = useAllRulerRuleAbilities(rule, groupIdentifier);
@@ -240,9 +240,9 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
export function useAllRulerRuleAbilities(
rule: RulerRuleDTO | undefined,
groupIdentifier: RuleGroupIdentifier
groupIdentifier: RuleGroupIdentifierV2
): Abilities<AlertRuleAction> {
const rulesSourceName = groupIdentifier.dataSourceName;
const rulesSourceName = groupIdentifier.rulesSource.name;
const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
@@ -192,7 +192,6 @@ const reduceNamespaces = (filterState: RulesFilter) => {
const ufuzzy = getSearchInstance(groupNameFilter);
const escapedQuery = escapeQueryRegex(groupNameFilter);
const [idxs, info, order] = ufuzzy.search(
groupsHaystack,
escapedQuery,
@@ -3,6 +3,7 @@ import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { getRulesPermissions } from '../utils/access-control';
import { getDatasourceAPIUid } from '../utils/datasource';
import { isGrafanaRulerRule } from '../utils/rules';
import { useFolder } from './useFolder';
@@ -16,7 +17,7 @@ interface ResultBag {
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
rulesSourceName,
uid: getDatasourceAPIUid(rulesSourceName),
});
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
@@ -1,5 +1,6 @@
import { HttpResponse, http } from 'msw';
import { DataSourceInstanceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi';
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
@@ -14,11 +15,12 @@ import {
getDisabledPluginHandler,
getPluginMissingHandler,
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
import { ALERTING_API_SERVER_BASE_URL } from 'app/features/alerting/unified/mocks/server/utils';
import { ALERTING_API_SERVER_BASE_URL, paginatedHandlerFor } from 'app/features/alerting/unified/mocks/server/utils';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { FolderDTO } from 'app/types';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { setupDataSources } from '../../testSetup/datasources';
import { buildInfoResponse } from '../../testSetup/featureDiscovery';
@@ -114,6 +116,10 @@ export function mimirDataSource() {
return { dataSource };
}
export function setPrometheusRules(ds: DataSourceInstanceSettings, groups: PromRuleGroupDTO[]) {
server.use(http.get(`/api/prometheus/${ds.uid}/api/v1/rules`, paginatedHandlerFor(groups)));
}
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
export const removePlugin = (pluginId: string) => {
delete config.apps[pluginId];
@@ -0,0 +1,79 @@
import { Factory } from 'fishery';
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
import { config, setDataSourceSrv } from '@grafana/runtime';
import {
PromAlertingRuleDTO,
PromAlertingRuleState,
PromRuleGroupDTO,
PromRuleType,
} from 'app/types/unified-alerting-dto';
import { MockDataSourceSrv } from '../../mocks';
import { DataSourceType } from '../../utils/datasource';
const ruleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
name: `test-rule-${sequence}`,
query: 'test-query',
state: PromAlertingRuleState.Inactive,
type: PromRuleType.Alerting,
health: 'ok',
labels: { team: 'infra' },
}));
const groupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
const group = {
name: `test-group-${sequence}`,
file: `test-namespace`,
interval: 10,
rules: ruleFactory.buildList(10),
};
ruleFactory.rewindSequence();
return group;
});
const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence, params, afterBuild }) => {
afterBuild((dataSource) => {
config.datasources[dataSource.name] = dataSource;
setDataSourceSrv(new MockDataSourceSrv(config.datasources));
});
const uid = params.uid ?? `mock-ds-${sequence}`;
return {
id: params.id ?? sequence,
uid,
type: DataSourceType.Prometheus,
name: `Prometheus-${uid}`,
access: 'proxy',
url: `/api/datasources/proxy/uid/${uid}`,
jsonData: {},
meta: {
info: {
author: { name: 'Grafana Labs' },
description: 'Open source time series database & alerting',
updated: '',
version: '',
logos: {
small: 'https://prometheus.io/assets/prometheus_logo_grey.svg',
large: 'https://prometheus.io/assets/prometheus_logo_grey.svg',
},
links: [],
screenshots: [],
},
name: 'Prometheus',
type: PluginType.datasource,
id: 'prometheus',
baseUrl: '"public/app/plugins/datasource/prometheus"',
module: 'core:plugin/prometheus',
},
readOnly: false,
};
});
export const alertingFactory = {
group: groupFactory,
rule: ruleFactory,
dataSource: dataSourceFactory,
};
@@ -1,3 +1,7 @@
import { DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams } from 'msw';
import { PromRuleGroupDTO, PromRulesResponse } from 'app/types/unified-alerting-dto';
/** Helper method to help generate a kubernetes-style response with a list of items */
export const getK8sResponse = <T>(kind: string, items: T[]) => {
return {
@@ -10,3 +14,32 @@ export const getK8sResponse = <T>(kind: string, items: T[]) => {
/** Expected base URL for our k8s APIs */
export const ALERTING_API_SERVER_BASE_URL = '/apis/notifications.alerting.grafana.app/v0alpha1';
export function paginatedHandlerFor(
groups: PromRuleGroupDTO[]
): HttpResponseResolver<PathParams, DefaultBodyType, PromRulesResponse> {
const orderedGroupsWithCursor = groups.map((group) => ({
...group,
id: Buffer.from(`${group.file}-${group.name}`).toString('base64url'),
}));
return ({ request }) => {
const { searchParams } = new URL(request.url);
const groupLimitParam = searchParams.get('group_limit');
const groupNextToken = searchParams.get('group_next_token');
const groupLimit = groupLimitParam ? parseInt(groupLimitParam, 10) : undefined;
const startIndex = groupNextToken ? orderedGroupsWithCursor.findIndex((group) => group.id === groupNextToken) : 0;
const endIndex = groupLimit ? startIndex + groupLimit : orderedGroupsWithCursor.length;
const groupsResult = orderedGroupsWithCursor.slice(startIndex, endIndex);
const nextToken =
groupLimit && orderedGroupsWithCursor.length > groupLimit ? orderedGroupsWithCursor.at(endIndex)?.id : undefined;
return HttpResponse.json<PromRulesResponse>({
status: 'success',
data: { groups: groupsResult, groupNextToken: nextToken },
});
};
}
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { PluginExtensionPoints } from '@grafana/data';
import { usePluginLinks } from '@grafana/runtime';
import { CombinedRule, Rule, RuleGroupIdentifier } from 'app/types/unified-alerting';
import { CombinedRule, Rule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { PromRuleType } from 'app/types/unified-alerting-dto';
import { getRulePluginOrigin } from '../utils/rules';
@@ -21,7 +21,7 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifier) {
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifierV2) {
const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier);
const { links } = usePluginLinks(ruleExtensionPoint);
@@ -57,9 +57,11 @@ interface EmptyExtensionPoint {
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier): RuleExtensionPoint {
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
return useMemo<RuleExtensionPoint>(() => {
const ruleType = rule.type;
const { namespace, groupName } = groupIdentifier;
const namespaceIdentifier = 'uid' in namespace ? namespace.uid : namespace.name;
switch (ruleType) {
case PromRuleType.Alerting:
@@ -67,8 +69,8 @@ function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier)
extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction,
context: {
name: rule.name,
namespace: groupIdentifier.namespaceName,
group: groupIdentifier.groupName,
namespace: namespaceIdentifier,
group: groupName,
expression: rule.query,
labels: rule.labels ?? {},
annotations: rule.annotations ?? {},
@@ -79,8 +81,8 @@ function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier)
extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction,
context: {
name: rule.name,
namespace: groupIdentifier.namespaceName,
group: groupIdentifier.groupName,
namespace: namespaceIdentifier,
group: groupName,
expression: rule.query,
labels: rule.labels ?? {},
},
@@ -0,0 +1,122 @@
import { memo, useMemo } from 'react';
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { equal, fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id';
import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules';
import { createRelativeUrl } from '../utils/url';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
interface AlertRuleLoaderProps {
rule: Rule;
groupIdentifier: DataSourceRuleGroupIdentifier;
}
export const AlertRuleLoader = memo(function AlertRuleLoader({ rule, groupIdentifier }: AlertRuleLoaderProps) {
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);
const href = createViewLinkFromIdentifier(ruleIdentifier);
const originMeta = getRulePluginOrigin(rule);
// @TODO work with context API to propagate rulerConfig and such
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
const {
isLoading,
data: rulerRuleGroup,
// error,
} = useGetRuleGroupForNamespaceQuery(
{
namespace: namespace.name,
group: groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
},
{ skip: !dataSourceInfo?.rulerConfig }
);
const rulerRule = useMemo(() => {
if (!rulerRuleGroup) {
return;
}
return rulerRuleGroup.rules.find((rule) =>
equal(fromRulerRule(rulesSource.name, namespace.name, groupName, rule), ruleIdentifier)
);
}, [rulesSource, namespace, groupName, ruleIdentifier, rulerRuleGroup]);
// 1. get the rule from the ruler API with "ruleWithLocation"
// 1.1 skip this if this datasource does not have a ruler
//
// 2.1 render action buttons
// 2.2 render provisioning badge and contact point metadata, etc.
const actions = useMemo(() => {
if (isLoading) {
return <ActionsLoader />;
}
if (rulerRule) {
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
}
return null;
}, [groupIdentifier, isLoading, rule, rulerRule]);
if (isAlertingRule(rule)) {
return (
<AlertRuleListItem
name={rule.name}
rulesSource={rulesSource}
application={dataSourceInfo?.application}
group={groupName}
namespace={namespace.name}
href={href}
summary={rule.annotations?.summary}
state={rule.state}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
instancesCount={rule.alerts?.length}
actions={actions}
origin={originMeta}
/>
);
}
if (isRecordingRule(rule)) {
return (
<RecordingRuleListItem
name={rule.name}
rulesSource={rulesSource}
application={dataSourceInfo?.application}
group={groupName}
namespace={namespace.name}
href={href}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
actions={actions}
origin={originMeta}
/>
);
}
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
});
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(identifier.ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
@@ -0,0 +1,108 @@
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { act, render, screen, waitForElementToBeRemoved } from 'test/test-utils';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { setPrometheusRules } from '../mocks/server/configure';
import { alertingFactory } from '../mocks/server/db';
import { RulesFilter } from '../search/rulesSearchParser';
import { FilterView } from './FilterView';
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
setupMswServer();
const mimirGroups = alertingFactory.group.buildList(5000, { file: 'test-mimir-namespace' });
alertingFactory.group.rewindSequence();
const prometheusGroups = alertingFactory.group.buildList(200, { file: 'test-prometheus-namespace' });
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
beforeEach(() => {
setPrometheusRules(mimirDs, mimirGroups);
setPrometheusRules(prometheusDs, prometheusGroups);
});
const io = mockIntersectionObserver();
describe('RuleList - FilterView', () => {
it('should render multiple pages of results', async () => {
render(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
await loadMoreResults();
expect(await screen.findAllByRole('treeitem')).toHaveLength(100);
await loadMoreResults();
expect(await screen.findAllByRole('treeitem')).toHaveLength(200);
});
it('should filter results by group and rule name ', async () => {
render(
<FilterView
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-8' })}
/>
);
await loadMoreResults();
const matchingRule = await screen.findByRole('treeitem', {
name: /test-rule-8 test-mimir-namespace test-group-4501/,
});
expect(matchingRule).toBeInTheDocument();
expect(matchingRule).toHaveTextContent('test-rule-8');
expect(matchingRule).toHaveTextContent('test-group-4501');
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
});
it('should display rules from multiple datasources', async () => {
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-5' })} />);
await loadMoreResults();
// Mimir has 11 matching rules, 181, 1810, 1811 ... 1819
const matchingMimirRules = await screen.findAllByRole('treeitem', {
name: /test-rule-5 Mimir test-mimir-namespace test-group-181/,
});
const matchingPrometheusRule = await screen.findByRole('treeitem', {
name: /test-rule-5 Prometheus test-prometheus-namespace test-group-181/,
});
expect(matchingMimirRules).toHaveLength(11);
expect(matchingPrometheusRule).toBeInTheDocument();
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
});
it('should display empty state when no rules are found', async () => {
render(<FilterView filterState={getFilter({ groupName: 'non-existing-group' })} />);
await loadMoreResults();
expect(await screen.findByText(/No matching rules found/)).toBeInTheDocument();
});
});
async function loadMoreResults() {
act(() => {
io.enterNode(screen.getByTestId('load-more-helper'));
});
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 8000 });
}
function getFilter(overrides: Partial<RulesFilter> = {}): RulesFilter {
return {
dataSourceNames: [],
freeFormWords: [],
labels: [],
...overrides,
};
}
@@ -0,0 +1,155 @@
import { take, tap, withAbort } from 'ix/asynciterable/operators';
import { useEffect, useRef, useState, useTransition } from 'react';
import Skeleton from 'react-loading-skeleton';
import { Card, EmptyState, Stack, Text } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { isLoading, useAsync } from '../hooks/useAsync';
import { RulesFilter } from '../search/rulesSearchParser';
import { hashRule } from '../utils/rule-id';
import { AlertRuleLoader } from './AlertRuleLoader';
import LoadMoreHelper from './LoadMoreHelper';
import { ListItem } from './components/ListItem';
import { ActionsLoader } from './components/RuleActionsButtons.V2';
import { RuleListIcon } from './components/RuleListIcon';
import { RuleWithOrigin, useFilteredRulesIteratorProvider } from './hooks/useFilteredRulesIterator';
interface FilterViewProps {
filterState: RulesFilter;
}
const FRONTENT_PAGE_SIZE = 100;
const API_PAGE_SIZE = 2000;
export function FilterView({ filterState }: FilterViewProps) {
// ⚠️ We use a key to force the component to unmount and remount when the filter state changes
// filterState is a complex object including arrays and is constructed from URL params
// so even for the same params we get a new object or new properties in it
return <FilterViewResults filterState={filterState} key={JSON.stringify(filterState)} />;
}
interface KeyedRuleWithOrigin extends RuleWithOrigin {
/**
* Artificial frontend-only identifier for the rule.
* It's used as a key for the rule in the rule list to prevent key duplication
*/
key: string;
}
/**
* Renders the list of rules that match the filter.
* It doesn't update results when the filter changes, use key property to force a remount with a new filter
* Internally it needs to reset rules generator to get new results
* While a bit counter-intuitive resetting using key simplifies a lot of logic in the component
* The component implements infinite scrolling. It loads next page when the user scrolls to the bottom of the list
*/
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();
/* 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 [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(take(FRONTENT_PAGE_SIZE))) {
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 }));
});
}
});
/* When we unmount the component we make sure to abort all iterables */
useEffect(() => {
const currentAbortController = controller.current;
return () => {
currentAbortController.abort();
};
}, [controller]);
const loading = isLoading(state) || transitionPending;
const numberOfRules = rules.length;
const noRulesFound = numberOfRules === 0 && !loading;
/* If we don't have any rules and have exhausted all sources, show a EmptyState */
if (noRulesFound && doneSearching) {
return (
<EmptyState variant="not-found" message="No matching rules found">
<Trans i18nKey="alerting.rule-list.filter-view.no-rules-found">
No alert or recording rules matched your current set of filters.
</Trans>
</EmptyState>
);
}
return (
<Stack direction="column" gap={0}>
<ul aria-label="filtered-rule-list">
{rules.map(({ key, rule, groupIdentifier }) => (
<AlertRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />
))}
{loading && (
<>
<AlertRuleListItemLoader />
<AlertRuleListItemLoader />
</>
)}
</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>
)}
{!doneSearching && <LoadMoreHelper handleLoad={loadResultPage} />}
</Stack>
);
}
const AlertRuleListItemLoader = () => (
<ListItem
title={<Skeleton width={64} />}
icon={<RuleListIcon isPaused={false} />}
description={<Skeleton width={256} />}
actions={<ActionsLoader />}
data-testid="alert-rule-list-item-loader"
/>
);
// simple helper function to detect the end of the source async iterable
function onFinished<T>(fn: () => void) {
return tap<T>(undefined, undefined, fn);
}
function getRuleKey(ruleWithOrigin: RuleWithOrigin) {
const {
rule,
groupIdentifier: { rulesSource, namespace, groupName },
} = ruleWithOrigin;
return `${rulesSource.name}-${namespace.name}-${groupName}-${rule.name}-${rule.type}-${hashRule(rule)}`;
}
@@ -0,0 +1,108 @@
import { render, screen, waitFor, within } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { setPluginComponentsHook, setPluginLinksHook, setReturnToPreviousHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { setPrometheusRules } from '../mocks/server/configure';
import { alertingFactory } from '../mocks/server/db';
import { GroupedView } from './GroupedView';
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
setReturnToPreviousHook(() => () => {});
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
setupMswServer();
const mimirGroups = alertingFactory.group.buildList(500, { file: 'test-mimir-namespace' });
alertingFactory.group.rewindSequence();
const prometheusGroups = alertingFactory.group.buildList(130, { file: 'test-prometheus-namespace' });
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
beforeEach(() => {
setPrometheusRules(mimirDs, mimirGroups);
setPrometheusRules(prometheusDs, prometheusGroups);
});
const ui = {
dsSection: (ds: string | RegExp) => byRole('listitem', { name: ds }),
namespace: (ns: string | RegExp) => byRole('treeitem', { name: ns }),
group: (group: string | RegExp) => byRole('treeitem', { name: group }),
nextButton: () => byRole('button', { name: /next page/ }),
};
describe('RuleList - GroupedView', () => {
it('should render datasource sections', async () => {
render(<GroupedView />);
const mimirSection = await screen.findByRole('listitem', { name: /Mimir/ });
const prometheusSection = await screen.findByRole('listitem', { name: /Prometheus/ });
expect(mimirSection).toBeInTheDocument();
expect(prometheusSection).toBeInTheDocument();
});
it('should paginate through groups', async () => {
const { user } = render(<GroupedView />);
const mimirSection = await ui.dsSection(/Mimir/).find();
expect(mimirSection).toBeInTheDocument();
const mimirNamespace = await ui.namespace(/test-mimir-namespace/).find(mimirSection);
const firstPageGroups = await ui.group(/test-group-([1-9]|[1-3][0-9]|40)/).findAll(mimirNamespace);
expect(firstPageGroups).toHaveLength(40);
expect(firstPageGroups[0]).toHaveTextContent('test-group-1');
expect(firstPageGroups[24]).toHaveTextContent('test-group-25');
expect(firstPageGroups[39]).toHaveTextContent('test-group-40');
const nextButton = await within(mimirSection).findByRole('button', { name: /next page/ });
await user.click(nextButton);
await waitFor(() => expect(nextButton).toBeEnabled());
const secondPageGroups = await ui.group(/test-group-(4[1-9]|[5-7][0-9]|80)/).findAll(mimirNamespace);
expect(secondPageGroups).toHaveLength(40);
expect(secondPageGroups[0]).toHaveTextContent('test-group-41');
expect(secondPageGroups[24]).toHaveTextContent('test-group-65');
expect(secondPageGroups[39]).toHaveTextContent('test-group-80');
});
it('should disable next button when there is no more data', async () => {
const { user } = render(<GroupedView />);
const prometheusSection = await ui.dsSection(/Prometheus/).find();
const nextButton = await ui.nextButton().find(prometheusSection);
await waitFor(() => expect(nextButton).toBeEnabled());
// Fetch second page
await user.click(nextButton);
// Fetch third page
await waitFor(() => expect(nextButton).toBeEnabled());
await user.click(nextButton);
// Fetch fourth page
await waitFor(() => expect(nextButton).toBeEnabled(), { timeout: 10000 });
await user.click(nextButton);
const promNamespace = await ui.namespace(/test-prometheus-namespace/).find(prometheusSection);
const lastPageGroups = await ui.group(/test-group-(12[1-9]|130)/).findAll(promNamespace);
expect(lastPageGroups).toHaveLength(10);
expect(lastPageGroups.at(0)).toHaveTextContent('test-group-121');
expect(lastPageGroups.at(6)).toHaveTextContent('test-group-127');
expect(lastPageGroups.at(9)).toHaveTextContent('test-group-130');
expect(nextButton).toBeDisabled();
});
});
@@ -0,0 +1,278 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Dropdown, Icon, IconButton, LinkButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DataSourceNamespaceIdentifier, DataSourceRuleGroupIdentifier, RuleGroup } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { Spacer } from '../components/Spacer';
import { WithReturnButton } from '../components/WithReturnButton';
import { getDatasourceAPIUid, getExternalRulesSources } from '../utils/datasource';
import { hashRule } from '../utils/rule-id';
import { AlertRuleLoader } from './AlertRuleLoader';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { DataSourceIcon } from './components/Namespace';
import { LoadingIndicator } from './components/RuleGroup';
import { usePaginatedPrometheusRuleNamespaces } from './hooks/usePaginatedPrometheusRuleNamespaces';
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
const GROUP_PAGE_SIZE = 40;
export function GroupedView() {
const externalRuleSources = useMemo(() => getExternalRulesSources(), []);
return (
<Stack direction="column" gap={1} role="list">
<GrafanaDataSourceLoader />
{externalRuleSources.map((ruleSource) => {
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
})}
</Stack>
);
}
interface DataSourceLoaderProps {
name: string;
uid: string;
}
export function GrafanaDataSourceLoader() {
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true}></DataSourceSection>;
}
export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
if (isLoading) {
return <DataSourceSection loader={<Skeleton width={250} height={16} />} uid={uid} name={name} />;
}
// 2. grab prometheus rule groups with max_groups if supported
if (dataSourceInfo) {
return (
<PaginatedDataSourceLoader
ruleSourceName={dataSourceInfo.name}
uid={uid}
name={name}
application={dataSourceInfo.application}
/>
);
}
return null;
}
// TODO Try to use a better rules source identifier
interface PaginatedDataSourceLoaderProps
extends Required<Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'>> {
ruleSourceName: string;
}
function PaginatedDataSourceLoader({ ruleSourceName, name, uid, application }: PaginatedDataSourceLoaderProps) {
const {
page: ruleNamespaces,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusRuleNamespaces(ruleSourceName, GROUP_PAGE_SIZE);
return (
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
<Stack direction="column" gap={1}>
{ruleNamespaces.map((namespace) => (
<ListSection
key={namespace.name}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" />{' '}
<Text variant="body" element="h3">
{namespace.name}
</Text>
</Stack>
}
>
{namespace.groups.map((group) => (
<RuleGroupListItem
key={`${ruleSourceName}-${namespace.name}-${group.name}`}
group={group}
ruleSourceName={ruleSourceName}
namespaceId={namespace}
/>
))}
</ListSection>
))}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
</Stack>
</DataSourceSection>
);
}
interface RuleGroupListItemProps {
group: RuleGroup;
ruleSourceName: string;
namespaceId: DataSourceNamespaceIdentifier;
}
function RuleGroupListItem({ group, ruleSourceName, namespaceId }: RuleGroupListItemProps) {
const rulesWithGroupId = useMemo(
() =>
group.rules.map((rule) => {
const groupIdentifier: DataSourceRuleGroupIdentifier = {
rulesSource: { uid: getDatasourceAPIUid(ruleSourceName), name: ruleSourceName },
namespace: namespaceId,
groupName: group.name,
groupOrigin: 'datasource',
};
return { rule, groupIdentifier };
}),
[group, namespaceId, ruleSourceName]
);
return (
<ListGroup
key={group.name}
name={group.name}
isOpen={false}
actions={
<>
<Dropdown
overlay={
<Menu>
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
<Menu.Item label="Re-order rules" icon="flip" />
<Menu.Divider />
<Menu.Item label="Export" icon="download-alt" />
<Menu.Item label="Delete" icon="trash-alt" destructive />
</Menu>
}
>
<IconButton name="ellipsis-h" aria-label="rule group actions" />
</Dropdown>
</>
}
>
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
<AlertRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
))}
</ListGroup>
);
}
interface DataSourceSectionProps extends PropsWithChildren {
uid: string;
name: string;
loader?: ReactNode;
application?: RulesSourceApplication;
isLoading?: boolean;
description?: ReactNode;
}
const DataSourceSection = ({
uid,
name,
application,
children,
loader,
isLoading = false,
description = null,
}: DataSourceSectionProps) => {
const styles = useStyles2(getStyles);
return (
<section aria-labelledby={`datasource-${uid}-heading`} role="listitem">
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator datasourceUid={uid} />}
<div className={styles.dataSourceSectionTitle}>
{loader ?? (
<Stack alignItems="center">
{application && <DataSourceIcon application={application} />}
<Text variant="body" weight="bold" element="h2" id={`datasource-${uid}-heading`}>
{name}
</Text>
{description && (
<>
{'·'}
{description}
</>
)}
<Spacer />
<WithReturnButton
title="alert rules"
component={
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
</LinkButton>
}
/>
</Stack>
)}
</div>
</Stack>
<div className={styles.itemsWrapper}>{children}</div>
</Stack>
</section>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
itemsWrapper: css({
position: 'relative',
marginLeft: theme.spacing(1.5),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: `-${theme.spacing(1.5)}`,
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}),
dataSourceSectionTitle: css({
background: theme.colors.background.secondary,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
interface LazyPaginationProps {
canMoveForward: boolean;
canMoveBackward: boolean;
nextPage: () => void;
previousPage: () => void;
}
function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
return (
<Stack direction="row" gap={1}>
<Button
aria-label={`previous page`}
size="sm"
variant="secondary"
onClick={previousPage}
disabled={!canMoveBackward}
>
<Icon name="angle-left" />
</Button>
<Button aria-label={`next page`} size="sm" variant="secondary" onClick={nextPage} disabled={!canMoveForward}>
<Icon name="angle-right" />
</Button>
</Stack>
);
}
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
import { useIntersection } from 'react-use';
type Props = {
handleLoad: () => void;
};
function LoadMoreHelper({ handleLoad }: Props) {
const intersectionRef = useRef<HTMLDivElement>(null);
const intersection = useIntersection(intersectionRef, {
root: null,
threshold: 1,
});
useEffect(() => {
const completelyInView = intersection && intersection.intersectionRatio > 0;
if (completelyInView) {
handleLoad();
}
}, [intersection, handleLoad]);
return <div ref={intersectionRef} data-testid="load-more-helper" />;
}
export default LoadMoreHelper;
@@ -0,0 +1,70 @@
import { render } from 'test/test-utils';
import { byTestId } from 'testing-library-selector';
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../mockApi';
import { grantUserPermissions } from '../mocks';
import { alertingFactory } from '../mocks/server/db';
import RuleList from './RuleList.v2';
// This tests only checks if proper components are rendered, so we mock them
// Both FilterView and GroupedView are tested in their own tests
jest.mock('./FilterView', () => ({
FilterView: () => <div data-testid="filter-view">Filter View</div>,
}));
jest.mock('./GroupedView', () => ({
GroupedView: () => <div data-testid="grouped-view">Grouped View</div>,
}));
const ui = {
filterView: byTestId('filter-view'),
groupedView: byTestId('grouped-view'),
};
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
setupMswServer();
alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
describe('RuleList v2', () => {
it('should show grouped view by default', () => {
render(<RuleList />);
expect(ui.groupedView.get()).toBeInTheDocument();
expect(ui.filterView.query()).not.toBeInTheDocument();
});
it('should show grouped view when invalid view parameter is provided', () => {
render(<RuleList />, {
historyOptions: {
initialEntries: ['/?view=invalid'],
},
});
expect(ui.groupedView.get()).toBeInTheDocument();
expect(ui.filterView.query()).not.toBeInTheDocument();
});
it('should show list view when "view=list" URL parameter is present', () => {
render(<RuleList />, { historyOptions: { initialEntries: ['/?view=list'] } });
expect(ui.filterView.get()).toBeInTheDocument();
expect(ui.groupedView.query()).not.toBeInTheDocument();
});
it('should show list view when a filter is applied', () => {
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } });
expect(ui.filterView.get()).toBeInTheDocument();
expect(ui.groupedView.query()).not.toBeInTheDocument();
});
});
@@ -1,370 +1,32 @@
import { css } from '@emotion/css';
import { PropsWithChildren, ReactNode, useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { withErrorBoundary } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import {
Dropdown,
Icon,
IconButton,
LinkButton,
Menu,
Pagination,
Stack,
Text,
useStyles2,
withErrorBoundary,
} from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { alertRuleApi } from '../api/alertRuleApi';
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { Spacer } from '../components/Spacer';
import { WithReturnButton } from '../components/WithReturnButton';
import RulesFilter from '../components/rules/Filter/RulesFilter';
import { getAllRulesSources, isGrafanaRulesSource } from '../utils/datasource';
import { equal, fromRule, fromRulerRule, hashRule, stringifyIdentifier } from '../utils/rule-id';
import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules';
import { createRelativeUrl } from '../utils/url';
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
import { useRulesFilter } from '../hooks/useFilteredRules';
import { useURLSearchParams } from '../hooks/useURLSearchParams';
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { DataSourceIcon } from './components/Namespace';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
import { LoadingIndicator } from './components/RuleGroup';
const noop = () => {};
const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
import { FilterView } from './FilterView';
import { GroupedView } from './GroupedView';
const RuleList = withErrorBoundary(
() => {
const ruleSources = getAllRulesSources();
const [queryParams] = useURLSearchParams();
const { filterState, hasActiveFilters } = useRulesFilter();
const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
const showListView = hasActiveFilters || view === 'list';
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
<RulesFilter onClear={() => {}} />
<Stack direction="column" gap={1}>
{ruleSources.map((ruleSource) => {
if (isGrafanaRulesSource(ruleSource)) {
return <GrafanaDataSourceLoader key={ruleSource} />;
} else {
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
}
})}
</Stack>
{showListView ? <FilterView filterState={filterState} /> : <GroupedView />}
</AlertingPageWrapper>
);
},
{ style: 'page' }
);
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
interface DataSourceLoaderProps {
name: string;
uid: string;
}
const GrafanaDataSourceLoader = () => {
return <DataSourceSection name="Grafana" application="grafana" isLoading={true}></DataSourceSection>;
};
const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => {
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
if (isLoading) {
return <DataSourceSection loader={<Skeleton width={250} height={16} />} />;
}
// 2. grab prometheus rule groups with max_groups if supported
if (dataSourceInfo) {
const rulerEnabled = Boolean(dataSourceInfo.rulerConfig);
return (
<PaginatedDataSourceLoader
ruleSourceName={dataSourceInfo.name}
rulerEnabled={rulerEnabled}
uid={uid}
name={name}
application={dataSourceInfo.application}
/>
);
}
return null;
};
interface PaginatedDataSourceLoaderProps extends Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'> {
ruleSourceName: string;
rulerEnabled?: boolean;
}
function PaginatedDataSourceLoader({
ruleSourceName,
rulerEnabled = false,
name,
uid,
application,
}: PaginatedDataSourceLoaderProps) {
const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({
ruleSourceName,
maxGroups: 25,
limitAlerts: 0,
excludeAlerts: true,
});
return (
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
<Stack direction="column" gap={1}>
{ruleNamespaces.map((namespace) => (
<ListSection
key={namespace.name}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" /> {namespace.name}
</Stack>
}
>
{namespace.groups.map((group) => (
<ListGroup
key={group.name}
name={group.name}
isOpen={false}
actions={
<>
<Dropdown
overlay={
<Menu>
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
<Menu.Item label="Re-order rules" icon="flip" />
<Menu.Divider />
<Menu.Item label="Export" icon="download-alt" />
<Menu.Item label="Delete" icon="trash-alt" destructive />
</Menu>
}
>
<IconButton name="ellipsis-h" aria-label="rule group actions" />
</Dropdown>
</>
}
>
{group.rules.map((rule) => {
const groupIdentifier: RuleGroupIdentifier = {
dataSourceName: ruleSourceName,
groupName: group.name,
namespaceName: namespace.name,
};
return (
<AlertRuleLoader
key={hashRule(rule)}
rule={rule}
groupIdentifier={groupIdentifier}
rulerEnabled={rulerEnabled}
/>
);
})}
</ListGroup>
))}
</ListSection>
))}
{!isLoading && <Pagination currentPage={1} numberOfPages={0} onNavigate={noop} />}
</Stack>
</DataSourceSection>
);
}
interface AlertRuleLoaderProps {
rule: Rule;
groupIdentifier: RuleGroupIdentifier;
rulerEnabled?: boolean;
}
function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) {
const { dataSourceName, namespaceName, groupName } = groupIdentifier;
const ruleIdentifier = fromRule(dataSourceName, namespaceName, groupName, rule);
const href = createViewLinkFromIdentifier(ruleIdentifier);
const originMeta = getRulePluginOrigin(rule);
// @TODO work with context API to propagate rulerConfig and such
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ rulesSourceName: dataSourceName });
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
const {
isLoading,
data: rulerRuleGroup,
// error,
} = useGetRuleGroupForNamespaceQuery(
{
namespace: namespaceName,
group: groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
},
{ skip: !dataSourceInfo?.rulerConfig }
);
const rulerRule = useMemo(() => {
if (!rulerRuleGroup) {
return;
}
return rulerRuleGroup.rules.find((rule) =>
equal(fromRulerRule(dataSourceName, namespaceName, groupName, rule), ruleIdentifier)
);
}, [dataSourceName, groupName, namespaceName, ruleIdentifier, rulerRuleGroup]);
// 1. get the rule from the ruler API with "ruleWithLocation"
// 1.1 skip this if this datasource does not have a ruler
//
// 2.1 render action buttons
// 2.2 render provisioning badge and contact point metadata, etc.
const actions = useMemo(() => {
if (!rulerEnabled) {
return null;
}
if (isLoading) {
return <ActionsLoader />;
}
if (rulerRule) {
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
}
return null;
}, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]);
if (isAlertingRule(rule)) {
return (
<AlertRuleListItem
name={rule.name}
href={href}
summary={rule.annotations?.summary}
state={rule.state}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
instancesCount={undefined}
actions={actions}
origin={originMeta}
/>
);
}
if (isRecordingRule(rule)) {
return (
<RecordingRuleListItem
name={rule.name}
href={href}
health={rule.health}
error={rule.lastError}
labels={rule.labels}
isProvisioned={undefined}
actions={null}
origin={originMeta}
/>
);
}
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
}
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(identifier.ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
interface DataSourceSectionProps extends PropsWithChildren {
uid?: string;
name?: string;
loader?: ReactNode;
application?: RulesSourceApplication;
isLoading?: boolean;
description?: ReactNode;
}
const DataSourceSection = ({
uid,
name,
application,
children,
loader,
isLoading = false,
description = null,
}: DataSourceSectionProps) => {
const styles = useStyles2(getStyles);
return (
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator />}
<div className={styles.dataSourceSectionTitle}>
{loader ?? (
<Stack alignItems="center">
{application && <DataSourceIcon application={application} />}
{name && (
<Text variant="body" weight="bold">
{name}
</Text>
)}
{description && (
<>
{'·'}
{description}
</>
)}
<Spacer />
{uid && (
<WithReturnButton
title="alert rules"
component={
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
</LinkButton>
}
/>
)}
</Stack>
)}
</div>
</Stack>
<div className={styles.itemsWrapper}>{children}</div>
</Stack>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
itemsWrapper: css({
position: 'relative',
marginLeft: theme.spacing(1.5),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: `-${theme.spacing(1.5)}`,
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}),
dataSourceSectionTitle: css({
background: theme.colors.background.secondary,
padding: `${theme.spacing(1)} ${theme.spacing(1.5)}`,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
}),
});
export default RuleList;
@@ -10,14 +10,10 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { usePagination } from '..//hooks/usePagination';
import { calculateTotalInstances } from '../components/rule-viewer/RuleViewer';
import { ListSection } from '../rule-list/components/ListSection';
import { groupIdentifier } from '../utils/groupIdentifier';
import { createViewLink } from '../utils/misc';
import { hashRule } from '../utils/rule-id';
import {
getRuleGroupLocationFromCombinedRule,
getRulePluginOrigin,
isAlertingRule,
isGrafanaRulerRule,
} from '../utils/rules';
import { getRulePluginOrigin, isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
import { AlertRuleListItem } from './components/AlertRuleListItem';
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
@@ -102,7 +98,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
const instancesCount = isAlertingRule(rule.promRule) ? calculateTotalInstances(rule.instanceTotals) : undefined;
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
const groupId = groupIdentifier.fromCombinedRule(rule);
if (!promRule) {
return null;
@@ -126,12 +122,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
group={rule.group.name}
actions={
rule.rulerRule ? (
<RuleActionsButtons
compact
rule={rule.rulerRule}
promRule={promRule}
groupIdentifier={groupIdentifier}
/>
<RuleActionsButtons compact rule={rule.rulerRule} promRule={promRule} groupIdentifier={groupId} />
) : (
<ActionsLoader />
)
@@ -3,10 +3,16 @@ import pluralize from 'pluralize';
import { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Rule, RuleGroupIdentifier, RuleHealth } from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import {
GrafanaRulesSourceSymbol,
Rule,
RuleGroupIdentifierV2,
RuleHealth,
RulesSourceIdentifier,
} from 'app/types/unified-alerting';
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
import { logError } from '../../Analytics';
import { MetaText } from '../../components/MetaText';
@@ -18,6 +24,7 @@ import { createContactPointSearchLink } from '../../utils/misc';
import { RulePluginOrigin } from '../../utils/rules';
import { ListItem } from './ListItem';
import { DataSourceIcon } from './Namespace';
import { RuleListIcon } from './RuleListIcon';
import { calculateNextEvaluationEstimate } from './util';
@@ -36,6 +43,8 @@ interface AlertRuleListItemProps {
instancesCount?: number;
namespace?: string;
group?: string;
rulesSource?: RulesSourceIdentifier;
application?: RulesSourceApplication;
// used for alert rules that use simplified routing
contactPoint?: string;
actions?: ReactNode;
@@ -57,6 +66,8 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
instancesCount = 0,
namespace,
group,
rulesSource,
application,
contactPoint,
labels,
origin,
@@ -67,7 +78,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
if (namespace && group) {
metadata.push(
<Text color="secondary" variant="bodySmall">
<RuleLocation namespace={namespace} group={group} />
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
</Text>
);
}
@@ -142,13 +153,27 @@ type RecordingRuleListItemProps = Omit<AlertRuleListItemProps, 'summary' | 'stat
export function RecordingRuleListItem({
name,
namespace,
group,
rulesSource,
application,
href,
health,
isProvisioned,
error,
isPaused,
origin,
actions,
}: RecordingRuleListItemProps) {
const metadata: ReactNode[] = [];
if (namespace && group) {
metadata.push(
<Text color="secondary" variant="bodySmall">
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
</Text>
);
}
return (
<ListItem
title={
@@ -165,8 +190,8 @@ export function RecordingRuleListItem({
}
description={<Summary error={error} />}
icon={<RuleListIcon recording={true} health={health} isPaused={isPaused} />}
actions={null}
meta={[]}
actions={actions}
meta={metadata}
/>
);
}
@@ -236,13 +261,19 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu
interface UnknownRuleListItemProps {
rule: Rule;
groupIdentifier: RuleGroupIdentifier;
groupIdentifier: RuleGroupIdentifierV2;
}
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
const styles = useStyles2(getStyles);
const { rulesSource, namespace, groupName } = groupIdentifier;
const ruleContext = { ...groupIdentifier, name: rule.name };
const ruleContext = {
name: rule.name,
groupName,
namespace: JSON.stringify(namespace),
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
};
logError(new Error('unknown rule type'), ruleContext);
return (
@@ -262,18 +293,34 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
interface RuleLocationProps {
namespace: string;
group: string;
rulesSource?: RulesSourceIdentifier;
application?: RulesSourceApplication;
}
export const RuleLocation = ({ namespace, group }: RuleLocationProps) => (
<Stack direction="row" alignItems="center" gap={0.5}>
<Icon size="xs" name="folder" />
<Stack direction="row" alignItems="center" gap={0}>
{namespace}
<Icon size="sm" name="angle-right" />
{group}
// @TODO make the datasource / namespace / group click-able to allow further filtering of the list
export const RuleLocation = ({ namespace, group, rulesSource, application }: RuleLocationProps) => {
const isGrafanaApp = application === 'grafana';
const isDataSourceApp = !!rulesSource && !!application && !isGrafanaApp;
return (
<Stack direction="row" alignItems="center" gap={0.5}>
{isGrafanaApp && <Icon size="xs" name="folder" />}
{isDataSourceApp && (
<Tooltip content={rulesSource.name}>
<span>
<DataSourceIcon application={application} size={14} />
</span>
</Tooltip>
)}
<Stack direction="row" alignItems="center" gap={0}>
{namespace}
<Icon size="sm" name="angle-right" />
{group}
</Stack>
</Stack>
</Stack>
);
);
};
const getStyles = (theme: GrafanaTheme2) => ({
alertListItemContainer: css({
@@ -60,7 +60,7 @@ const GroupHeader = (props: GroupHeaderProps) => {
onClick={onToggle}
aria-label={t('common.collapse', 'Collapse')}
/>
<Text truncate variant="body">
<Text truncate variant="body" element="h4">
{name}
</Text>
</Stack>
@@ -12,14 +12,15 @@ interface ListItemProps {
meta?: ReactNode[];
metaRight?: ReactNode[];
actions?: ReactNode;
'data-testid'?: string;
}
export const ListItem = (props: ListItemProps) => {
const styles = useStyles2(getStyles);
const { icon = null, title, description, meta, metaRight, actions } = props;
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId } = props;
return (
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false">
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false" data-testid={testId}>
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
{/* icon */}
{icon}
@@ -47,25 +47,26 @@ const Namespace = ({ children, name, href, application }: NamespaceProps) => {
interface NamespaceIconProps {
application?: RulesSourceApplication;
size?: number;
}
export const DataSourceIcon = ({ application }: NamespaceIconProps) => {
export const DataSourceIcon = ({ application, size = 16 }: NamespaceIconProps) => {
switch (application) {
case PromApplication.Prometheus:
return (
<img
width={16}
height={16}
width={size}
height={size}
src="public/app/plugins/datasource/prometheus/img/prometheus_logo.svg"
alt="Prometheus"
/>
);
case PromApplication.Mimir:
return (
<img width={16} height={16} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
<img width={size} height={size} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
);
case 'Loki':
return <img width={16} height={16} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
return <img width={size} height={size} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
case 'grafana':
default:
return <Icon name="grafana" />;
@@ -11,7 +11,7 @@ import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/s
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { useDispatch } from 'app/types';
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
@@ -24,7 +24,7 @@ import { createRelativeUrl } from '../../utils/url';
interface Props {
rule: RulerRuleDTO;
promRule: Rule;
groupIdentifier: RuleGroupIdentifier;
groupIdentifier: RuleGroupIdentifierV2;
/**
* Should we show the buttons in a "compact" state?
* i.e. without text and using smaller button sizes
@@ -34,7 +34,7 @@ interface Props {
// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed.
// This is only done to keep the new list behind a feature flag and limit changes in the existing components
export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }: Props) => {
export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }: Props) {
const dispatch = useDispatch();
const redirectToListView = compact ? false : true;
@@ -46,7 +46,6 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
const { namespaceName, groupName, dataSourceName } = groupIdentifier;
const { hasActiveFilters } = useRulesFilter();
const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance);
@@ -58,11 +57,9 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
const buttons: JSX.Element[] = [];
const buttonSize = compact ? 'sm' : 'md';
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule);
const identifier = ruleId.fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rule);
if (canEditRule) {
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule);
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
buttons.push(
@@ -109,6 +106,6 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
)}
</Stack>
);
};
}
export const ActionsLoader = () => <Skeleton width={50} height={16} />;
@@ -75,11 +75,11 @@ export const EvaluationGroupLoader = ({
);
};
export const LoadingIndicator = () => {
export const LoadingIndicator = ({ datasourceUid }: { datasourceUid: string }) => {
const [ref, { width }] = useMeasure<HTMLDivElement>();
return (
<div ref={ref}>
<div ref={ref} data-testid={`ds-loading-indicator-${datasourceUid}`}>
<LoadingBar width={width} />
</div>
);
@@ -0,0 +1,175 @@
import { merge } from 'ix/asynciterable/merge';
import { filter, flatMap, map } from 'ix/asynciterable/operators';
import { compact } from 'lodash';
import { useCallback } from 'react';
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
import { DataSourceRuleGroupIdentifier, ExternalRulesSourceIdentifier } from 'app/types/unified-alerting';
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { prometheusApi } from '../../api/prometheusApi';
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 { isAlertingRule } from '../../utils/rules';
export interface RuleWithOrigin {
rule: PromRuleDTO;
groupIdentifier: DataSourceRuleGroupIdentifier;
}
const { useLazyGroupsQuery } = prometheusApi;
export function useFilteredRulesIteratorProvider() {
const [fetchGroups] = useLazyGroupsQuery();
const allExternalRulesSources = getExternalRulesSources();
/**
* This async generator will continue to yield rule groups and will keep fetching backend pages as long as the consumer
* is iterating.
*/
const fetchRuleSourceGroups = useCallback(
async function* (ruleSource: ExternalRulesSourceIdentifier, maxGroups: number) {
const response = await fetchGroups({ ruleSource: { uid: ruleSource.uid }, groupLimit: maxGroups });
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
while (lastToken) {
const response = await fetchGroups({
ruleSource: { uid: ruleSource.uid },
groupNextToken: lastToken,
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
}
lastToken = response.data?.data?.groupNextToken;
}
},
[fetchGroups]
);
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number) => {
const ruleSourcesToFetchFrom = filterState.dataSourceNames.length
? filterState.dataSourceNames.map((ds) => ({ name: ds, uid: getDatasourceAPIUid(ds) }))
: allExternalRulesSources;
// This split into the first one and the rest is only for compatibility with the merge function from ix
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => fetchRuleSourceGroups(ds, groupLimit));
return merge(source, ...iterables).pipe(
filter(([_, group]) => groupFilter(group, filterState)),
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 { getFilteredRulesIterator };
}
function mapRuleToRuleWithOrigin(
rulesSource: ExternalRulesSourceIdentifier,
group: PromRuleGroupDTO,
rule: PromRuleDTO
): RuleWithOrigin {
return {
rule,
groupIdentifier: {
rulesSource,
namespace: { name: group.file },
groupName: group.name,
groupOrigin: 'datasource',
},
};
}
/**
* 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.includes(filterState.namespace)) {
return false;
}
if (filterState.groupName && !name.includes(filterState.groupName)) {
return false;
}
return true;
}
function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
const { name, labels = {}, health, type } = rule;
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => name.includes(word))) {
return false;
}
if (filterState.ruleName && !name.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 (!isAlertingRule(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;
}
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 };
}
}
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePrevious } from 'react-use';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { groupRulesByFileName } from '../../api/prometheus';
import { prometheusApi } from '../../api/prometheusApi';
import { isLoading, useAsync } from '../../hooks/useAsync';
import { getDatasourceAPIUid } from '../../utils/datasource';
const { useLazyGroupsQuery } = prometheusApi;
export function usePaginatedPrometheusRuleNamespaces(ruleSourceName: string, pageSize: number) {
const [currentPage, setCurrentPage] = useState(1);
const [groups, setGroups] = useState<PromRuleGroupDTO[]>([]);
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
const { groupsGenerator } = usePrometheusGroupsGenerator(ruleSourceName, pageSize);
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
let done = false;
const currentGroups: PromRuleGroupDTO[] = [];
while (currentGroups.length < groupsCount) {
const group = await groupsGenerator.next();
if (group.done) {
done = true;
break;
}
currentGroups.push(group.value);
}
if (done) {
const groupsTotal = groups.length + currentGroups.length;
setLastPage(Math.ceil(groupsTotal / pageSize));
}
setGroups((groups) => [...groups, ...currentGroups]);
});
const fetchInProgress = isLoading(groupsRequestState);
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
const canMoveBackward = currentPage > 1 && !fetchInProgress;
const nextPage = useCallback(async () => {
if (canMoveForward) {
setCurrentPage((page) => page + 1);
}
}, [canMoveForward]);
const previousPage = useCallback(async () => {
if (canMoveBackward) {
setCurrentPage((page) => page - 1);
}
}, [canMoveBackward]);
// groups.length - pageSize to have one more page loaded to prevent flickering with loading state
// lastPage === undefined because 0 is falsy but a value which should stop fetching (e.g for broken data sources)
const shouldFetchNextPage = groups.length - pageSize < pageSize * currentPage && lastPage === undefined;
if (shouldFetchNextPage && !fetchInProgress) {
fetchMoreGroups(pageSize);
}
const pageNamespaces = useMemo(() => {
const pageGroups = groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// groupRulesByFileName mutates the array and RTKQ query freezes the response data
return groupRulesByFileName(structuredClone(pageGroups), ruleSourceName);
}, [groups, ruleSourceName, currentPage, pageSize]);
return { isLoading: fetchInProgress, page: pageNamespaces, nextPage, previousPage, canMoveForward, canMoveBackward };
}
function usePrometheusGroupsGenerator(ruleSourceName: string, pageSize: number) {
const [fetchGroups, { isLoading }] = useLazyGroupsQuery();
const prevRuleSourceName = usePrevious(ruleSourceName);
// 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 just fetch everything in one go
// For paginated we fetch the next page when needed
const getGroups = useCallback(
async function* (ruleSourceName: string, maxGroups: number) {
const ruleSourceUid = getDatasourceAPIUid(ruleSourceName);
const response = await fetchGroups({
ruleSource: { uid: ruleSourceUid },
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
let lastToken: string | undefined = undefined;
if (response.data?.data?.groupNextToken) {
lastToken = response.data.data.groupNextToken;
}
while (lastToken) {
const response = await fetchGroups({
ruleSource: { uid: ruleSourceUid },
groupNextToken: lastToken,
groupLimit: maxGroups,
});
if (!response.isSuccess) {
return;
}
if (response.data?.data) {
yield* response.data.data.groups;
}
lastToken = response.data?.data?.groupNextToken;
}
},
[fetchGroups]
);
const [groupsGenerator, setGroupsGenerator] = useState<AsyncGenerator<PromRuleGroupDTO, void, unknown>>(
getGroups(ruleSourceName, pageSize)
);
const resetGenerator = useCallback(() => {
setGroupsGenerator(getGroups(ruleSourceName, pageSize));
}, [ruleSourceName, getGroups, pageSize]);
if (prevRuleSourceName && prevRuleSourceName !== ruleSourceName) {
resetGenerator();
}
useEffect(() => {
const currentGenerator = groupsGenerator;
return () => {
currentGenerator.return();
};
}, [groupsGenerator]);
return { groupsGenerator, isLoading };
}
@@ -9,7 +9,12 @@ import {
AlertmanagerChoice,
} from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { RulesSource } from 'app/types/unified-alerting';
import {
ExternalRulesSourceIdentifier,
GrafanaRulesSourceSymbol,
RulesSource,
RulesSourceUid,
} from 'app/types/unified-alerting';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { PERMISSIONS_CONTACT_POINTS } from '../components/contact-points/permissions';
@@ -23,8 +28,6 @@ import { getAllDataSources } from './config';
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
export type RulesSourceIdentifier = { rulesSourceName: string } | { uid: string };
export enum DataSourceType {
Alertmanager = 'alertmanager',
Loki = 'loki',
@@ -211,6 +214,13 @@ export function getAllRulesSourceNames(): string[] {
return availableRulesSources;
}
export function getExternalRulesSources(): ExternalRulesSourceIdentifier[] {
return getRulesDataSources().map((ds) => ({
name: ds.name,
uid: ds.uid,
}));
}
export function getAllRulesSources(): RulesSource[] {
const availableRulesSources: RulesSource[] = getRulesDataSources();
@@ -293,13 +303,13 @@ export function getDatasourceAPIUid(dataSourceName: string) {
return ds.uid;
}
export function getDataSourceUID(rulesSourceIdentifier: RulesSourceIdentifier) {
export function getDataSourceUID(rulesSourceIdentifier: { rulesSourceName: string } | { uid: RulesSourceUid }) {
if ('uid' in rulesSourceIdentifier) {
return rulesSourceIdentifier.uid;
}
if (rulesSourceIdentifier.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) {
return GRAFANA_RULES_SOURCE_NAME;
return GrafanaRulesSourceSymbol;
}
const ds = getRulesDataSource(rulesSourceIdentifier.rulesSourceName);
@@ -0,0 +1,28 @@
import { CombinedRule, GrafanaRulesSourceSymbol, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource';
import { isGrafanaRulerRule } from './rules';
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
return {
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME },
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
groupName: rule.group.name,
groupOrigin: 'grafana',
};
}
const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource);
const rulesSourceUid = getDatasourceAPIUid(rulesSourceName);
return {
rulesSource: { uid: rulesSourceUid, name: rulesSourceName },
namespace: { name: rule.namespace.name },
groupName: rule.group.name,
groupOrigin: 'datasource',
};
}
export const groupIdentifier = {
fromCombinedRule,
};
@@ -16,6 +16,7 @@ import { SortOrder } from 'app/plugins/panel/alertlist/types';
import {
Alert,
CombinedRule,
DataSourceRuleGroupIdentifier,
FilterState,
RuleIdentifier,
RulesSource,
@@ -24,6 +25,7 @@ import {
import {
GrafanaAlertState,
PromAlertingRuleState,
PromRuleDTO,
mapStateWithReasonToBaseState,
} from 'app/types/unified-alerting-dto';
@@ -43,6 +45,19 @@ export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, retu
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
export function createViewLinkV2(
groupIdentifier: DataSourceRuleGroupIdentifier,
rule: PromRuleDTO,
returnTo?: string
): string {
const ruleSourceName = groupIdentifier.rulesSource.name;
const identifier = ruleId.fromRule(ruleSourceName, groupIdentifier.namespace.name, groupIdentifier.groupName, rule);
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
const paramSource = encodeURIComponent(ruleSourceName);
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
}
export function createExploreLink(datasource: DataSourceRef, query: string) {
const { uid, type } = datasource;
@@ -7,11 +7,13 @@ import {
EditableRuleIdentifier,
Rule,
RuleGroupIdentifier,
RuleGroupIdentifierV2,
RuleIdentifier,
RuleWithLocation,
} from 'app/types/unified-alerting';
import { Annotations, Labels, PromRuleType, RulerCloudRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
import { logError } from '../Analytics';
import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
@@ -44,6 +46,21 @@ export function fromRulerRule(
} satisfies CloudRuleIdentifier;
}
export function fromRulerRuleAndGroupIdentifierV2(
ruleGroup: RuleGroupIdentifierV2,
rule: RulerRuleDTO
): EditableRuleIdentifier {
if (ruleGroup.groupOrigin === 'grafana') {
if (isGrafanaRulerRule(rule)) {
return { uid: rule.grafana_alert.uid, ruleSourceName: 'grafana' };
}
logError(new Error('Rule is not a Grafana Ruler rule'));
throw new Error('Rule is not a Grafana Ruler rule');
}
return fromRulerRule(ruleGroup.rulesSource.name, ruleGroup.namespace.name, ruleGroup.groupName, rule);
}
export function fromRulerRuleAndRuleGroupIdentifier(
ruleGroup: RuleGroupIdentifier,
rule: RulerRuleDTO
+1
View File
@@ -169,6 +169,7 @@ export interface PromResponse<T> {
export type PromRulesResponse = PromResponse<{
groups: PromRuleGroupDTO[];
groupNextToken?: string;
totals?: AlertGroupTotals;
}>;
+41 -1
View File
@@ -2,6 +2,7 @@
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
import { PromOptions } from '@grafana/prometheus';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { LokiOptions } from 'app/plugins/datasource/loki/types';
import {
@@ -96,6 +97,7 @@ export interface RulesSourceResult {
namespaces?: RuleNamespace[];
}
/** @deprecated use RulesSourceIdentifier instead */
export type RulesSource = DataSourceInstanceSettings<PromOptions | LokiOptions> | 'grafana';
// combined prom and ruler result
@@ -149,7 +151,21 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
rule: T;
}
// identifier for where we can find a RuleGroup
export const GrafanaRulesSourceSymbol = Symbol('grafana');
export type RulesSourceUid = string | typeof GrafanaRulesSourceSymbol;
export interface ExternalRulesSourceIdentifier {
uid: string;
name: string;
}
export interface GrafanaRulesSourceIdentifier {
uid: typeof GrafanaRulesSourceSymbol;
name: typeof GRAFANA_RULES_SOURCE_NAME;
}
export type RulesSourceIdentifier = ExternalRulesSourceIdentifier | GrafanaRulesSourceIdentifier;
/** @deprecated use RuleGroupIdentifierV2 instead */
export interface RuleGroupIdentifier {
dataSourceName: string;
/** ⚠️ use the Grafana folder UID for Grafana-managed rules */
@@ -157,6 +173,30 @@ export interface RuleGroupIdentifier {
groupName: string;
}
export interface GrafanaNamespaceIdentifier {
uid: string;
}
export interface DataSourceNamespaceIdentifier {
name: string;
}
export interface GrafanaRuleGroupIdentifier {
rulesSource: GrafanaRulesSourceIdentifier;
groupName: string;
namespace: GrafanaNamespaceIdentifier;
groupOrigin: 'grafana';
}
export interface DataSourceRuleGroupIdentifier {
rulesSource: ExternalRulesSourceIdentifier;
groupName: string;
namespace: DataSourceNamespaceIdentifier;
groupOrigin: 'datasource';
}
export type RuleGroupIdentifierV2 = GrafanaRuleGroupIdentifier | DataSourceRuleGroupIdentifier;
export type CombinedRuleWithLocation = CombinedRule & RuleGroupIdentifier;
export interface PromRuleWithLocation {
+4
View File
@@ -479,6 +479,10 @@
},
"rule-list": {
"configure-datasource": "Configure",
"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."
},
"new-alert-rule": "New alert rule"
},
"rule-state": {
@@ -479,6 +479,10 @@
},
"rule-list": {
"configure-datasource": "Cőʼnƒįģūřę",
"filter-view": {
"no-more-results": "Ńő mőřę řęşūľŧş – şĥőŵįʼnģ {{numberOfRules}} řūľęş",
"no-rules-found": "Ńő äľęřŧ őř řęčőřđįʼnģ řūľęş mäŧčĥęđ yőūř čūřřęʼnŧ şęŧ őƒ ƒįľŧęřş."
},
"new-alert-rule": "Ńęŵ äľęřŧ řūľę"
},
"rule-state": {
+70 -1
View File
@@ -9511,7 +9511,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0":
"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0":
version: 22.9.0
resolution: "@types/node@npm:22.9.0"
dependencies:
@@ -9538,6 +9538,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:>=10.0.0, @types/node@npm:>=13.7.4":
version: 22.10.1
resolution: "@types/node@npm:22.10.1"
dependencies:
undici-types: "npm:~6.20.0"
checksum: 10/c802a526da2f3fa3ccefd00a71244e7cb825329951719e79e8fec62b1dbc2855388c830489770611584665ce10be23c05ed585982038b24924e1ba2c2cce03fd
languageName: node
linkType: hard
"@types/nodemailer@npm:*":
version: 6.4.15
resolution: "@types/nodemailer@npm:6.4.15"
@@ -11727,6 +11736,13 @@ __metadata:
languageName: node
linkType: hard
"bezier-easing@npm:^2.1.0":
version: 2.1.0
resolution: "bezier-easing@npm:2.1.0"
checksum: 10/086dfd042ccf91c3a9de811b635381aa6580e9f83d1951ed4ce4d4007645d8f3cebd491cb6259719ce9320df213316b99dd8e39e78997944b1c252a1d4b424b5
languageName: node
linkType: hard
"big.js@npm:^5.2.2":
version: 5.2.2
resolution: "big.js@npm:5.2.2"
@@ -13492,6 +13508,13 @@ __metadata:
languageName: node
linkType: hard
"css-mediaquery@npm:^0.1.2":
version: 0.1.2
resolution: "css-mediaquery@npm:0.1.2"
checksum: 10/f2f7512daa015f98b82bd65bbdd7c2100b16dddf22784bde2a8085f10f11e8775da57108016fe767f5e400afe64581c15d4e649c47725af7eac5f4094fa7ae43
languageName: node
linkType: hard
"css-minimizer-webpack-plugin@npm:6.0.0":
version: 6.0.0
resolution: "css-minimizer-webpack-plugin@npm:6.0.0"
@@ -16663,6 +16686,15 @@ __metadata:
languageName: node
linkType: hard
"fishery@npm:^2.2.2":
version: 2.2.2
resolution: "fishery@npm:2.2.2"
dependencies:
lodash.mergewith: "npm:^4.6.2"
checksum: 10/68120995b0cd7827f0d50ef3b552a74289c99fa6195cafb6e5d376aa14421f12873274e1fc5aa101073fdae35ad6543cb5b04687bb295d8c37ff3b99dbdeb9ae
languageName: node
linkType: hard
"flat-cache@npm:^3.0.4":
version: 3.1.1
resolution: "flat-cache@npm:3.1.1"
@@ -17724,6 +17756,7 @@ __metadata:
fast-deep-equal: "npm:^3.1.3"
fast-json-patch: "npm:3.1.1"
file-saver: "npm:2.0.5"
fishery: "npm:^2.2.2"
fork-ts-checker-webpack-plugin: "npm:9.0.2"
glob: "npm:11.0.0"
history: "npm:4.10.1"
@@ -17736,6 +17769,7 @@ __metadata:
immer: "npm:10.1.1"
immutable: "npm:4.3.7"
ini: "npm:^4.1.3"
ix: "npm:^7.0.0"
jest: "npm:29.7.0"
jest-canvas-mock: "npm:2.5.2"
jest-date-mock: "npm:1.0.10"
@@ -17746,6 +17780,7 @@ __metadata:
jest-watch-typeahead: "npm:^2.2.2"
jquery: "npm:3.7.1"
js-yaml: "npm:^4.1.0"
jsdom-testing-mocks: "npm:^1.13.1"
json-markup: "npm:^1.1.0"
json-source-map: "npm:0.6.1"
jsurl: "npm:^0.1.5"
@@ -19633,6 +19668,16 @@ __metadata:
languageName: node
linkType: hard
"ix@npm:^7.0.0":
version: 7.0.0
resolution: "ix@npm:7.0.0"
dependencies:
"@types/node": "npm:>=13.7.4"
tslib: "npm:^2.6.2"
checksum: 10/913cfbf645e04820f831bb2bd74f51e734284aa6b64819959964aaa2d8ee88ef246460f5a1cb4d71b4bda07dab241e3098210342dfb05ff73cfd20c9b2797f51
languageName: node
linkType: hard
"jackspeak@npm:^3.1.2":
version: 3.1.2
resolution: "jackspeak@npm:3.1.2"
@@ -20313,6 +20358,16 @@ __metadata:
languageName: node
linkType: hard
"jsdom-testing-mocks@npm:^1.13.1":
version: 1.13.1
resolution: "jsdom-testing-mocks@npm:1.13.1"
dependencies:
bezier-easing: "npm:^2.1.0"
css-mediaquery: "npm:^0.1.2"
checksum: 10/434acae65fc89f4d8e0e2dc23b830ebaf4568483d5127da60bf825c8357c489955105b98a5d95ed5c4e1ed48f2fa5369b138f141d55b290cd656008493ac6a65
languageName: node
linkType: hard
"jsdom@npm:^20.0.0":
version: 20.0.2
resolution: "jsdom@npm:20.0.2"
@@ -21196,6 +21251,13 @@ __metadata:
languageName: node
linkType: hard
"lodash.mergewith@npm:^4.6.2":
version: 4.6.2
resolution: "lodash.mergewith@npm:4.6.2"
checksum: 10/aea75a4492541a4902ac7e551dc6c54b722da0c187f84385d02e8fc33a7ae3454b837822446e5f63fcd5ad1671534ea408740b776670ea4d9c7890b10105fce0
languageName: node
linkType: hard
"lodash.once@npm:^4.1.1":
version: 4.1.1
resolution: "lodash.once@npm:4.1.1"
@@ -29777,6 +29839,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.20.0":
version: 6.20.0
resolution: "undici-types@npm:6.20.0"
checksum: 10/583ac7bbf4ff69931d3985f4762cde2690bb607844c16a5e2fbb92ed312fe4fa1b365e953032d469fa28ba8b224e88a595f0b10a449332f83fa77c695e567dbe
languageName: node
linkType: hard
"undici@npm:^6.19.5":
version: 6.19.8
resolution: "undici@npm:6.19.8"