Alerting: List V2 - Grouped view filters (#106400)
* Add group and namespace filtering for GMA rules * Add group and namespace filtering for DMA rules * Fix view mode handling * Preserve group and namespace filters when switching views * update "no rules" logic for Grafana managed rules * use groupFilter function for filter logic * Add populateCache docs, tidy up api consts * Fix imports in tests * Fix failing import tests --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
co-authored by
Gilles De Mey
parent
3fe25d2f1b
commit
95efe7a388
@@ -1400,6 +1400,9 @@ exports[`better eslint`] = {
|
||||
"public/app/features/alerting/unified/hooks/useControlledFieldArray.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/hooks/useFilteredRules.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/unified/insights/InsightsMenuButton.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
|
||||
+9
-4
@@ -1,4 +1,4 @@
|
||||
import { render } from 'test/test-utils';
|
||||
import { render, waitFor } from 'test/test-utils';
|
||||
import { byLabelText, byRole } from 'testing-library-selector';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
@@ -47,7 +47,7 @@ const ui = {
|
||||
|
||||
alertingFactory.dataSource.mimir().build({ meta: { alerting: true } });
|
||||
|
||||
describe.skip('ImportToGMARules', () => {
|
||||
describe('ImportToGMARules', () => {
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleCreate]);
|
||||
testWithFeatureToggles(['alertingImportYAMLUI', 'alertingMigrationUI']);
|
||||
|
||||
@@ -62,8 +62,9 @@ describe.skip('ImportToGMARules', () => {
|
||||
it('should render datasource options', async () => {
|
||||
const { user } = render(<ImportToGMARules />);
|
||||
|
||||
// Wait for the data source picker to be ready
|
||||
// Wait for the data source picker to be ready and enabled
|
||||
const dsPicker = await ui.dsImport.dsPicker.find();
|
||||
await waitFor(() => expect(dsPicker).toBeEnabled());
|
||||
|
||||
await user.click(dsPicker);
|
||||
await user.click(await ui.dsImport.mimirDsOption.find());
|
||||
@@ -91,8 +92,12 @@ describe.skip('ImportToGMARules', () => {
|
||||
it('should show confirmation dialog when importing from data source', async () => {
|
||||
const { user } = render(<ImportToGMARules />);
|
||||
|
||||
// Wait for the data source picker to be enabled
|
||||
const dsPicker = ui.dsImport.dsPicker.get();
|
||||
await waitFor(() => expect(dsPicker).toBeEnabled());
|
||||
|
||||
// Select a data source
|
||||
await user.click(ui.dsImport.dsPicker.get());
|
||||
await user.click(dsPicker);
|
||||
await user.click(await ui.dsImport.mimirDsOption.find());
|
||||
|
||||
// Click the import button
|
||||
|
||||
@@ -3,11 +3,14 @@ import { Suspense, lazy } from 'react';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import RulesFilterV1 from './RulesFilter.v1';
|
||||
import { SupportedView } from './RulesViewModeSelector';
|
||||
|
||||
const RulesFilterV2 = lazy(() => import('./RulesFilter.v2'));
|
||||
|
||||
interface RulesFilerProps {
|
||||
onClear?: () => void;
|
||||
viewMode?: SupportedView;
|
||||
onViewModeChange?: (viewMode: SupportedView) => void;
|
||||
}
|
||||
|
||||
const RulesFilter = (props: RulesFilerProps) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { alertStateToReadable } from '../../../utils/rules';
|
||||
import { PopupCard } from '../../HoverCard';
|
||||
import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker';
|
||||
|
||||
import { RulesViewModeSelector } from './RulesViewModeSelector';
|
||||
import { RulesViewModeSelector, SupportedView } from './RulesViewModeSelector';
|
||||
|
||||
const RuleTypeOptions: SelectableValue[] = [
|
||||
{ label: 'Alert ', value: PromRuleType.Alerting },
|
||||
@@ -44,6 +44,8 @@ const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlActi
|
||||
|
||||
interface RulesFilerProps {
|
||||
onClear?: () => void;
|
||||
viewMode?: SupportedView;
|
||||
onViewModeChange?: (viewMode: SupportedView) => void;
|
||||
}
|
||||
|
||||
const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
|
||||
@@ -51,7 +53,7 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]
|
||||
value,
|
||||
}));
|
||||
|
||||
const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
|
||||
const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilerProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { pluginsFilterEnabled } = usePluginsFilterStatus();
|
||||
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
|
||||
@@ -317,7 +319,7 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
|
||||
<Label>
|
||||
<Trans i18nKey="alerting.rules-filter.view-as">View as</Trans>
|
||||
</Label>
|
||||
<RulesViewModeSelector />
|
||||
<RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} />
|
||||
</div>
|
||||
</Stack>
|
||||
{hasActiveFilters && (
|
||||
|
||||
+61
-14
@@ -1,3 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
@@ -15,24 +17,58 @@ const ViewOptions: Array<SelectableValue<SupportedView>> = [
|
||||
{ icon: 'list-ul', label: 'List', value: 'list' },
|
||||
];
|
||||
|
||||
function RulesViewModeSelectorV2() {
|
||||
interface RulesViewModeSelectorV2Props {
|
||||
viewMode?: SupportedView;
|
||||
onViewModeChange?: (viewMode: SupportedView) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selecting a view mode is no longer a simple toggle relying on the URL query params.
|
||||
* We now need to check if the current filters are compatible with the grouped view.
|
||||
* If they are, we show the grouped view by default.
|
||||
* If they are not, we show the list view.
|
||||
* Use the complementary {@link useListViewMode} hook to get the current view mode and a handler for changing it.
|
||||
*/
|
||||
function RulesViewModeSelectorV2({ viewMode, onViewModeChange }: RulesViewModeSelectorV2Props) {
|
||||
return <RadioButtonGroup options={ViewOptions} value={viewMode} onChange={onViewModeChange} />;
|
||||
}
|
||||
|
||||
export function useListViewMode() {
|
||||
const [queryParams, updateQueryParams] = useURLSearchParams();
|
||||
const { hasActiveFilters } = useRulesFilter();
|
||||
const wantsListView = queryParams.get('view') === 'list';
|
||||
const { activeFilters } = useRulesFilter();
|
||||
|
||||
const selectedViewOption = hasActiveFilters || wantsListView ? 'list' : 'grouped';
|
||||
const queryStringView: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
|
||||
|
||||
/* If we change to the grouped view, we just remove the "list" and "search" params */
|
||||
const handleViewChange = (view: SupportedView) => {
|
||||
if (view === 'list') {
|
||||
updateQueryParams({ view });
|
||||
const areFiltersGroupedViewCompatible = activeFilters.every(
|
||||
(filter) => filter === 'groupName' || filter === 'namespace'
|
||||
);
|
||||
const showListView = areFiltersGroupedViewCompatible === false || queryStringView === 'list';
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(view: SupportedView) => {
|
||||
if (view === 'grouped') {
|
||||
// When switching to grouped view, preserve filters only if they are grouped-view compatible
|
||||
if (areFiltersGroupedViewCompatible) {
|
||||
// Only remove view parameter, keep search (preserve group/namespace filters)
|
||||
updateQueryParams({ view: undefined });
|
||||
} else {
|
||||
// Clear both view and search (clear all filters)
|
||||
updateQueryParams({ view: undefined, search: undefined });
|
||||
}
|
||||
} else {
|
||||
updateQueryParams({ view });
|
||||
}
|
||||
trackRulesListViewChange({ view });
|
||||
} else {
|
||||
updateQueryParams({ view: undefined, search: undefined });
|
||||
}
|
||||
};
|
||||
},
|
||||
[updateQueryParams, areFiltersGroupedViewCompatible]
|
||||
);
|
||||
|
||||
return <RadioButtonGroup options={ViewOptions} value={selectedViewOption} onChange={handleViewChange} />;
|
||||
const viewMode: SupportedView = showListView ? 'list' : 'grouped';
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
handleViewChange,
|
||||
};
|
||||
}
|
||||
|
||||
const LegacyViewOptions: Array<SelectableValue<LegacySupportedView>> = [
|
||||
@@ -66,4 +102,15 @@ function viewParamToLegacyView(viewParam: string | null): LegacySupportedView {
|
||||
return 'grouped';
|
||||
}
|
||||
|
||||
export const RulesViewModeSelector = shouldUseAlertingListViewV2() ? RulesViewModeSelectorV2 : RulesViewModeSelectorV1;
|
||||
interface RulesViewModeSelectorProps {
|
||||
viewMode?: SupportedView;
|
||||
onViewModeChange?: (viewMode: SupportedView) => void;
|
||||
}
|
||||
|
||||
export function RulesViewModeSelector({ viewMode, onViewModeChange }: RulesViewModeSelectorProps) {
|
||||
if (shouldUseAlertingListViewV2()) {
|
||||
return <RulesViewModeSelectorV2 viewMode={viewMode} onViewModeChange={onViewModeChange} />;
|
||||
}
|
||||
|
||||
return <RulesViewModeSelectorV1 />;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export function useRulesFilter() {
|
||||
}, [searchQuery]);
|
||||
const hasActiveFilters = useMemo(() => Object.values(filterState).some((filter) => !isEmpty(filter)), [filterState]);
|
||||
|
||||
const activeFilters = useMemo(() => {
|
||||
return chain(filterState).omitBy(isEmpty).keys().filter(isRuleFilterKey).value();
|
||||
}, [filterState]);
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(newFilter: RulesFilter) => {
|
||||
const newSearchQuery = applySearchFilterToQuery(searchQuery, newFilter);
|
||||
@@ -86,7 +90,7 @@ export function useRulesFilter() {
|
||||
}
|
||||
}, [queryParams, updateFilters, filterState, updateQueryParams]);
|
||||
|
||||
return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters };
|
||||
return { filterState, hasActiveFilters, activeFilters, searchQuery, setSearchQuery, updateFilters };
|
||||
}
|
||||
|
||||
export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => {
|
||||
@@ -356,3 +360,20 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: Rules
|
||||
return ds?.name && filterState?.dataSourceNames?.includes(ds.name);
|
||||
});
|
||||
};
|
||||
|
||||
const RULES_FILTER_KEYS: Set<keyof RulesFilter> = new Set([
|
||||
'freeFormWords',
|
||||
'namespace',
|
||||
'groupName',
|
||||
'ruleName',
|
||||
'ruleState',
|
||||
'ruleType',
|
||||
'dataSourceNames',
|
||||
'labels',
|
||||
'ruleHealth',
|
||||
'dashboardUid',
|
||||
'plugins',
|
||||
'contactPoint',
|
||||
]);
|
||||
|
||||
const isRuleFilterKey = (key: string): key is keyof RulesFilter => RULES_FILTER_KEYS.has(key as keyof RulesFilter);
|
||||
|
||||
@@ -22,14 +22,12 @@ import {
|
||||
RuleWithOrigin,
|
||||
useFilteredRulesIteratorProvider,
|
||||
} from './hooks/useFilteredRulesIterator';
|
||||
import { FRONTEND_LIST_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
|
||||
|
||||
interface FilterViewProps {
|
||||
filterState: RulesFilter;
|
||||
}
|
||||
|
||||
const FRONTENT_PAGE_SIZE = 100;
|
||||
const API_PAGE_SIZE = 2000;
|
||||
|
||||
export function FilterView({ filterState }: FilterViewProps) {
|
||||
// ⚠️ We use a key to force the component to unmount and remount when the filter state changes
|
||||
// filterState is a complex object including arrays and is constructed from URL params
|
||||
@@ -79,10 +77,10 @@ function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
* ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint
|
||||
* and the iterator will allow us to exhaust the iterable in a stateful way
|
||||
*/
|
||||
const { iterable, abortController } = getFilteredRulesIterator(filterState, API_PAGE_SIZE);
|
||||
const { iterable, abortController } = getFilteredRulesIterator(filterState, getApiGroupPageSize(true));
|
||||
const rulesBatchIterator = iterable
|
||||
.pipe(
|
||||
bufferCountOrTime(FRONTENT_PAGE_SIZE, 1000),
|
||||
bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000),
|
||||
onFinished(() => setDoneSearching(true))
|
||||
)
|
||||
[Symbol.asyncIterator]();
|
||||
@@ -98,7 +96,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
|
||||
let loadedRulesCount = 0;
|
||||
|
||||
while (loadedRulesCount < FRONTENT_PAGE_SIZE) {
|
||||
while (loadedRulesCount < FRONTEND_LIST_PAGE_SIZE) {
|
||||
const nextRulesBatch = await rulesIterator.next();
|
||||
if (nextRulesBatch.done) {
|
||||
return;
|
||||
|
||||
@@ -10,7 +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';
|
||||
import { FRONTED_GROUPED_PAGE_SIZE } from './paginationLimits';
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
@@ -67,7 +67,7 @@ describe('RuleList - GroupedView', () => {
|
||||
const mimirNamespace = await ui.namespace(/test-mimir-namespace/).find(mimirSection);
|
||||
const firstPageGroups = await ui.group(/test-group-([1-9]|[1-3][0-9]|40)/).findAll(mimirNamespace);
|
||||
|
||||
expect(firstPageGroups).toHaveLength(DATA_SOURCE_GROUP_PAGE_SIZE);
|
||||
expect(firstPageGroups).toHaveLength(FRONTED_GROUPED_PAGE_SIZE);
|
||||
expect(firstPageGroups[0]).toHaveTextContent('test-group-1');
|
||||
expect(firstPageGroups[24]).toHaveTextContent('test-group-25');
|
||||
expect(firstPageGroups[39]).toHaveTextContent('test-group-40');
|
||||
@@ -79,7 +79,7 @@ describe('RuleList - GroupedView', () => {
|
||||
|
||||
const secondPageGroups = await ui.group(/test-group-(4[1-9]|[5-7][0-9]|80)/).findAll(mimirNamespace);
|
||||
|
||||
expect(secondPageGroups).toHaveLength(DATA_SOURCE_GROUP_PAGE_SIZE);
|
||||
expect(secondPageGroups).toHaveLength(FRONTED_GROUPED_PAGE_SIZE);
|
||||
expect(secondPageGroups[0]).toHaveTextContent('test-group-41');
|
||||
expect(secondPageGroups[24]).toHaveTextContent('test-group-65');
|
||||
expect(secondPageGroups[39]).toHaveTextContent('test-group-80');
|
||||
|
||||
@@ -14,16 +14,32 @@ import { DataSourceSection } from './components/DataSourceSection';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
export function GroupedView() {
|
||||
interface GroupedViewProps {
|
||||
groupFilter?: string;
|
||||
namespaceFilter?: string;
|
||||
}
|
||||
|
||||
export function GroupedView({ groupFilter, namespaceFilter }: GroupedViewProps) {
|
||||
const externalRuleSources = useMemo(() => getExternalRulesSources(), []);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1} role="list">
|
||||
<DataSourceErrorBoundary rulesSourceIdentifier={GrafanaRulesSource}>
|
||||
<PaginatedGrafanaLoader />
|
||||
<PaginatedGrafanaLoader
|
||||
groupFilter={groupFilter}
|
||||
namespaceFilter={namespaceFilter}
|
||||
key={`${groupFilter}-${namespaceFilter}`}
|
||||
/>
|
||||
</DataSourceErrorBoundary>
|
||||
{externalRuleSources.map((ruleSource) => {
|
||||
return <DataSourceLoader key={ruleSource.uid} rulesSourceIdentifier={ruleSource} />;
|
||||
return (
|
||||
<DataSourceLoader
|
||||
key={ruleSource.uid}
|
||||
rulesSourceIdentifier={ruleSource}
|
||||
groupFilter={groupFilter}
|
||||
namespaceFilter={namespaceFilter}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
@@ -31,13 +47,15 @@ export function GroupedView() {
|
||||
|
||||
interface DataSourceLoaderProps {
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
groupFilter?: string;
|
||||
namespaceFilter?: string;
|
||||
}
|
||||
|
||||
export function GrafanaDataSourceLoader() {
|
||||
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true} />;
|
||||
}
|
||||
|
||||
function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) {
|
||||
function DataSourceLoader({ rulesSourceIdentifier, groupFilter, namespaceFilter }: DataSourceLoaderProps) {
|
||||
const { data: dataSourceInfo, isLoading, error } = useDiscoverDsFeaturesQuery({ uid: rulesSourceIdentifier.uid });
|
||||
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
@@ -55,9 +73,10 @@ function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) {
|
||||
return (
|
||||
<DataSourceErrorBoundary rulesSourceIdentifier={rulesSourceIdentifier}>
|
||||
<PaginatedDataSourceLoader
|
||||
key={rulesSourceIdentifier.uid}
|
||||
rulesSourceIdentifier={rulesSourceIdentifier}
|
||||
application={dataSourceInfo.application}
|
||||
groupFilter={groupFilter}
|
||||
namespaceFilter={namespaceFilter}
|
||||
/>
|
||||
</DataSourceErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Icon, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Icon, Spinner, Stack, Text } from '@grafana/ui';
|
||||
import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { groups } from '../utils/navigation';
|
||||
|
||||
@@ -15,23 +14,54 @@ 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 { groupFilter as groupFilterFn } from './hooks/filters';
|
||||
import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator';
|
||||
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
|
||||
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
|
||||
|
||||
export const DATA_SOURCE_GROUP_PAGE_SIZE = 40;
|
||||
|
||||
interface PaginatedDataSourceLoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
|
||||
interface LoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
|
||||
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
|
||||
groupFilter?: string;
|
||||
namespaceFilter?: string;
|
||||
}
|
||||
|
||||
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
export function PaginatedDataSourceLoader({
|
||||
rulesSourceIdentifier,
|
||||
application,
|
||||
groupFilter,
|
||||
namespaceFilter,
|
||||
}: LoaderProps) {
|
||||
const key = `${rulesSourceIdentifier.uid}-${groupFilter}-${namespaceFilter}`;
|
||||
|
||||
// Key is crucial. It resets the generator when filters change.
|
||||
return (
|
||||
<PaginatedGroupsLoader
|
||||
key={key}
|
||||
rulesSourceIdentifier={rulesSourceIdentifier}
|
||||
application={application}
|
||||
groupFilter={groupFilter}
|
||||
namespaceFilter={namespaceFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedGroupsLoader({ rulesSourceIdentifier, application, groupFilter, namespaceFilter }: LoaderProps) {
|
||||
// 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 hasFilters = Boolean(groupFilter || namespaceFilter);
|
||||
|
||||
const { uid, name } = rulesSourceIdentifier;
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true });
|
||||
const prometheusGroupsGenerator = usePrometheusGroupsGenerator({
|
||||
populateCache: hasFilters ? false : true,
|
||||
});
|
||||
|
||||
// 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(hasFilters);
|
||||
|
||||
const groupsGenerator = useRef(
|
||||
toIndividualRuleGroups(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE))
|
||||
toIndividualRuleGroups(prometheusGroupsGenerator(rulesSourceIdentifier, apiGroupPageSize))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,11 +69,21 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
|
||||
return () => {
|
||||
currentGenerator.return();
|
||||
};
|
||||
}, [groupsGenerator]);
|
||||
}, []);
|
||||
|
||||
const filterFn = useMemo(
|
||||
() => (group: PromRuleGroupDTO) =>
|
||||
groupFilterFn(group, {
|
||||
namespace: namespaceFilter,
|
||||
groupName: groupFilter,
|
||||
}),
|
||||
[namespaceFilter, groupFilter]
|
||||
);
|
||||
|
||||
const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups(
|
||||
groupsGenerator.current,
|
||||
DATA_SOURCE_GROUP_PAGE_SIZE
|
||||
FRONTED_GROUPED_PAGE_SIZE,
|
||||
filterFn
|
||||
);
|
||||
|
||||
const hasNoRules = isEmpty(groups) && !isLoading;
|
||||
@@ -86,13 +126,7 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }
|
||||
<Trans i18nKey="alerting.rule-list.loading-more-groups">Loading more groups...</Trans>
|
||||
</Stack>
|
||||
)}
|
||||
{hasNoRules && (
|
||||
<div className={styles.noRules}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="alerting.rule-list.empty-data-source">No rules found</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{hasNoRules && <NoRulesFound />}
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
@@ -127,9 +161,3 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
noRules: css({
|
||||
margin: theme.spacing(1.5, 0, 0.5, 4),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Icon, Spinner, Stack, Text } from '@grafana/ui';
|
||||
import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting';
|
||||
import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton';
|
||||
import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA';
|
||||
@@ -17,15 +17,39 @@ 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 { groupFilter as groupFilterFn } from './hooks/filters';
|
||||
import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
|
||||
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
|
||||
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
|
||||
|
||||
export const GRAFANA_GROUP_PAGE_SIZE = 40;
|
||||
interface LoaderProps {
|
||||
groupFilter?: string;
|
||||
namespaceFilter?: string;
|
||||
}
|
||||
|
||||
export function PaginatedGrafanaLoader() {
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true, limitAlerts: 0 });
|
||||
export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderProps) {
|
||||
const key = `${groupFilter}-${namespaceFilter}`;
|
||||
|
||||
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE)));
|
||||
// Key is crucial. It resets the generator when filters change.
|
||||
return <PaginatedGroupsLoader key={key} groupFilter={groupFilter} namespaceFilter={namespaceFilter} />;
|
||||
}
|
||||
|
||||
function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) {
|
||||
// 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 hasFilters = Boolean(groupFilter || namespaceFilter);
|
||||
|
||||
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({
|
||||
populateCache: hasFilters ? false : true,
|
||||
limitAlerts: 0,
|
||||
});
|
||||
|
||||
// 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(hasFilters);
|
||||
|
||||
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize)));
|
||||
|
||||
useEffect(() => {
|
||||
const currentGenerator = groupsGenerator.current;
|
||||
@@ -34,9 +58,19 @@ export function PaginatedGrafanaLoader() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filterFn = useMemo(
|
||||
() => (group: PromRuleGroupDTO) =>
|
||||
groupFilterFn(group, {
|
||||
namespace: namespaceFilter,
|
||||
groupName: groupFilter,
|
||||
}),
|
||||
[namespaceFilter, groupFilter]
|
||||
);
|
||||
|
||||
const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups(
|
||||
groupsGenerator.current,
|
||||
GRAFANA_GROUP_PAGE_SIZE
|
||||
FRONTED_GROUPED_PAGE_SIZE,
|
||||
filterFn
|
||||
);
|
||||
|
||||
const groupsByFolder = useMemo(() => groupBy(groups, 'folderUid'), [groups]);
|
||||
@@ -78,7 +112,9 @@ export function PaginatedGrafanaLoader() {
|
||||
</ListSection>
|
||||
);
|
||||
})}
|
||||
{hasNoRules && <GrafanaNoRulesCTA />}
|
||||
{/* 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>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AccessControlAction } from 'app/types';
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
import { testWithFeatureToggles } from '../test/test-utils';
|
||||
|
||||
import RuleList, { RuleListActions } from './RuleList.v2';
|
||||
|
||||
@@ -23,12 +25,18 @@ jest.mock('./GroupedView', () => ({
|
||||
const ui = {
|
||||
filterView: byTestId('filter-view'),
|
||||
groupedView: byTestId('grouped-view'),
|
||||
modeSelector: {
|
||||
grouped: byRole('radio', { name: /grouped/i }),
|
||||
list: byRole('radio', { name: /list/i }),
|
||||
},
|
||||
searchInput: byTestId('search-query-input'),
|
||||
};
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
testWithFeatureToggles(['alertingListViewV2']);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
@@ -61,8 +69,84 @@ describe('RuleList v2', () => {
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when a filter is applied', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } });
|
||||
it('should show grouped view when only group filter is applied', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } });
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show grouped view when only namespace filter is applied', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=namespace:global'] } });
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show grouped view when both group and namespace filters are applied', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } });
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when group and namespace filters are combined with other filter types', () => {
|
||||
render(<RuleList />, {
|
||||
historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global state:firing'] },
|
||||
});
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show grouped view when view parameter is empty', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?view='] } });
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show grouped view when search parameter is empty', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search='] } });
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each<{ filterType: keyof RulesFilter; searchQuery: string }>([
|
||||
{ filterType: 'freeFormWords', searchQuery: 'cpu alert' },
|
||||
{ filterType: 'ruleName', searchQuery: 'rule:"cpu 80%"' },
|
||||
{ filterType: 'ruleState', searchQuery: 'state:firing' },
|
||||
{ filterType: 'ruleType', searchQuery: 'type:alerting' },
|
||||
{ filterType: 'dataSourceNames', searchQuery: 'datasource:prometheus' },
|
||||
{ filterType: 'labels', searchQuery: 'label:team=backend' },
|
||||
{ filterType: 'ruleHealth', searchQuery: 'health:error' },
|
||||
{ filterType: 'contactPoint', searchQuery: 'contactPoint:slack' },
|
||||
])('should show list view when %s filter is applied', ({ filterType, searchQuery }) => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } });
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when "view=list" URL parameter is present with group filter', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } });
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when "view=list" URL parameter is present with namespace filter', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } });
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when "view=list" URL parameter is present with both group and namespace filters', () => {
|
||||
render(<RuleList />, {
|
||||
historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] },
|
||||
});
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
@@ -160,3 +244,47 @@ describe('RuleListActions', () => {
|
||||
expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleList v2 - View switching', () => {
|
||||
it('should preserve both group and namespace filters when switching from list view to grouped view', async () => {
|
||||
// Start with list view and both group and namespace filters
|
||||
const { user } = render(<RuleList />, {
|
||||
historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] },
|
||||
});
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
|
||||
// Click the "Grouped" view button
|
||||
const groupedButton = await ui.modeSelector.grouped.find();
|
||||
await user.click(groupedButton);
|
||||
|
||||
// Should preserve both filters and switch to grouped view
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
|
||||
// Verify filters are preserved
|
||||
expect(ui.searchInput.get()).toHaveValue('group:cpu-usage namespace:global');
|
||||
expect(ui.modeSelector.list.query()).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should clear all filters when switching from list view to grouped view with group, namespace and other filters', async () => {
|
||||
// Start with list view with all types of filters
|
||||
const { user } = render(<RuleList />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/?view=list&search=group:cpu-usage namespace:global state:firing rule:"test"'],
|
||||
},
|
||||
});
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
|
||||
// Click the "Grouped" view button
|
||||
const groupedButton = await ui.modeSelector.grouped.find();
|
||||
await user.click(groupedButton);
|
||||
|
||||
// Should clear all filters because other filters are present
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
|
||||
// Verify all filters are cleared
|
||||
expect(ui.searchInput.get()).toHaveValue('');
|
||||
expect(ui.modeSelector.list.query()).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,9 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui';
|
||||
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import RulesFilter from '../components/rules/Filter/RulesFilter';
|
||||
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
|
||||
import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector';
|
||||
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
||||
import { useRulesFilter } from '../hooks/useFilteredRules';
|
||||
import { useURLSearchParams } from '../hooks/useURLSearchParams';
|
||||
import { isAdmin } from '../utils/misc';
|
||||
|
||||
import { FilterView } from './FilterView';
|
||||
@@ -17,16 +16,17 @@ import { GroupedView } from './GroupedView';
|
||||
import { RuleListPageTitle } from './RuleListPageTitle';
|
||||
|
||||
function RuleList() {
|
||||
const [queryParams] = useURLSearchParams();
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
|
||||
const showListView = hasActiveFilters || view === 'list';
|
||||
const { filterState } = useRulesFilter();
|
||||
const { viewMode, handleViewChange } = useListViewMode();
|
||||
|
||||
return (
|
||||
<>
|
||||
<RulesFilter onClear={() => {}} />
|
||||
{showListView ? <FilterView filterState={filterState} /> : <GroupedView />}
|
||||
<RulesFilter viewMode={viewMode} onViewModeChange={handleViewChange} />
|
||||
{viewMode === 'list' ? (
|
||||
<FilterView filterState={filterState} />
|
||||
) : (
|
||||
<GroupedView groupFilter={filterState.groupName} namespaceFilter={filterState.namespace} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
// @TODO I don't like applying the margins to this component here, ideally the parent component should be layouting this.
|
||||
export const NoRulesFound = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.noRules}>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="alerting.rule-list.empty-data-source">No rules found</Trans>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
noRules: css({
|
||||
margin: theme.spacing(1.5, 0, 0.5, 4),
|
||||
}),
|
||||
});
|
||||
@@ -14,16 +14,20 @@ import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules';
|
||||
/**
|
||||
* @returns True if the group matches the filter, false otherwise. Keeps rules intact
|
||||
*/
|
||||
export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean {
|
||||
export function groupFilter(
|
||||
group: PromRuleGroupDTO,
|
||||
filterState: Pick<RulesFilter, 'namespace' | 'groupName'>
|
||||
): boolean {
|
||||
const { name, file } = group;
|
||||
const { namespace, groupName } = filterState;
|
||||
|
||||
// Add fuzzy search for namespace
|
||||
if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) {
|
||||
if (namespace && !file.toLocaleLowerCase().includes(namespace.toLocaleLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add fuzzy search for group name
|
||||
if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) {
|
||||
if (groupName && !name.toLocaleLowerCase().includes(groupName.toLocaleLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi';
|
||||
const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi;
|
||||
|
||||
interface UseGeneratorHookOptions {
|
||||
/**
|
||||
* Whether to populate the RTKQ cache with the groups.
|
||||
* Populating cache might harm performance when fetching a lot of groups or fetching multiple pages
|
||||
*/
|
||||
populateCache?: boolean;
|
||||
limitAlerts?: number;
|
||||
}
|
||||
|
||||
+7
-1
@@ -16,7 +16,8 @@ import { isLoading as isLoadingState, useAsync } from '../../hooks/useAsync';
|
||||
*/
|
||||
export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
|
||||
groupsGenerator: AsyncIterator<TGroup>,
|
||||
pageSize: number
|
||||
pageSize: number,
|
||||
filter?: (group: TGroup) => boolean
|
||||
) {
|
||||
const [groups, setGroups] = useState<TGroup[]>([]);
|
||||
const [hasMoreGroups, setHasMoreGroups] = useState<boolean>(true);
|
||||
@@ -31,7 +32,12 @@ export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const group = generatorResult.value;
|
||||
if (filter && !filter(group)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentGroups.push(group);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const FRONTEND_LIST_PAGE_SIZE = 100;
|
||||
|
||||
export const FILTERED_GROUPS_API_PAGE_SIZE = 2000;
|
||||
export const DEFAULT_GROUPS_API_PAGE_SIZE = 40;
|
||||
export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE;
|
||||
|
||||
export function getApiGroupPageSize(hasFilters: boolean) {
|
||||
return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
|
||||
}
|
||||
Reference in New Issue
Block a user