Compare commits

...

1 Commits

Author SHA1 Message Date
Konrad Lalik e230a71fa2 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
2026-01-08 11:50:52 +01:00
5 changed files with 270 additions and 141 deletions
@@ -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 <PaginatedGroupsLoader key={key} groupFilter={groupFilter} namespaceFilter={namespaceFilter} />;
// Key is crucial. It resets the state when filters change.
return <PaginatedFoldersLoader key={key} groupFilter={groupFilter} namespaceFilter={namespaceFilter} />;
}
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 (
<DataSourceSection
name="Grafana-managed"
application="grafana"
uid={GrafanaRulesSourceSymbol}
isLoading={isLoading}
isLoading={isLoading && isEmpty(folders)}
error={error}
>
<Stack direction="column" gap={0}>
{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) => (
<AlertingFolder key={folder.uid} folder={folder} />
))}
return (
<ListSection
key={folderUid}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" />{' '}
<Text variant="body" element="h3">
{folderName}
</Text>
</Stack>
}
actions={<FolderActionsButton folderUID={folderUid} />}
>
{groups.map((group) => (
<GrafanaRuleGroupListItem
key={`grafana-ns-${folderUid}-${group.name}`}
group={group}
namespaceName={folderName}
/>
))}
</ListSection>
);
})}
{/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */}
{hasNoRules && !hasFilters && <GrafanaNoRulesCTA />}
{hasNoRules && hasFilters && <NoRulesFound />}
{hasMoreGroups && (
// this div will make the button not stretch
<div>
<LoadMoreButton loading={isLoading} onClick={fetchMoreGroups} />
</div>
)}
</Stack>
{/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */}
{hasNoFolders && !hasFilters && <GrafanaNoRulesCTA />}
{hasNoFolders && hasFilters && <NoRulesFound />}
{hasMore && (
<div>
<LoadMoreButton loading={isLoading} onClick={fetchMore} />
</div>
)}
</DataSourceSection>
);
}
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 (
<ListGroup
key={group.name}
name={group.name}
metaRight={<GroupIntervalIndicator seconds={group.interval} />}
href={detailsLink}
isOpen={false}
>
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={namespaceName} />
</ListGroup>
);
}
@@ -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 (
<ListSection
key={folderUid}
title={
<Stack direction="row" gap={1} alignItems="center">
<Icon name="folder" />
<Text variant="body" element="h3">
{folderName}
</Text>
</Stack>
}
actions={<FolderActionsButton folderUID={folderUid} />}
collapsed={true}
pagination={
hasMoreGroups ? (
<div>
<LoadMoreButton loading={isLoading} onClick={fetchMoreGroups} />
</div>
) : null
}
>
{error && (
<Text color="error" variant="body">
<Trans i18nKey="alerting.folder.failed-to-load-groups">Failed to load groups:</Trans> {error.message}
</Text>
)}
{groups.map((group) => (
<GrafanaRuleGroupListItem
key={`grafana-ns-${folderUid}-${group.name}`}
group={group}
namespaceName={folderName}
/>
))}
</ListSection>
);
}
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 (
<ListGroup
key={group.name}
name={group.name}
metaRight={<GroupIntervalIndicator seconds={group.interval} />}
href={detailsLink}
isOpen={false}
>
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={namespaceName} />
</ListGroup>
);
}
@@ -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<DashboardQueryResult[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<Error>();
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,
};
}
@@ -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<PromRulesResponse<GrafanaPromRuleGroupDTO>> => {
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<TGroup>(
generator: AsyncGenerator<TGroup[], void, unknown>
): AsyncGenerator<TGroup, void, unknown> {
for await (const batch of generator) {
for (const item of batch) {
yield item;
}
}
}
+3
View File
@@ -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": {