From 8055d69ad2233ddd6735c205948ea8339102cfcc Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 12 Dec 2024 14:18:52 +0100 Subject: [PATCH] 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 * 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 Co-authored-by: Tom Ratcliffe --- package.json | 3 + .../unified/api/featureDiscoveryApi.ts | 13 +- .../alerting/unified/api/prometheusApi.ts | 41 ++ .../unified/components/MenuItemPauseRule.tsx | 4 +- .../components/rule-viewer/AlertRuleMenu.tsx | 8 +- .../components/rule-viewer/DeleteModal.tsx | 22 +- .../rule-viewer/RuleViewer.test.tsx | 73 ++-- .../rules/Filter/RulesFilter.v1.tsx | 42 +- .../rules/Filter/RulesViewModeSelector.tsx | 71 ++++ .../rules/RuleActionsButtons.test.tsx | 13 +- .../components/rules/RuleActionsButtons.tsx | 9 +- .../components/rules/RulesTable.test.tsx | 5 +- .../ruleGroup/usePauseAlertRule.test.tsx | 8 +- .../hooks/ruleGroup/usePauseAlertRule.ts | 15 +- .../unified/hooks/useAbilities.test.tsx | 5 +- .../alerting/unified/hooks/useAbilities.ts | 10 +- .../unified/hooks/useFilteredRules.ts | 1 - .../unified/hooks/useIsRuleEditable.ts | 3 +- .../unified/mocks/server/configure.ts | 8 +- .../alerting/unified/mocks/server/db.ts | 79 ++++ .../alerting/unified/mocks/server/utils.ts | 33 ++ .../plugins/useRulePluginLinkExtensions.ts | 16 +- .../unified/rule-list/AlertRuleLoader.tsx | 122 ++++++ .../unified/rule-list/FilterView.test.tsx | 108 ++++++ .../alerting/unified/rule-list/FilterView.tsx | 155 ++++++++ .../unified/rule-list/GroupedView.test.tsx | 108 ++++++ .../unified/rule-list/GroupedView.tsx | 278 ++++++++++++++ .../unified/rule-list/LoadMoreHelper.tsx | 25 ++ .../unified/rule-list/RuleList.v2.test.tsx | 70 ++++ .../unified/rule-list/RuleList.v2.tsx | 362 +----------------- .../alerting/unified/rule-list/StateView.tsx | 17 +- .../components/AlertRuleListItem.tsx | 81 +++- .../rule-list/components/ListGroup.tsx | 2 +- .../unified/rule-list/components/ListItem.tsx | 5 +- .../rule-list/components/Namespace.tsx | 11 +- .../components/RuleActionsButtons.V2.tsx | 13 +- .../rule-list/components/RuleGroup.tsx | 4 +- .../hooks/useFilteredRulesIterator.ts | 175 +++++++++ .../usePaginatedPrometheusRuleNamespaces.tsx | 146 +++++++ .../alerting/unified/utils/datasource.ts | 20 +- .../alerting/unified/utils/groupIdentifier.ts | 28 ++ .../features/alerting/unified/utils/misc.ts | 15 + .../alerting/unified/utils/rule-id.ts | 17 + public/app/types/unified-alerting-dto.ts | 1 + public/app/types/unified-alerting.ts | 42 +- public/locales/en-US/grafana.json | 4 + public/locales/pseudo-LOCALE/grafana.json | 4 + yarn.lock | 71 +++- 48 files changed, 1835 insertions(+), 531 deletions(-) create mode 100644 public/app/features/alerting/unified/api/prometheusApi.ts create mode 100644 public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx create mode 100644 public/app/features/alerting/unified/mocks/server/db.ts create mode 100644 public/app/features/alerting/unified/rule-list/AlertRuleLoader.tsx create mode 100644 public/app/features/alerting/unified/rule-list/FilterView.test.tsx create mode 100644 public/app/features/alerting/unified/rule-list/FilterView.tsx create mode 100644 public/app/features/alerting/unified/rule-list/GroupedView.test.tsx create mode 100644 public/app/features/alerting/unified/rule-list/GroupedView.tsx create mode 100644 public/app/features/alerting/unified/rule-list/LoadMoreHelper.tsx create mode 100644 public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx create mode 100644 public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusRuleNamespaces.tsx create mode 100644 public/app/features/alerting/unified/utils/groupIdentifier.ts diff --git a/package.json b/package.json index 4fa95ba0d77..5981f55e46d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts index 51cacdca1eb..82e55c1875a 100644 --- a/public/app/features/alerting/unified/api/featureDiscoveryApi.ts +++ b/public/app/features/alerting/unified/api/featureDiscoveryApi.ts @@ -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({ + discoverDsFeatures: build.query({ 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, diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts new file mode 100644 index 00000000000..eb9bf877ae1 --- /dev/null +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -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({ + 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, + }, + }), + }), + }), +}); diff --git a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx index 8b85b20266f..e6d170b0506 100644 --- a/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx +++ b/public/app/features/alerting/unified/components/MenuItemPauseRule.tsx @@ -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 */ diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index 283a68c2b8c..e5502ca14da 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -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' && ( )} {canSilence && } diff --git a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx index 420628acf7d..6586d1c9088 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/DeleteModal.tsx @@ -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(); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index e698417ca52..be7153bc131 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -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( + { + 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({ - 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); diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index 0345383789e..5563a823721 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -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) => {
- +
{hasActiveFilters && ( diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx new file mode 100644 index 00000000000..a0c63afa419 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx @@ -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> = [ + { 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 ; +} + +const LegacyViewOptions: Array> = [ + { 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 ; +} + +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; diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx index 7f5abbdd946..2f105fffe5f 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.test.tsx @@ -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(, { 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(); + render(); 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' ); }); }); diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index 04040a39161..0201bec3161 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -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)} diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index 1f3034fed48..22cb1bb8b7c 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -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) => { diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx index 58dd56f239a..71e8df7a04d 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx +++ b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.test.tsx @@ -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(() => {}); }; diff --git a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts index 3eb31a4e4d2..2788f8d7011 100644 --- a/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts +++ b/public/app/features/alerting/unified/hooks/ruleGroup/usePauseAlertRule.ts @@ -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, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx index f9d393b3a97..4f84677370f 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx +++ b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx @@ -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() }); diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index a2ac44f667f..27f0fa0e178 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 { - const rulesSourceName = groupIdentifier.dataSourceName; + const rulesSourceName = groupIdentifier.rulesSource.name; const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule); const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index e8c99bb6e42..7789c8ec90d 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -192,7 +192,6 @@ const reduceNamespaces = (filterState: RulesFilter) => { const ufuzzy = getSearchInstance(groupNameFilter); const escapedQuery = escapeQueryRegex(groupNameFilter); - const [idxs, info, order] = ufuzzy.search( groupsHaystack, escapedQuery, diff --git a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts index 4e712668f09..50ca91b8188 100644 --- a/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts +++ b/public/app/features/alerting/unified/hooks/useIsRuleEditable.ts @@ -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; diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index 7e2d6c66c71..340081f0262 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -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]; diff --git a/public/app/features/alerting/unified/mocks/server/db.ts b/public/app/features/alerting/unified/mocks/server/db.ts new file mode 100644 index 00000000000..55e76f78208 --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/db.ts @@ -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(({ sequence }) => ({ + name: `test-rule-${sequence}`, + query: 'test-query', + state: PromAlertingRuleState.Inactive, + type: PromRuleType.Alerting, + health: 'ok', + labels: { team: 'infra' }, +})); + +const groupFactory = Factory.define(({ sequence }) => { + const group = { + name: `test-group-${sequence}`, + file: `test-namespace`, + interval: 10, + rules: ruleFactory.buildList(10), + }; + + ruleFactory.rewindSequence(); + + return group; +}); + +const dataSourceFactory = Factory.define(({ 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, +}; diff --git a/public/app/features/alerting/unified/mocks/server/utils.ts b/public/app/features/alerting/unified/mocks/server/utils.ts index db4f6f1fb3c..5f01e038225 100644 --- a/public/app/features/alerting/unified/mocks/server/utils.ts +++ b/public/app/features/alerting/unified/mocks/server/utils.ts @@ -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 = (kind: string, items: T[]) => { return { @@ -10,3 +14,32 @@ export const getK8sResponse = (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 { + 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({ + status: 'success', + data: { groups: groupsResult, groupNextToken: nextToken }, + }); + }; +} diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts index f7cd81c56bf..ca0750ac538 100644 --- a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts +++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts @@ -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(() => { 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 ?? {}, }, diff --git a/public/app/features/alerting/unified/rule-list/AlertRuleLoader.tsx b/public/app/features/alerting/unified/rule-list/AlertRuleLoader.tsx new file mode 100644 index 00000000000..2567f21f8be --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/AlertRuleLoader.tsx @@ -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 ; + } + + if (rulerRule) { + return ; + } + + return null; + }, [groupIdentifier, isLoading, rule, rulerRule]); + + if (isAlertingRule(rule)) { + return ( + + ); + } + + if (isRecordingRule(rule)) { + return ( + + ); + } + + return ; +}); + +function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) { + const paramId = encodeURIComponent(stringifyIdentifier(identifier)); + const paramSource = encodeURIComponent(identifier.ruleSourceName); + + return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {}); +} diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx new file mode 100644 index 00000000000..01b8e64663c --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -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(); + + 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( + + ); + + 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(); + + 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(); + + 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 { + return { + dataSourceNames: [], + freeFormWords: [], + labels: [], + ...overrides, + }; +} diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx new file mode 100644 index 00000000000..1744c77551b --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -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 ; +} + +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 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 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([]); + 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 ( + + + No alert or recording rules matched your current set of filters. + + + ); + } + + return ( + +
    + {rules.map(({ key, rule, groupIdentifier }) => ( + + ))} + {loading && ( + <> + + + + )} +
+ {doneSearching && !noRulesFound && ( + + + + No more results – showing {{ numberOfRules }} rules + + + + )} + {!doneSearching && } +
+ ); +} + +const AlertRuleListItemLoader = () => ( + } + icon={} + description={} + actions={} + data-testid="alert-rule-list-item-loader" + /> +); + +// simple helper function to detect the end of the source async iterable +function onFinished(fn: () => void) { + return tap(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)}`; +} diff --git a/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx new file mode 100644 index 00000000000..adb8b83c077 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/GroupedView.tsx b/public/app/features/alerting/unified/rule-list/GroupedView.tsx new file mode 100644 index 00000000000..af2b4bfe265 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/GroupedView.tsx @@ -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 ( + + + {externalRuleSources.map((ruleSource) => { + return ; + })} + + ); +} + +interface DataSourceLoaderProps { + name: string; + uid: string; +} + +export function GrafanaDataSourceLoader() { + return ; +} + +export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) { + const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid }); + + if (isLoading) { + return } uid={uid} name={name} />; + } + + // 2. grab prometheus rule groups with max_groups if supported + if (dataSourceInfo) { + return ( + + ); + } + + return null; +} + +// TODO Try to use a better rules source identifier +interface PaginatedDataSourceLoaderProps + extends Required> { + ruleSourceName: string; +} + +function PaginatedDataSourceLoader({ ruleSourceName, name, uid, application }: PaginatedDataSourceLoaderProps) { + const { + page: ruleNamespaces, + nextPage, + previousPage, + canMoveForward, + canMoveBackward, + isLoading, + } = usePaginatedPrometheusRuleNamespaces(ruleSourceName, GROUP_PAGE_SIZE); + + return ( + + + {ruleNamespaces.map((namespace) => ( + + {' '} + + {namespace.name} + + + } + > + {namespace.groups.map((group) => ( + + ))} + + ))} + + + + ); +} + +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 ( + + + + + + + + + } + > + + + + } + > + {rulesWithGroupId.map(({ rule, groupIdentifier }) => ( + + ))} + + ); +} + +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 ( +
+ + + {isLoading && } +
+ {loader ?? ( + + {application && } + + {name} + + {description && ( + <> + {'·'} + {description} + + )} + + + Configure + + } + /> + + )} +
+
+
{children}
+
+
+ ); +}; + +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 ( + + + + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/LoadMoreHelper.tsx b/public/app/features/alerting/unified/rule-list/LoadMoreHelper.tsx new file mode 100644 index 00000000000..b21356a6742 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/LoadMoreHelper.tsx @@ -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(null); + const intersection = useIntersection(intersectionRef, { + root: null, + threshold: 1, + }); + + useEffect(() => { + const completelyInView = intersection && intersection.intersectionRatio > 0; + if (completelyInView) { + handleLoad(); + } + }, [intersection, handleLoad]); + + return
; +} + +export default LoadMoreHelper; diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx new file mode 100644 index 00000000000..527c50687d2 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -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: () =>
Filter View
, +})); + +jest.mock('./GroupedView', () => ({ + GroupedView: () =>
Grouped View
, +})); + +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(); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it('should show grouped view when invalid view parameter is provided', () => { + render(, { + 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(, { 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(, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } }); + + expect(ui.filterView.get()).toBeInTheDocument(); + expect(ui.groupedView.query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 6db9f149f18..b8ccbb2fa8f 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -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 {}} /> - - {ruleSources.map((ruleSource) => { - if (isGrafanaRulesSource(ruleSource)) { - return ; - } else { - return ; - } - })} - + {showListView ? : } ); }, { style: 'page' } ); -const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; - -interface DataSourceLoaderProps { - name: string; - uid: string; -} - -const GrafanaDataSourceLoader = () => { - return ; -}; - -const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => { - const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid }); - - if (isLoading) { - return } />; - } - - // 2. grab prometheus rule groups with max_groups if supported - if (dataSourceInfo) { - const rulerEnabled = Boolean(dataSourceInfo.rulerConfig); - - return ( - - ); - } - - return null; -}; - -interface PaginatedDataSourceLoaderProps extends Pick { - 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 ( - - - {ruleNamespaces.map((namespace) => ( - - {namespace.name} - - } - > - {namespace.groups.map((group) => ( - - - - - - - - - } - > - - - - } - > - {group.rules.map((rule) => { - const groupIdentifier: RuleGroupIdentifier = { - dataSourceName: ruleSourceName, - groupName: group.name, - namespaceName: namespace.name, - }; - - return ( - - ); - })} - - ))} - - ))} - {!isLoading && } - - - ); -} - -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 ; - } - - if (rulerRule) { - return ; - } - - return null; - }, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]); - - if (isAlertingRule(rule)) { - return ( - - ); - } - - if (isRecordingRule(rule)) { - return ( - - ); - } - - return ; -} - -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 ( - - - {isLoading && } -
- {loader ?? ( - - {application && } - {name && ( - - {name} - - )} - {description && ( - <> - {'·'} - {description} - - )} - - {uid && ( - - Configure - - } - /> - )} - - )} -
-
-
{children}
-
- ); -}; - -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; diff --git a/public/app/features/alerting/unified/rule-list/StateView.tsx b/public/app/features/alerting/unified/rule-list/StateView.tsx index 248d83f43a2..6f87188fb79 100644 --- a/public/app/features/alerting/unified/rule-list/StateView.tsx +++ b/public/app/features/alerting/unified/rule-list/StateView.tsx @@ -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 ? ( - + ) : ( ) diff --git a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx index d4ab558ba1f..193f6802ed7 100644 --- a/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/AlertRuleListItem.tsx @@ -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( - + ); } @@ -142,13 +153,27 @@ type RecordingRuleListItemProps = Omit + + + ); + } + return ( } icon={} - 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) => ( - - - - {namespace} - - {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 ( + + {isGrafanaApp && } + {isDataSourceApp && ( + + + + + + )} + + + {namespace} + + {group} + - -); + ); +}; const getStyles = (theme: GrafanaTheme2) => ({ alertListItemContainer: css({ diff --git a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx index 721d8002908..253b69cdb90 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListGroup.tsx @@ -60,7 +60,7 @@ const GroupHeader = (props: GroupHeaderProps) => { onClick={onToggle} aria-label={t('common.collapse', 'Collapse')} /> - + {name} diff --git a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx index 5340ff57ede..3a67d08696b 100644 --- a/public/app/features/alerting/unified/rule-list/components/ListItem.tsx +++ b/public/app/features/alerting/unified/rule-list/components/ListItem.tsx @@ -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 ( -
  • +
  • {/* icon */} {icon} diff --git a/public/app/features/alerting/unified/rule-list/components/Namespace.tsx b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx index 8d4f325e3b8..ef1dc37965e 100644 --- a/public/app/features/alerting/unified/rule-list/components/Namespace.tsx +++ b/public/app/features/alerting/unified/rule-list/components/Namespace.tsx @@ -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 ( Prometheus ); case PromApplication.Mimir: return ( - Mimir + Mimir ); case 'Loki': - return Loki; + return Loki; case 'grafana': default: return ; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx index e560b464a09..405f2f4ae2d 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleActionsButtons.V2.tsx @@ -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 }: )} ); -}; +} export const ActionsLoader = () => ; diff --git a/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx index fb8a036128f..663625c0af2 100644 --- a/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx +++ b/public/app/features/alerting/unified/rule-list/components/RuleGroup.tsx @@ -75,11 +75,11 @@ export const EvaluationGroupLoader = ({ ); }; -export const LoadingIndicator = () => { +export const LoadingIndicator = ({ datasourceUid }: { datasourceUid: string }) => { const [ref, { width }] = useMeasure(); return ( -
    +
    ); diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts new file mode 100644 index 00000000000..f3ac6cbf315 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -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 }; + } +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusRuleNamespaces.tsx b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusRuleNamespaces.tsx new file mode 100644 index 00000000000..ab4bd2ca42b --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/usePaginatedPrometheusRuleNamespaces.tsx @@ -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([]); + const [lastPage, setLastPage] = useState(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>( + 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 }; +} diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index d64e6d82ae8..9acc000ec59 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -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); diff --git a/public/app/features/alerting/unified/utils/groupIdentifier.ts b/public/app/features/alerting/unified/utils/groupIdentifier.ts new file mode 100644 index 00000000000..8f8a1e48b2d --- /dev/null +++ b/public/app/features/alerting/unified/utils/groupIdentifier.ts @@ -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, +}; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 9f8869820cd..d2a12957938 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -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; diff --git a/public/app/features/alerting/unified/utils/rule-id.ts b/public/app/features/alerting/unified/utils/rule-id.ts index 8e44a5a3dc4..1c184ec6da1 100644 --- a/public/app/features/alerting/unified/utils/rule-id.ts +++ b/public/app/features/alerting/unified/utils/rule-id.ts @@ -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 diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 4b7082e16e4..89c3626f355 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -169,6 +169,7 @@ export interface PromResponse { export type PromRulesResponse = PromResponse<{ groups: PromRuleGroupDTO[]; + groupNextToken?: string; totals?: AlertGroupTotals; }>; diff --git a/public/app/types/unified-alerting.ts b/public/app/types/unified-alerting.ts index 74eb00ee147..ccf4221e0a5 100644 --- a/public/app/types/unified-alerting.ts +++ b/public/app/types/unified-alerting.ts @@ -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 | 'grafana'; // combined prom and ruler result @@ -149,7 +151,21 @@ export interface RuleWithLocation { 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 { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 131f1a00ceb..d1b9458d42c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index f4046c8e748..aeb77531dbb 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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": { diff --git a/yarn.lock b/yarn.lock index c83a4fd1ab8..799cf1b463c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"