Alerting: New list view layout update (#105489)
This commit is contained in:
+44
-13
@@ -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}
|
||||
+2
-2
@@ -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 };
|
||||
}
|
||||
+2
-2
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user