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