Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e230a71fa2 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user