From e230a71fa264520b2c969c55678fc55da01e619a Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 8 Jan 2026 11:50:52 +0100 Subject: [PATCH] feat(alerting): add folder-based grouping for Grafana-managed alert rules Introduce folder-based view for Grafana-managed alert rules with lazy-loaded groups and pagination support. - Add AlertingFolder component to render individual folders with their rule groups - Add useAlertingFolders hook for paginated folder fetching via search API - Add useFolderGroupsGenerator hook for async pagination of groups within a folder - Add toIndividualGroups helper to convert batch generators to individual item generators - Update PaginatedGrafanaLoader to use folder-based hierarchy - Update i18n strings for folder-based views Features: - Folders are fetched with pagination (40 per page) - Groups are lazily loaded and displayed when folder is expanded - Load more buttons provide explicit pagination control - Error handling for both folder and group loading failures - Collapsible folder sections with folder icons and action buttons --- .../rule-list/PaginatedGrafanaLoader.tsx | 166 +++--------------- .../rule-list/components/AlertingFolder.tsx | 113 ++++++++++++ .../rule-list/hooks/useAlertingFolders.ts | 77 ++++++++ .../rule-list/hooks/useFolderGroups.ts | 52 ++++++ public/locales/en-US/grafana.json | 3 + 5 files changed, 270 insertions(+), 141 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/components/AlertingFolder.tsx create mode 100644 public/app/features/alerting/unified/rule-list/hooks/useAlertingFolders.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/useFolderGroups.ts diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index 9dfd25309ba..ccfc5d711cf 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -1,26 +1,14 @@ -import { groupBy, isEmpty } from 'lodash'; -import { useEffect, useMemo, useRef } from 'react'; +import { isEmpty } from 'lodash'; -import { Icon, Stack, Text } from '@grafana/ui'; -import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting'; -import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { GrafanaRulesSourceSymbol } from 'app/types/unified-alerting'; -import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton'; import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA'; -import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; -import { groups } from '../utils/navigation'; -import { GrafanaGroupLoader } from './GrafanaGroupLoader'; +import { AlertingFolder } from './components/AlertingFolder'; import { DataSourceSection } from './components/DataSourceSection'; -import { GroupIntervalIndicator } from './components/GroupIntervalMetadata'; -import { ListGroup } from './components/ListGroup'; -import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { getGrafanaFilter, hasGrafanaClientSideFilters } from './hooks/grafanaFilter'; -import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; -import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; -import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; +import { useAlertingFolders } from './hooks/useAlertingFolders'; interface LoaderProps { groupFilter?: string; @@ -30,146 +18,42 @@ interface LoaderProps { export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderProps) { const key = `${groupFilter}-${namespaceFilter}`; - // Key is crucial. It resets the generator when filters change. - return ; + // Key is crucial. It resets the state when filters change. + return ; } -function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { - // When backend filters are enabled, groupFilter is handled on the backend - const filterState = { namespace: namespaceFilter, groupName: groupFilter }; - const { backendFilter } = getGrafanaFilter(filterState); - +function PaginatedFoldersLoader({ groupFilter, namespaceFilter }: LoaderProps) { const hasFilters = Boolean(groupFilter || namespaceFilter); - const needsClientSideFiltering = hasGrafanaClientSideFilters(filterState); - // If there are filters, we don't want to populate the cache to avoid performance issues - // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance - const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ - populateCache: needsClientSideFiltering ? false : true, - limitAlerts: 0, - }); + // TODO: Implement folder/group filtering when filters are provided + // For now, we fetch all folders and filtering happens at the group level within each folder - // If there are no filters we can match one frontend page to one API page. - // However, if there are filters, we need to fetch more groups from the API to populate one frontend page - const apiGroupPageSize = getApiGroupPageSize(needsClientSideFiltering); + // Fetch folders containing alert rules + const { folders, isLoading, hasMore, fetchMore, error } = useAlertingFolders(); - const groupsGenerator = useRef( - toIndividualRuleGroups(grafanaGroupsGenerator({ groupLimit: apiGroupPageSize }, backendFilter)) - ); - - useEffect(() => { - const currentGenerator = groupsGenerator.current; - return () => { - currentGenerator.return(); - }; - }, []); - - const filterFn = useMemo(() => { - const { frontendFilter } = getGrafanaFilter({ - namespace: namespaceFilter, - groupName: groupFilter, - freeFormWords: [], - ruleName: '', - labels: [], - ruleType: undefined, - ruleState: undefined, - ruleHealth: undefined, - dashboardUid: undefined, - dataSourceNames: [], - plugins: undefined, - contactPoint: undefined, - ruleSource: undefined, - }); - return (group: PromRuleGroupDTO) => frontendFilter.groupMatches(group); - }, [namespaceFilter, groupFilter]); - - const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( - groupsGenerator.current, - FRONTED_GROUPED_PAGE_SIZE, - filterFn - ); - - const groupsByFolder = useMemo(() => groupBy(groups, 'folderUid'), [groups]); - const hasNoRules = isEmpty(groups) && !isLoading; + const hasNoFolders = isEmpty(folders) && !isLoading; return ( - - {Object.entries(groupsByFolder).map(([folderUid, groups]) => { - // Groups are grouped by folder, so we can use the first group to get the folder name - const folderName = groups[0].file; + {folders.map((folder) => ( + + ))} - return ( - - {' '} - - {folderName} - - - } - actions={} - > - {groups.map((group) => ( - - ))} - - ); - })} - {/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */} - {hasNoRules && !hasFilters && } - {hasNoRules && hasFilters && } - {hasMoreGroups && ( - // this div will make the button not stretch -
- -
- )} - + {/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */} + {hasNoFolders && !hasFilters && } + {hasNoFolders && hasFilters && } + + {hasMore && ( +
+ +
+ )}
); } - -interface GrafanaRuleGroupListItemProps { - group: GrafanaPromRuleGroupDTO; - namespaceName: string; -} - -export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) { - const groupIdentifier: GrafanaRuleGroupIdentifier = useMemo( - () => ({ - groupName: group.name, - namespace: { - uid: group.folderUid, - }, - groupOrigin: 'grafana', - }), - [group.name, group.folderUid] - ); - - const detailsLink = groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name); - - return ( - } - href={detailsLink} - isOpen={false} - > - - - ); -} diff --git a/public/app/features/alerting/unified/rule-list/components/AlertingFolder.tsx b/public/app/features/alerting/unified/rule-list/components/AlertingFolder.tsx new file mode 100644 index 00000000000..241a17699b2 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/AlertingFolder.tsx @@ -0,0 +1,113 @@ +import { useMemo, useRef } from 'react'; + +import { Trans } from '@grafana/i18n'; +import { Icon, Stack, Text } from '@grafana/ui'; +import { DashboardQueryResult } from 'app/features/search/service/types'; +import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting'; +import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { FolderActionsButton } from '../../components/folder-actions/FolderActionsButton'; +import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { groups } from '../../utils/navigation'; +import { GrafanaGroupLoader } from '../GrafanaGroupLoader'; +import { toIndividualGroups, useFolderGroupsGenerator } from '../hooks/useFolderGroups'; +import { useLazyLoadPrometheusGroups } from '../hooks/useLazyLoadPrometheusGroups'; +import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from '../paginationLimits'; + +import { GroupIntervalIndicator } from './GroupIntervalMetadata'; +import { ListGroup } from './ListGroup'; +import { ListSection } from './ListSection'; +import { LoadMoreButton } from './LoadMoreButton'; + +interface AlertingFolderProps { + folder: DashboardQueryResult; +} + +/** + * Component that renders a single folder containing alert rules. + * Groups are loaded lazily when the folder is expanded. + */ +export function AlertingFolder({ folder }: AlertingFolderProps) { + const folderUid = folder.uid; + const folderName = folder.name; + + const apiGroupPageSize = getApiGroupPageSize(false); + + // Generator for fetching groups in this folder + const folderGroupsGenerator = useFolderGroupsGenerator(folderUid, 0); + + const groupsGenerator = useRef(toIndividualGroups(folderGroupsGenerator(apiGroupPageSize))); + + const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( + groupsGenerator.current, + FRONTED_GROUPED_PAGE_SIZE + ); + + return ( + + + + {folderName} + + + } + actions={} + collapsed={true} + pagination={ + hasMoreGroups ? ( +
+ +
+ ) : null + } + > + {error && ( + + Failed to load groups: {error.message} + + )} + {groups.map((group) => ( + + ))} +
+ ); +} + +interface GrafanaRuleGroupListItemProps { + group: GrafanaPromRuleGroupDTO; + namespaceName: string; +} + +function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGroupListItemProps) { + const groupIdentifier: GrafanaRuleGroupIdentifier = useMemo( + () => ({ + groupName: group.name, + namespace: { + uid: group.folderUid, + }, + groupOrigin: 'grafana', + }), + [group.name, group.folderUid] + ); + + const detailsLink = groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name); + + return ( + } + href={detailsLink} + isOpen={false} + > + + + ); +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/useAlertingFolders.ts b/public/app/features/alerting/unified/rule-list/hooks/useAlertingFolders.ts new file mode 100644 index 00000000000..7cf83dc622a --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/useAlertingFolders.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { getGrafanaSearcher } from 'app/features/search/service/searcher'; +import { DashboardQueryResult } from 'app/features/search/service/types'; + +interface UseAlertingFoldersResult { + folders: DashboardQueryResult[]; + isLoading: boolean; + hasMore: boolean; + fetchMore: () => void; + error?: Error; +} + +const FOLDERS_PAGE_SIZE = 40; + +/** + * Hook to fetch folders using the search API. + * Folders are fetched lazily with pagination support. + * Note: This fetches all folders, not just those containing alert rules. + * Empty folders will show no groups when expanded. + */ +export function useAlertingFolders(): UseAlertingFoldersResult { + const [folders, setFolders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(); + const [page, setPage] = useState(0); + + const fetchFolders = useCallback( + async (pageToFetch: number) => { + if (!hasMore && pageToFetch > 0) { + return; + } + + setIsLoading(true); + setError(undefined); + + try { + const searcher = getGrafanaSearcher(); + const response = await searcher.search({ + kind: ['folder'], + limit: FOLDERS_PAGE_SIZE, + from: pageToFetch * FOLDERS_PAGE_SIZE, + }); + + const newFolders = response.view.toArray(); + setFolders((prev) => (pageToFetch === 0 ? newFolders : [...prev, ...newFolders])); + setHasMore(newFolders.length === FOLDERS_PAGE_SIZE); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch folders')); + } finally { + setIsLoading(false); + } + }, + [hasMore] + ); + + const fetchMore = useCallback(() => { + const nextPage = page + 1; + setPage(nextPage); + fetchFolders(nextPage); + }, [page, fetchFolders]); + + // Initial fetch + useEffect(() => { + fetchFolders(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + folders, + isLoading, + hasMore, + fetchMore, + error, + }; +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFolderGroups.ts b/public/app/features/alerting/unified/rule-list/hooks/useFolderGroups.ts new file mode 100644 index 00000000000..f15a1c4f253 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/useFolderGroups.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react'; + +import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi'; + +const { useLazyGetGrafanaGroupsQuery } = prometheusApi; + +/** + * Generator that yields groups for a specific folder with pagination support + */ +export function useFolderGroupsGenerator(folderUid: string, limitAlerts = 0) { + const [getGrafanaGroups] = useLazyGetGrafanaGroupsQuery(); + + return useCallback( + async function* (groupLimit: number) { + const fetchGroups = async (groupNextToken?: string): Promise> => { + return getGrafanaGroups({ + folderUid, + groupLimit, + limitAlerts, + groupNextToken, + }).unwrap(); + }; + + let response = await fetchGroups(); + yield response.data.groups; + + let lastToken = response.data?.groupNextToken; + + while (lastToken) { + response = await fetchGroups(lastToken); + yield response.data.groups; + lastToken = response.data?.groupNextToken; + } + }, + [getGrafanaGroups, folderUid, limitAlerts] + ); +} + +/** + * Converts a generator yielding arrays of groups to a generator yielding groups one by one + */ +export async function* toIndividualGroups( + generator: AsyncGenerator +): AsyncGenerator { + for await (const batch of generator) { + for (const item of batch) { + yield item; + } + } +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2174ff0adbc..58b63db2943 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1243,6 +1243,9 @@ "filter-view-results": { "aria-label-filteredrulelist": "filtered-rule-list" }, + "folder": { + "failed-to-load-groups": "Failed to load groups:" + }, "folder-bulk-actions": { "delete": { "button": {