Alerting: New list view layout update (#105489)

This commit is contained in:
Gilles De Mey
2025-05-20 19:03:07 +03:00
committed by GitHub
parent b1a6860f52
commit 082b46067c
23 changed files with 284 additions and 433 deletions
@@ -12,6 +12,7 @@ import { useFolder } from '../../hooks/useFolder';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { createRelativeUrl } from '../../utils/url';
import MoreButton from '../MoreButton';
import { DeleteModal } from './DeleteModal';
import { PauseUnpauseActionMenuItem } from './PauseUnpauseActionMenuItem';
@@ -20,20 +21,30 @@ interface Props {
}
export const FolderBulkActionsButton = ({ folderUID }: Props) => {
const { t } = useTranslate();
// state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// abilities
const [pauseSupported, pauseAllowed] = useFolderBulkActionAbility(FolderBulkAction.Pause);
const canPause = pauseSupported && pauseAllowed;
const [deleteSupported, deleteAllowed] = useFolderBulkActionAbility(FolderBulkAction.Delete);
const canPause = pauseSupported && pauseAllowed;
const canDelete = deleteSupported && deleteAllowed;
// mutations
const [pauseFolder, updateState] = alertingFolderActionsApi.endpoints.pauseFolder.useMutation();
const [unpauseFolder, unpauseState] = alertingFolderActionsApi.endpoints.unpauseFolder.useMutation();
const [deleteGrafanaRulesFromFolder, deleteState] =
alertingFolderActionsApi.endpoints.deleteGrafanaRulesFromFolder.useMutation();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const folderName = useFolder(folderUID).folder?.title || 'unknown folder';
const { t } = useTranslate();
const listView2Enabled = config.featureToggles.alertingListViewV2 ?? false;
const view = listView2Enabled ? 'list' : 'grouped';
const redirectToListView = useRedirectToListView(view);
const viewComponent = listView2Enabled ? 'list' : 'grouped';
// URLs
const redirectToListView = useRedirectToListView(viewComponent);
if (!canPause && !canDelete) {
return null;
@@ -70,24 +81,44 @@ export const FolderBulkActionsButton = ({ folderUID }: Props) => {
)}
{canDelete && (
<Menu.Item
label={t('alerting.folder-bulk-actions.delete.button.label', 'Delete rules')}
label={t('alerting.folder-bulk-actions.delete.button.label', 'Delete all rules')}
icon="trash-alt"
onClick={() => setIsDeleteModalOpen(true)}
disabled={deleteState.isLoading}
/>
)}
{/* @TODO re-implement */}
{/* {listView2Enabled && (
<>
<Menu.Divider />
<Menu.Item
label={t('alerting.folder-bulk-actions.export.button.label', 'Export rules')}
icon="download-alt"
onClick={() => {}}
/>
</>
)} */}
</>
);
return (
<>
<Dropdown overlay={<Menu>{menuItems}</Menu>}>
<IconButton
name="ellipsis-v"
size="sm"
aria-label={t('alerting.folder-bulk-actions.more-button.title', 'Folder bulk Actions')}
tooltip={t('alerting.folder-bulk-actions.more-button.tooltip', 'Folder bulk Actions')}
/>
<Dropdown placement="bottom" overlay={<Menu>{menuItems}</Menu>}>
{listView2Enabled ? (
<MoreButton
fill="text"
size="sm"
aria-label={t('alerting.folder-bulk-actions.more-button.title', 'Folder actions')}
/>
) : (
<IconButton
name="ellipsis-h"
size="sm"
aria-label={t('alerting.folder-bulk-actions.more-button.title', 'Folder actions')}
tooltip={t('alerting.folder-bulk-actions.more-button.tooltip', 'Folder actions')}
tooltipPlacement="top"
/>
)}
</Dropdown>
<DeleteModal
isOpen={isDeleteModalOpen}
@@ -20,8 +20,8 @@ export function PauseUnpauseActionMenuItem({ folderUID, executeAction, isLoading
const { t } = useTranslate();
const label =
action === 'pause'
? t('alerting.folder-bulk-actions.pause.button.label', 'Pause all rule evaluation')
: t('alerting.folder-bulk-actions.unpause.button.label', 'Resume all rule evaluation');
? t('alerting.folder-bulk-actions.pause.button.label', 'Pause all rules')
: t('alerting.folder-bulk-actions.unpause.button.label', 'Resume all rules');
const icon = action === 'pause' ? 'pause' : 'play';
const trackActionSuccess =
action === 'pause' ? trackFolderBulkActionsPauseSuccess : trackFolderBulkActionsUnpauseSuccess;
@@ -1,6 +1,8 @@
import { PropsOf } from '@emotion/react';
import { AppEvents } from '@grafana/data';
import { useTranslate } from '@grafana/i18n';
import { ComponentSize, Dropdown, Menu } from '@grafana/ui';
import { Button, ComponentSize, Dropdown, Menu } from '@grafana/ui';
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';
@@ -25,6 +27,7 @@ interface Props {
handleDuplicateRule: (identifier: RuleIdentifier) => void;
onPauseChange?: () => void;
buttonSize?: ComponentSize;
fill?: PropsOf<typeof Button>['fill'];
}
/**
@@ -41,6 +44,7 @@ const AlertRuleMenu = ({
handleDuplicateRule,
onPauseChange,
buttonSize,
fill,
}: Props) => {
// check all abilities and permissions
const [pauseSupported, pauseAllowed] = useRulerRuleAbility(rulerRule, groupIdentifier, AlertRuleAction.Pause);
@@ -145,8 +149,8 @@ const AlertRuleMenu = ({
);
return (
<Dropdown overlay={<Menu>{menuItems}</Menu>}>
<MoreButton size={buttonSize} />
<Dropdown overlay={<Menu>{menuItems}</Menu>} placement="bottom">
<MoreButton size={buttonSize} fill={fill} />
</Dropdown>
);
};
@@ -117,6 +117,7 @@ const RuleViewer = () => {
health={promRule?.health}
ruleType={promRule?.type}
ruleOrigin={ruleOrigin}
returnToHref="/alerting/list"
/>
)}
actions={<RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />}
@@ -19,7 +19,7 @@ import { CollapseToggle } from '../CollapseToggle';
import { RuleLocation } from '../RuleLocation';
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
import { decodeGrafanaNamespace } from '../expressions/util';
import { FolderBulkActionsButton } from '../folder-bulk-actions/FolderBulkActionsButton';
import { FolderBulkActionsButton } from '../folder-actions/FolderActionsButton';
import { ActionIcon } from './ActionIcon';
import { RuleGroupStats } from './RuleStats';
@@ -10,6 +10,7 @@ import { setPrometheusRules } from '../mocks/server/configure';
import { alertingFactory } from '../mocks/server/db';
import { GroupedView } from './GroupedView';
import { DATA_SOURCE_GROUP_PAGE_SIZE } from './PaginatedDataSourceLoader';
setPluginLinksHook(() => ({ links: [], isLoading: false }));
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
@@ -35,7 +36,7 @@ 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/ }),
loadMoreButton: () => byRole('button', { name: /Show more/i }),
};
describe('RuleList - GroupedView', () => {
@@ -64,14 +65,14 @@ describe('RuleList - GroupedView', () => {
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);
const loadMoreButton = await within(mimirSection).findByRole('button', { name: /Show more/i });
await user.click(loadMoreButton);
await waitFor(() => expect(nextButton).toBeEnabled());
await waitFor(() => expect(loadMoreButton).toBeEnabled());
const secondPageGroups = await ui.group(/test-group-(4[1-9]|[5-7][0-9]|80)/).findAll(mimirNamespace);
expect(secondPageGroups).toHaveLength(40);
expect(secondPageGroups).toHaveLength(DATA_SOURCE_GROUP_PAGE_SIZE);
expect(secondPageGroups[0]).toHaveTextContent('test-group-41');
expect(secondPageGroups[24]).toHaveTextContent('test-group-65');
expect(secondPageGroups[39]).toHaveTextContent('test-group-80');
@@ -81,28 +82,25 @@ describe('RuleList - GroupedView', () => {
const { user } = render(<GroupedView />);
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();
// initial load – should have all groups 1-40
await ui.group(/test-group-([1-9]|[1-3][0-9]|40)/).findAll(promNamespace);
// fetch page 2
const loadMoreButton = await ui.loadMoreButton().find(prometheusSection);
await waitFor(() => expect(loadMoreButton).toBeEnabled());
// we should now have all groups 1-80
await ui.group(/test-group-([1-9]|[1-7][0-9]|80)/).findAll(promNamespace);
// fetch third page
await waitFor(() => expect(loadMoreButton).toBeEnabled());
await user.click(loadMoreButton);
// we should now have all groups 1-130
await ui.group(/test-group-([1-9]|[1-9][0-9]|1[0-2][0-9]|130)/).findAll(promNamespace);
expect(loadMoreButton).not.toBeInTheDocument();
});
});
@@ -8,14 +8,13 @@ import { groups } from '../utils/navigation';
import { DataSourceGroupLoader } from './DataSourceGroupLoader';
import { DataSourceSection, DataSourceSectionProps } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
import { LoadMoreButton } from './components/LoadMoreButton';
import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
export const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
@@ -36,20 +35,16 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
};
}, [groupsGenerator]);
const {
page: groupsPage,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusGroups(groupsGenerator.current, DATA_SOURCE_GROUP_PAGE_SIZE);
const { isLoading, groups, hasMoreGroups, fetchMoreGroups } = useLazyLoadPrometheusGroups(
groupsGenerator.current,
DATA_SOURCE_GROUP_PAGE_SIZE
);
const groupsByNamespace = useMemo(() => groupBy(groupsPage, 'file'), [groupsPage]);
const groupsByNamespace = useMemo(() => groupBy(groups, 'file'), [groups]);
return (
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
{Object.entries(groupsByNamespace).map(([namespace, groups]) => (
<ListSection
key={namespace}
@@ -72,12 +67,12 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
))}
</ListSection>
))}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
{hasMoreGroups && (
// this div will make the button not stretch
<div>
<LoadMoreButton onClick={fetchMoreGroups} />
</div>
)}
</Stack>
</DataSourceSection>
);
@@ -106,7 +101,6 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule
name={group.name}
href={groups.detailsPageLink(rulesSourceIdentifier.uid, namespaceName, group.name)}
isOpen={false}
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
>
<DataSourceGroupLoader groupIdentifier={groupIdentifier} expectedRulesCount={group.rules.length} />
</ListGroup>
@@ -1,25 +1,26 @@
import { groupBy } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Icon, Stack, Text } from '@grafana/ui';
import { Icon, LinkButton, Stack, Text } from '@grafana/ui';
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { FolderBulkActionsButton } from '../components/folder-bulk-actions/FolderBulkActionsButton';
import { FolderBulkActionsButton } from '../components/folder-actions/FolderActionsButton';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { makeFolderLink } from '../utils/misc';
import { groups } from '../utils/navigation';
import { GrafanaGroupLoader } from './GrafanaGroupLoader';
import { DataSourceSection } from './components/DataSourceSection';
import { LazyPagination } from './components/LazyPagination';
import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { RuleGroupActionsMenu } from './components/RuleGroupActionsMenu';
import { LoadMoreButton } from './components/LoadMoreButton';
import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { usePaginatedPrometheusGroups } from './hooks/usePaginatedPrometheusGroups';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
const GRAFANA_GROUP_PAGE_SIZE = 40;
export const GRAFANA_GROUP_PAGE_SIZE = 40;
export function PaginatedGrafanaLoader() {
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true });
@@ -33,25 +34,23 @@ export function PaginatedGrafanaLoader() {
};
}, []);
const {
page: groupsPage,
nextPage,
previousPage,
canMoveForward,
canMoveBackward,
isLoading,
} = usePaginatedPrometheusGroups(groupsGenerator.current, GRAFANA_GROUP_PAGE_SIZE);
const { isLoading, groups, hasMoreGroups, fetchMoreGroups } = useLazyLoadPrometheusGroups(
groupsGenerator.current,
GRAFANA_GROUP_PAGE_SIZE
);
const groupsByFolder = useMemo(() => groupBy(groupsPage, 'folderUid'), [groupsPage]);
const groupsByFolder = useMemo(() => groupBy(groups, 'folderUid'), [groups]);
const isFolderBulkActionsEnabled = config.featureToggles.alertingBulkActionsInUI;
return (
<DataSourceSection name="Grafana" application="grafana" uid={GrafanaRulesSourceSymbol} isLoading={isLoading}>
<Stack direction="column" gap={1}>
<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;
const folderUrl = makeFolderLink(folderUid);
return (
<ListSection
key={folderUid}
@@ -63,7 +62,14 @@ export function PaginatedGrafanaLoader() {
</Text>
</Stack>
}
actions={isFolderBulkActionsEnabled ? <FolderBulkActionsButton folderUID={folderUid} /> : null}
actions={
<>
<LinkButton variant="secondary" fill="text" size="sm" href={folderUrl}>
<Trans i18nKey="alerting.folder-bulk-actions.view.folder">View folder</Trans>
</LinkButton>
{isFolderBulkActionsEnabled ? <FolderBulkActionsButton folderUID={folderUid} /> : null}
</>
}
>
{groups.map((group) => (
<GrafanaRuleGroupListItem
@@ -75,12 +81,12 @@ export function PaginatedGrafanaLoader() {
</ListSection>
);
})}
<LazyPagination
nextPage={nextPage}
previousPage={previousPage}
canMoveForward={canMoveForward}
canMoveBackward={canMoveBackward}
/>
{hasMoreGroups && (
// this div will make the button not stretch
<div>
<LoadMoreButton onClick={fetchMoreGroups} />
</div>
)}
</Stack>
</DataSourceSection>
);
@@ -109,7 +115,6 @@ export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGr
name={group.name}
href={groups.detailsPageLink(GRAFANA_RULES_SOURCE_NAME, group.folderUid, group.name)}
isOpen={false}
actions={<RuleGroupActionsMenu groupIdentifier={groupIdentifier} />}
>
<GrafanaGroupLoader groupIdentifier={groupIdentifier} namespaceName={namespaceName} />
</ListGroup>
@@ -181,7 +181,7 @@ export function RecordingRuleListItem({
<ListItem
title={
<Stack direction="row" alignItems="center">
<TextLink href={href} inline={false}>
<TextLink color="primary" href={href} inline={false}>
{name}
</TextLink>
{origin && <PluginOriginBadge pluginId={origin.pluginId} size="sm" />}
@@ -35,14 +35,14 @@ export const DataSourceSection = ({
isLoading = false,
description = null,
}: DataSourceSectionProps) => {
const styles = useStyles2(getStyles);
const [isCollapsed, toggleCollapsed] = useToggle(false);
const styles = useStyles2((theme) => getStyles(theme, isCollapsed));
const { rulesSourcesWithRuler } = useRulesSourcesWithRuler();
const showImportLink =
uid !== GrafanaRulesSourceSymbol &&
rulesSourcesWithRuler.some(({ uid: dsUid, type }) => dsUid === uid && supportedImportTypes.includes(type));
const [isCollapsed, toggleCollapsed] = useToggle(false);
const { t } = useTranslate();
const configureLink = (() => {
if (uid === GrafanaRulesSourceSymbol) {
@@ -56,7 +56,7 @@ export const DataSourceSection = ({
})();
return (
<section aria-labelledby={`datasource-${String(uid)}-heading`} role="listitem">
<Stack direction="column" gap={1}>
<Stack direction="column" gap={0}>
<Stack direction="column" gap={0}>
{isLoading && <LoadingIndicator datasourceUid={String(uid)} />}
<div className={styles.dataSourceSectionTitle}>
@@ -82,9 +82,9 @@ export const DataSourceSection = ({
{showImportLink && (
<LinkButton
variant="secondary"
fill="text"
size="sm"
href={`/alerting/import-datasource-managed-rules?datasourceUid=${String(uid)}`}
icon="arrow-up"
>
<Trans i18nKey="alerting.data-source-section.import-to-grafana">Import to Grafana rules</Trans>
</LinkButton>
@@ -93,7 +93,7 @@ export const DataSourceSection = ({
<WithReturnButton
title={t('alerting.rule-list.return-button.title', 'Alert rules')}
component={
<LinkButton variant="secondary" size="sm" href={configureLink}>
<LinkButton variant="secondary" fill="text" size="sm" href={configureLink}>
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
</LinkButton>
}
@@ -109,25 +109,12 @@ export const DataSourceSection = ({
);
};
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2, isCollapsed = false) => ({
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,
padding: theme.spacing(1, 1.5),
}),
});
@@ -1,36 +0,0 @@
import { useTranslate } from '@grafana/i18n';
import { Button, Icon, Stack } from '@grafana/ui';
interface LazyPaginationProps {
canMoveForward: boolean;
canMoveBackward: boolean;
nextPage: () => void;
previousPage: () => void;
}
export function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
const { t } = useTranslate();
return (
<Stack direction="row" gap={1}>
<Button
aria-label={t('alerting.rule-list.pagination.previous-page', 'previous page')}
size="sm"
variant="secondary"
onClick={previousPage}
disabled={!canMoveBackward}
>
<Icon name="angle-left" />
</Button>
<Button
aria-label={t('alerting.rule-list.pagination.next-page', 'next page')}
size="sm"
variant="secondary"
onClick={nextPage}
disabled={!canMoveForward}
>
<Icon name="angle-right" />
</Button>
</Stack>
);
}
@@ -40,7 +40,11 @@ export const ListGroup = ({
actions={actions}
href={href}
/>
{open && <div role="group">{children}</div>}
{open && (
<div role="group" className={styles.childrenWrapper}>
{children}
</div>
)}
</div>
);
};
@@ -57,7 +61,7 @@ const GroupHeader = (props: GroupHeaderProps) => {
return (
<div className={styles.headerWrapper}>
<Stack direction="row" alignItems="center" gap={1}>
<Stack alignItems="center" gap={0}>
<Stack alignItems="center" gap={0.5}>
<IconButton
name={isOpen ? 'angle-down' : 'angle-right'}
onClick={onToggle}
@@ -87,15 +91,27 @@ const getStyles = (theme: GrafanaTheme2) => ({
groupWrapper: css({
display: 'flex',
flexDirection: 'column',
position: 'relative',
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: theme.spacing(2.5),
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
}),
headerWrapper: css({
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
padding: theme.spacing(1),
paddingLeft: theme.spacing(4),
position: 'relative',
background: theme.colors.background.secondary,
border: 'none',
borderBottom: `solid 1px ${theme.colors.border.weak}`,
borderTopLeftRadius: theme.shape.radius.default,
borderTopRightRadius: theme.shape.radius.default,
'&:hover': {
background: theme.colors.action.hover,
},
}),
childrenWrapper: css({
position: 'relative',
}),
});
@@ -29,7 +29,7 @@ export const ListItem = (props: ListItemProps) => {
>
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
{/* icon */}
{icon}
<span className={styles.statusIcon}>{icon}</span>
<Stack direction="column" gap={0} flex="1" minWidth={0}>
{/* title */}
@@ -80,14 +80,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
alertListItemContainer: css({
position: 'relative',
listStyle: 'none',
background: theme.colors.background.primary,
borderBottom: `solid 1px ${theme.colors.border.weak}`,
padding: `${theme.spacing(1)} ${theme.spacing(1)}`,
padding: theme.spacing(1),
'&:hover': {
background: theme.colors.action.hover,
},
}),
textOverflow: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
color: 'inherit',
}),
// this will line up the icon with the title of the rule
statusIcon: css({
marginTop: theme.spacing(0.5),
}),
});
@@ -30,7 +30,7 @@ export const ListSection = ({
<li className={styles.wrapper} role="treeitem" aria-selected="false">
<div className={styles.sectionTitle}>
<Stack alignItems="center">
<Stack alignItems="center" gap={0}>
<Stack alignItems="center" gap={0.5}>
<IconButton
name={isCollapsed ? 'angle-right' : 'angle-down'}
onClick={toggleCollapsed}
@@ -61,23 +61,32 @@ export const ListSection = ({
const getStyles = (theme: GrafanaTheme2) => ({
groupItemsWrapper: css({
position: 'relative',
borderRadius: theme.shape.radius.default,
border: `solid 1px ${theme.colors.border.weak}`,
borderBottom: 'none',
marginLeft: theme.spacing(1.5),
// unfortunately we have to resort to this since we can't overwrite the styles of the list items individually
// unless we clone the React Elements and modify className
'li[role=treeitem]': {
paddingLeft: theme.spacing(6.5),
'&:before': {
content: "''",
position: 'absolute',
height: '100%',
marginLeft: theme.spacing(-1.5),
marginTop: theme.spacing(-1),
borderLeft: `solid 1px ${theme.colors.border.weak}`,
},
},
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
sectionTitle: css({
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
padding: theme.spacing(1, 1.5),
background: theme.colors.background.secondary,
border: `solid 1px ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
'&:hover': {
background: theme.colors.action.hover,
},
}),
});
@@ -0,0 +1,17 @@
import { useTranslate } from '@grafana/i18n';
import { Button } from '@grafana/ui';
interface LoadMoreButtonProps {
onClick: () => void;
}
export function LoadMoreButton({ onClick }: LoadMoreButtonProps) {
const { t } = useTranslate();
const label = t('alerting.rule-list.pagination.next-page', 'Show more…');
return (
<Button aria-label={label} fill="text" size="sm" variant="secondary" onClick={onClick}>
{label}
</Button>
);
}
@@ -58,7 +58,7 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
size={buttonSize}
key="edit"
variant="secondary"
icon="pen"
fill="text"
href={editURL}
>
<Trans i18nKey="common.edit">Edit</Trans>
@@ -67,10 +67,11 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
}
return (
<Stack gap={1} alignItems="center" wrap="nowrap">
<Stack gap={0} alignItems="center" wrap="nowrap">
{buttons}
<AlertRuleMenu
buttonSize={buttonSize}
fill="text"
rulerRule={rule}
promRule={promRule}
groupIdentifier={groupIdentifier}
@@ -1,156 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useTranslate } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { Dropdown, Icon, IconButton, LinkButton, Menu } from '@grafana/ui';
import { DataSourceRuleGroupIdentifier, GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
import { alertRuleApi } from '../../api/alertRuleApi';
import { featureDiscoveryApi } from '../../api/featureDiscoveryApi';
import { useFolder } from '../../hooks/useFolder';
import { useRulesAccess } from '../../utils/accessControlHooks';
import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSourceByUID } from '../../utils/datasource';
import { groups } from '../../utils/navigation';
import { isFederatedRuleGroup, isPluginProvidedGroup, isProvisionedRuleGroup } from '../../utils/rules';
import { GroupStatus } from './GroupStatus';
import { RuleActionsSkeleton } from './RuleActionsSkeleton';
const { useGetGrafanaRulerGroupQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
interface DataSourceGroupsActionMenuProps {
groupIdentifier: DataSourceRuleGroupIdentifier;
}
interface GrafanaGroupsActionMenuProps {
groupIdentifier: GrafanaRuleGroupIdentifier;
}
type RuleGroupActionsMenuProps = DataSourceGroupsActionMenuProps | GrafanaGroupsActionMenuProps;
export function RuleGroupActionsMenu({ groupIdentifier }: RuleGroupActionsMenuProps) {
switch (groupIdentifier.groupOrigin) {
case 'grafana':
return <GrafanaGroupsActionMenu groupIdentifier={groupIdentifier} />;
case 'datasource':
return <DataSourceGroupsActionMenu groupIdentifier={groupIdentifier} />;
default:
return null;
}
}
function DataSourceGroupsActionMenu({ groupIdentifier }: DataSourceGroupsActionMenuProps) {
const { canEditRules } = useRulesAccess();
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: groupIdentifier.rulesSource.uid });
const {
data: rulerRuleGroup,
error: rulerGroupError,
isLoading: isRulerGroupLoading,
} = useGetRuleGroupForNamespaceQuery(
dataSourceInfo?.rulerConfig
? {
namespace: groupIdentifier.namespace.name,
group: groupIdentifier.groupName,
rulerConfig: dataSourceInfo?.rulerConfig!,
}
: skipToken
);
const { t } = useTranslate();
const isFederated = rulerRuleGroup ? isFederatedRuleGroup(rulerRuleGroup) : false;
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
const canEdit = !isFederated && !isPluginProvided && canEditRules(groupIdentifier.rulesSource.name);
const rulesSource = getRulesDataSourceByUID(groupIdentifier.rulesSource.uid);
if (!rulesSource) {
// This should never happen
return null;
}
// We don't provide any actions if the data source doesn't support ruler
if (!dataSourceInfo?.rulerConfig) {
return null;
}
if (isRulerGroupLoading) {
return <RuleActionsSkeleton />;
}
if (rulerGroupError) {
if (isFetchError(rulerGroupError) && rulerGroupError.status === 404) {
return <GroupStatus status="deleting" />;
}
return (
<Icon
name="exclamation-triangle"
title={t('alerting.group-actions-menu.group-load-failed', 'Failed to load group details')}
/>
);
}
// This should never happen. Loading and error states are handled above
if (!rulerRuleGroup) {
return <Icon name="exclamation-triangle" title={t('alerting.group-actions-menu.unknown-error', 'Unknown error')} />;
}
return (
<Dropdown
placement="right-start"
overlay={
<Menu>
<Menu.Item
label={t('alerting.group-actions.details', 'Details')}
icon="info-circle"
data-testid="details-group-action"
url={groups.detailsPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
/>
{canEdit && (
<Menu.Item
label={t('alerting.group-actions.edit', 'Edit')}
icon="pen"
data-testid="edit-group-action"
url={groups.editPageLink(rulesSource.uid, groupIdentifier.namespace.name, groupIdentifier.groupName)}
/>
)}
</Menu>
}
>
<IconButton name="ellipsis-h" aria-label={t('alerting.group-actions.actions-trigger', 'Rule group actions')} />
</Dropdown>
);
}
function GrafanaGroupsActionMenu({ groupIdentifier }: GrafanaGroupsActionMenuProps) {
const { canEditRules } = useRulesAccess();
const { data: rulerRuleGroup } = useGetGrafanaRulerGroupQuery({
folderUid: groupIdentifier.namespace.uid,
groupName: groupIdentifier.groupName,
});
const isProvisioned = rulerRuleGroup ? isProvisionedRuleGroup(rulerRuleGroup) : false;
const isPluginProvided = rulerRuleGroup ? isPluginProvidedGroup(rulerRuleGroup) : false;
const folderUid = groupIdentifier.namespace.uid;
const { folder } = useFolder(folderUid);
const { t } = useTranslate();
const canEdit = folder?.canSave && !isProvisioned && !isPluginProvided && canEditRules(GRAFANA_RULES_SOURCE_NAME);
if (!canEdit) {
return null;
}
return (
<LinkButton
icon="pen"
variant="secondary"
size="sm"
href={groups.editPageLink(GRAFANA_RULES_SOURCE_NAME, folderUid, groupIdentifier.groupName)}
>
{t('alerting.group-actions.edit', 'Edit')}
</LinkButton>
);
}
@@ -54,7 +54,7 @@ const operationIcons: Record<RuleOperation, IconName> = {
};
// ⚠️ not trivial to update this, you have to re-do the math for the loading spinner
const ICON_SIZE = 18;
const ICON_SIZE = 15;
/**
* Make sure that the order of importance here matches the one we use in the StateBadge component for the detail view
@@ -109,7 +109,7 @@ export const RuleListIcon = memo(function RuleListIcon({
<div>
<Text color={iconColor}>
<div className={styles.iconsContainer}>
<Icon name={iconName} width={18} height={18} title={stateName} />
<Icon name={iconName} width={ICON_SIZE} height={ICON_SIZE} title={stateName} />
{/* this loading spinner works by using an optical illusion;
the actual icon is static and the "spinning" part is just a semi-transparent darker circle overlayed on top.
This makes it look like there is a small bright colored spinner rotating.
@@ -118,22 +118,22 @@ export const RuleListIcon = memo(function RuleListIcon({
<svg
width={ICON_SIZE}
height={ICON_SIZE}
viewBox="0 0 24 24"
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={styles.spinning}
>
<circle
r={ICON_SIZE / 2}
cx="12"
cy="12"
cx="10"
cy="10"
// make sure to match this color to the color of the list item background where it's being used! Works for both light and dark themes.
stroke={theme.colors.background.primary}
strokeWidth="3"
strokeWidth="2"
strokeLinecap="round"
fill="transparent"
strokeOpacity={0.85}
strokeDasharray="24px"
strokeDasharray="20px"
/>
</svg>
)}
@@ -159,8 +159,8 @@ const spin = keyframes({
const getStyles = (theme: GrafanaTheme2) => ({
iconsContainer: css({
position: 'relative',
width: 18,
height: 18,
width: ICON_SIZE,
height: ICON_SIZE,
'> *': {
position: 'absolute',
},
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useEffectOnce } from 'react-use';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { isLoading as isLoadingState, useAsync } from '../../hooks/useAsync';
/**
* Provides lazy loading for rule groups.
* Instead of loading all groups at once, it uses a generator to fetch them in batches as needed,
* which helps with performance when dealing with large numbers of rules.
*
* @param groupsGenerator - An async generator that yields rule groups in batches
* @param pageSize - Number of groups to display per page
* @returns Groups loaded so far and controls for navigating through rule groups
*/
export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
groupsGenerator: AsyncIterator<TGroup>,
pageSize: number
) {
const [groups, setGroups] = useState<TGroup[]>([]);
const [hasMoreGroups, setHasMoreGroups] = useState<boolean>(true);
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async () => {
let done = false;
const currentGroups: TGroup[] = [];
while (currentGroups.length < pageSize) {
const generatorResult = await groupsGenerator.next();
if (generatorResult.done) {
done = true;
break;
}
const group = generatorResult.value;
currentGroups.push(group);
}
if (done) {
setHasMoreGroups(false);
}
setGroups((groups) => groups.concat(currentGroups));
});
// make sure we only load the initial group exactly once
useEffectOnce(() => {
fetchMoreGroups();
});
const isLoading = isLoadingState(groupsRequestState);
return {
isLoading,
groups,
hasMoreGroups: !isLoading && hasMoreGroups,
fetchMoreGroups,
};
}
@@ -1,77 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { isLoading, useAsync } from '../../hooks/useAsync';
/**
* Provides pagination functionality for rule groups with lazy loading.
* Instead of loading all groups at once, it uses a generator to fetch them in batches as needed,
* which helps with performance when dealing with large numbers of rules.
*
* @param groupsGenerator - An async generator that yields rule groups in batches
* @param pageSize - Number of groups to display per page
* @returns Pagination state and controls for navigating through rule groups
*/
export function usePaginatedPrometheusGroups<TGroup extends PromRuleGroupDTO>(
groupsGenerator: AsyncIterator<TGroup>,
pageSize: number
) {
const [currentPage, setCurrentPage] = useState(1);
const [groups, setGroups] = useState<TGroup[]>([]);
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
let done = false;
const currentGroups: TGroup[] = [];
while (currentGroups.length < groupsCount) {
const generatorResult = await groupsGenerator.next();
if (generatorResult.done) {
done = true;
break;
}
const group = generatorResult.value;
currentGroups.push(group);
}
if (done) {
const groupsTotal = groups.length + currentGroups.length;
setLastPage(Math.ceil(groupsTotal / pageSize));
}
setGroups((groups) => [...groups, ...currentGroups]);
});
// lastPage could be computed from groups.length and pageSize
const fetchInProgress = isLoading(groupsRequestState);
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
// When going backward we already have the groups loaded, so no need to check if fetchInProgress
const canMoveBackward = currentPage > 1;
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 groupsPage = useMemo(() => {
return groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
}, [groups, currentPage, pageSize]);
return { isLoading: fetchInProgress, page: groupsPage, nextPage, previousPage, canMoveForward, canMoveBackward };
}
@@ -72,9 +72,9 @@
},
"config-editor": {
"description-additional-settings": "Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy.",
"description-request-timeout": "Set the request timeout in seconds. Default is 30 seconds.",
"title-additional-settings": "Additional settings",
"title-request-timeout": "Request timeout",
"description-request-timeout": "Set the request timeout in seconds. Default is 30 seconds."
"title-request-timeout": "Request Timeout"
},
"current-user-fallback-credentials": {
"alert-fallback-credentials-disabled": "Fallback credentials have been disabled. As user-based authentication only inherently supports requests with a user in scope, features such as alerting, recorded queries, or reporting will not function as expected. Please review the <2>documentation</2> for more details.",
+9 -16
View File
@@ -1050,7 +1050,7 @@
"folder-bulk-actions": {
"delete": {
"button": {
"label": "Delete rules"
"label": "Delete all rules"
}
},
"delete-modal-confirmation-text": "Delete",
@@ -1060,18 +1060,21 @@
"delete-modal-title": "Delete",
"error": "Failed to execute action for folder: {{error}}",
"more-button": {
"title": "Folder bulk Actions",
"tooltip": "Folder bulk Actions"
"title": "Folder actions",
"tooltip": "Folder actions"
},
"pause": {
"button": {
"label": "Pause all rule evaluation"
"label": "Pause all rules"
}
},
"unpause": {
"button": {
"label": "Resume all rule evaluation"
"label": "Resume all rules"
}
},
"view": {
"folder": "View folder"
}
},
"folder-selector": {
@@ -1206,15 +1209,6 @@
"grafana-rules-export-preview": {
"text-loading": "Loading...."
},
"group-actions": {
"actions-trigger": "Rule group actions",
"details": "Details",
"edit": "Edit"
},
"group-actions-menu": {
"group-load-failed": "Failed to load group details",
"unknown-error": "Unknown error"
},
"group-and-namespace-fields": {
"group-picker-label-group": "Group",
"namespace-picker-label-namespace": "Namespace"
@@ -2070,8 +2064,7 @@
"new-datasource-recording-rule": "New Data source recording rule",
"new-grafana-recording-rule": "New Grafana recording rule",
"pagination": {
"next-page": "next page",
"previous-page": "previous page"
"next-page": "Show more…"
},
"recording-rules": "Recording rules",
"return-button": {