Alerting: Alert list - pagination and filtering part 1 (#96423)
* Add basic token-based paginated fetching * Add 1:many relation between UI and API pages * Fix pagination arrows * Add pagination to hierarchical view * Add multidatasource filtering * Improve flushing filtered rules, add better identifiers * Fix pagination for data sources not supporting server side pagination * Use alert rule loader on the filter view Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> * use useTransition and update loader * types * Update ruleGruopIdentifier. Add actions and location to recording rules * Update to the new API parameters * Refactor iterator code * Use ix to merge iterators * Improve perf * use AbortController to cancel loading pages * remove iterops for now * add comments * add application and rulesource information to list view * update test * update list view functionality * add emptystate * automatically load more items when we get to the bottom of the page * reduce number of loaders * separate hook for useFilteredRulesIteratorProvider * use useDeepCompareEffect to track filter state changes * fix weird no results loading glitch * fix rare case where changing filters wouldn't update the list * add number of results to component * Simplify FilterView rerendering * add filter for dashboard * Add tests for filtered view, use data source UID instead of names in the interator code * Improve HTML semantics, extract a separate GroupedView component * Split RuleList.v2 into multiple files * Split tests into Filtered and Grouped view files * PR feedback * Improve error handling, add tests for GroupedView * Improve types, small refactoring * Improve rules setup * Small improvements, v1 and v2 versions of the view type selector * Remove yarn cache changes * Import from test-utils * Move groupIdentifiers, improve state param parsing * reorder imports * reorder imports * update yarn resolution * i18n * Improve API mock, increase timeout limit * Add tests for RuleList.v2 * Update tests * Fix mocks in test * Fix lint * Fix data sources mock --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
co-authored by
Gilles De Mey
Tom Ratcliffe
parent
65dfbd7731
commit
8055d69ad2
@@ -192,6 +192,7 @@
|
||||
"eslint-scope": "^8.1.0",
|
||||
"eslint-webpack-plugin": "4.2.0",
|
||||
"expose-loader": "5.0.0",
|
||||
"fishery": "^2.2.2",
|
||||
"fork-ts-checker-webpack-plugin": "9.0.2",
|
||||
"glob": "11.0.0",
|
||||
"html-loader": "5.1.0",
|
||||
@@ -207,6 +208,7 @@
|
||||
"jest-junit": "16.0.0",
|
||||
"jest-matcher-utils": "29.7.0",
|
||||
"jest-watch-typeahead": "^2.2.2",
|
||||
"jsdom-testing-mocks": "^1.13.1",
|
||||
"knip": "^5.10.0",
|
||||
"lerna": "8.1.8",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
@@ -334,6 +336,7 @@
|
||||
"i18next-browser-languagedetector": "^7.0.2",
|
||||
"immer": "10.1.1",
|
||||
"immutable": "4.3.7",
|
||||
"ix": "^7.0.0",
|
||||
"jquery": "3.7.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-markup": "^1.1.0",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { RulerDataSourceConfig } from 'app/types/unified-alerting';
|
||||
import { GrafanaRulesSourceSymbol, RulerDataSourceConfig, RulesSourceUid } from 'app/types/unified-alerting';
|
||||
|
||||
import {
|
||||
AlertmanagerApiFeatures,
|
||||
PromApplication,
|
||||
RulesSourceApplication,
|
||||
} from '../../../../types/unified-alerting-dto';
|
||||
import {
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
getDataSourceUID,
|
||||
getRulesDataSourceByUID,
|
||||
isGrafanaRulesSource,
|
||||
} from '../utils/datasource';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getDataSourceUID, getRulesDataSourceByUID } from '../utils/datasource';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
import { discoverAlertmanagerFeatures, discoverFeaturesByUid } from './buildInfo';
|
||||
@@ -40,14 +35,14 @@ export const featureDiscoveryApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
|
||||
discoverDsFeatures: build.query<RulesSourceFeatures, { rulesSourceName: string } | { uid: string }>({
|
||||
discoverDsFeatures: build.query<RulesSourceFeatures, { rulesSourceName: string } | { uid: RulesSourceUid }>({
|
||||
queryFn: async (rulesSourceIdentifier) => {
|
||||
const dataSourceUID = getDataSourceUID(rulesSourceIdentifier);
|
||||
if (!dataSourceUID) {
|
||||
return { error: new Error(`Unable to find data source for ${rulesSourceIdentifier}`) };
|
||||
}
|
||||
|
||||
if (isGrafanaRulesSource(dataSourceUID)) {
|
||||
if (dataSourceUID === GrafanaRulesSourceSymbol) {
|
||||
return {
|
||||
data: {
|
||||
name: GRAFANA_RULES_SOURCE_NAME,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
interface PromRulesResponse {
|
||||
status: string;
|
||||
data: {
|
||||
groups: PromRuleGroupDTO[];
|
||||
groupNextToken?: string;
|
||||
};
|
||||
errorType?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PromRulesOptions {
|
||||
ruleSource: { uid: string };
|
||||
namespace?: string;
|
||||
groupName?: string;
|
||||
ruleName?: string;
|
||||
groupLimit?: number;
|
||||
excludeAlerts?: boolean;
|
||||
groupNextToken?: string;
|
||||
}
|
||||
|
||||
export const prometheusApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
groups: build.query<PromRulesResponse, PromRulesOptions>({
|
||||
query: ({ ruleSource, namespace, groupName, ruleName, groupLimit, excludeAlerts, groupNextToken }) => ({
|
||||
url: `api/prometheus/${ruleSource.uid}/api/v1/rules`,
|
||||
params: {
|
||||
'file[]': namespace,
|
||||
'group[]': groupName,
|
||||
'rule[]': ruleName,
|
||||
exclude_alerts: excludeAlerts?.toString(),
|
||||
group_limit: groupLimit?.toFixed(0),
|
||||
group_next_token: groupNextToken,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Menu } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from 'app/features/alerting/unified/utils/rules';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { usePauseRuleInGroup } from '../hooks/ruleGroup/usePauseAlertRule';
|
||||
@@ -10,7 +10,7 @@ import { stringifyErrorLike } from '../utils/misc';
|
||||
|
||||
interface Props {
|
||||
rule: RulerRuleDTO;
|
||||
groupIdentifier: RuleGroupIdentifier;
|
||||
groupIdentifier: GrafanaRuleGroupIdentifier;
|
||||
/**
|
||||
* Method invoked after the request to change the paused state has completed
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions';
|
||||
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
|
||||
@@ -18,9 +18,9 @@ interface Props {
|
||||
promRule: Rule;
|
||||
rulerRule?: RulerRuleDTO;
|
||||
identifier: RuleIdentifier;
|
||||
groupIdentifier: RuleGroupIdentifier;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
handleSilence: () => void;
|
||||
handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void;
|
||||
handleDelete: (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void;
|
||||
handleDuplicateRule: (identifier: RuleIdentifier) => void;
|
||||
onPauseChange?: () => void;
|
||||
buttonSize?: ComponentSize;
|
||||
@@ -86,7 +86,7 @@ const AlertRuleMenu = ({
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
{canPause && rulerRule && (
|
||||
{canPause && rulerRule && groupIdentifier.groupOrigin === 'grafana' && (
|
||||
<MenuItemPauseRule rule={rulerRule} groupIdentifier={groupIdentifier} onPauseChange={onPauseChange} />
|
||||
)}
|
||||
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { RuleGroupIdentifier, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { shouldUsePrometheusRulesPrimary } from '../../featureToggles';
|
||||
@@ -13,8 +13,8 @@ import { fetchPromAndRulerRulesAction, fetchRulerRulesAction } from '../../state
|
||||
import { fromRulerRuleAndRuleGroupIdentifier } from '../../utils/rule-id';
|
||||
import { isCloudRuleIdentifier } from '../../utils/rules';
|
||||
|
||||
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => void, () => void];
|
||||
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifier } | undefined;
|
||||
type DeleteModalHook = [JSX.Element, (rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => void, () => void];
|
||||
type DeleteRuleInfo = { rule: RulerRuleDTO; groupIdentifier: RuleGroupIdentifierV2 } | undefined;
|
||||
|
||||
const prometheusRulesPrimary = shouldUsePrometheusRulesPrimary();
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
|
||||
setRuleToDelete(undefined);
|
||||
}, []);
|
||||
|
||||
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifier) => {
|
||||
const showModal = useCallback((rule: RulerRuleDTO, groupIdentifier: RuleGroupIdentifierV2) => {
|
||||
setRuleToDelete({ rule, groupIdentifier });
|
||||
}, []);
|
||||
|
||||
@@ -38,18 +38,24 @@ export const useDeleteModal = (redirectToListView = false): DeleteModalHook => {
|
||||
|
||||
const { rule, groupIdentifier } = ruleToDelete;
|
||||
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifier, rule);
|
||||
await deleteRuleFromGroup.execute(groupIdentifier, ruleIdentifier);
|
||||
const groupIdentifierV1: RuleGroupIdentifier = {
|
||||
dataSourceName: groupIdentifier.rulesSource.name,
|
||||
namespaceName:
|
||||
'uid' in groupIdentifier.namespace ? groupIdentifier.namespace.uid : groupIdentifier.namespace.name,
|
||||
groupName: groupIdentifier.groupName,
|
||||
};
|
||||
const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(groupIdentifierV1, rule);
|
||||
await deleteRuleFromGroup.execute(groupIdentifierV1, ruleIdentifier);
|
||||
|
||||
// refetch rules for this rules source
|
||||
// @TODO remove this when we moved everything to RTKQ – then the endpoint will simply invalidate the tags
|
||||
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
|
||||
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name }));
|
||||
|
||||
if (prometheusRulesPrimary && isCloudRuleIdentifier(ruleIdentifier)) {
|
||||
await waitForRemoval(ruleIdentifier);
|
||||
} else {
|
||||
// Without this the delete popup will close and the user will still see the deleted rule
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.dataSourceName }));
|
||||
await dispatch(fetchRulerRulesAction({ rulesSourceName: groupIdentifier.rulesSource.name }));
|
||||
}
|
||||
|
||||
dismissModal();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getGrafanaRule,
|
||||
getVanillaPromRule,
|
||||
grantUserPermissions,
|
||||
mockCombinedCloudRuleNamespace,
|
||||
mockDataSource,
|
||||
mockPluginLinkExtension,
|
||||
mockPromAlertingRule,
|
||||
@@ -62,7 +63,7 @@ const ELEMENTS = {
|
||||
};
|
||||
|
||||
setupMswServer();
|
||||
setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'mimir-1' }));
|
||||
|
||||
setPluginLinksHook(() => ({
|
||||
links: [
|
||||
mockPluginLinkExtension({ pluginId: 'grafana-slo-app', title: 'SLO dashboard', path: '/a/grafana-slo-app' }),
|
||||
@@ -102,6 +103,19 @@ beforeAll(() => {
|
||||
]);
|
||||
});
|
||||
|
||||
const dataSources = {
|
||||
am: mockDataSource<AlertManagerDataSourceJsonData>(
|
||||
{
|
||||
name: 'Alertmanager',
|
||||
type: DataSourceType.Alertmanager,
|
||||
jsonData: { handleGrafanaManagedAlerts: true },
|
||||
},
|
||||
{ module: 'core:plugin/alertmanager' }
|
||||
),
|
||||
mimir: mockDataSource({ uid: 'mimir', name: 'Mimir' }, { module: 'core:plugin/prometheus' }),
|
||||
prometheus: mockDataSource({ uid: 'prometheus', name: 'Prometheus' }, { module: 'core:plugin/prometheus' }),
|
||||
};
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
describe('Grafana managed alert rule', () => {
|
||||
const mockRule = getGrafanaRule(
|
||||
@@ -140,17 +154,6 @@ describe('RuleViewer', () => {
|
||||
AccessControlAction.AlertingInstancesExternalRead,
|
||||
AccessControlAction.AlertingInstancesExternalWrite,
|
||||
]);
|
||||
|
||||
const dataSources = {
|
||||
am: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||
name: 'Alertmanager',
|
||||
type: DataSourceType.Alertmanager,
|
||||
jsonData: {
|
||||
handleGrafanaManagedAlerts: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
setupDataSources(dataSources.am);
|
||||
});
|
||||
|
||||
it('should render a Grafana managed alert rule', async () => {
|
||||
@@ -199,12 +202,17 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
describe('Data source managed alert rule', () => {
|
||||
const mockRule = getCloudRule({
|
||||
name: 'cloud test alert',
|
||||
annotations: { [Annotation.summary]: 'cloud summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
|
||||
group: { name: 'Cloud group', interval: '15m', rules: [], totals: { alerting: 1 } },
|
||||
});
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule);
|
||||
const { mimir } = dataSources;
|
||||
|
||||
const mockRule = getCloudRule(
|
||||
{
|
||||
name: 'cloud test alert',
|
||||
annotations: { [Annotation.summary]: 'cloud summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
|
||||
group: { name: 'Cloud group', interval: '15m', rules: [], totals: { alerting: 1 } },
|
||||
},
|
||||
{ rulesSource: mimir }
|
||||
);
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule(mimir.name, mockRule);
|
||||
|
||||
beforeAll(() => {
|
||||
grantUserPermissions([
|
||||
@@ -213,6 +221,10 @@ describe('RuleViewer', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
});
|
||||
|
||||
it('should render a data source managed alert rule', () => {
|
||||
renderRuleViewer(mockRule, mockRuleIdentifier);
|
||||
|
||||
@@ -226,11 +238,11 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
it('should render custom plugin actions for a plugin-provided rule', async () => {
|
||||
const sloRule = getCloudRule({
|
||||
name: 'slo test alert',
|
||||
labels: { __grafana_origin: 'plugin/grafana-slo-app' },
|
||||
});
|
||||
const sloRuleIdentifier = ruleId.fromCombinedRule('mimir-1', sloRule);
|
||||
const sloRule = getCloudRule(
|
||||
{ name: 'slo test alert', labels: { __grafana_origin: 'plugin/grafana-slo-app' } },
|
||||
{ rulesSource: mimir }
|
||||
);
|
||||
const sloRuleIdentifier = ruleId.fromCombinedRule(mimir.name, sloRule);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -247,11 +259,11 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
it('should render different custom plugin actions for a different plugin-provided rule', async () => {
|
||||
const assertsRule = getCloudRule({
|
||||
name: 'asserts test alert',
|
||||
labels: { __grafana_origin: 'plugin/grafana-asserts-app' },
|
||||
});
|
||||
const assertsRuleIdentifier = ruleId.fromCombinedRule('mimir-1', assertsRule);
|
||||
const assertsRule = getCloudRule(
|
||||
{ name: 'asserts test alert', labels: { __grafana_origin: 'plugin/grafana-asserts-app' } },
|
||||
{ rulesSource: mimir }
|
||||
);
|
||||
const assertsRuleIdentifier = ruleId.fromCombinedRule(mimir.name, assertsRule);
|
||||
|
||||
renderRuleViewer(assertsRule, assertsRuleIdentifier);
|
||||
|
||||
@@ -267,8 +279,11 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
|
||||
describe('Vanilla Prometheus rule', () => {
|
||||
const { prometheus } = dataSources;
|
||||
|
||||
const mockRule = getVanillaPromRule({
|
||||
name: 'prom test alert',
|
||||
namespace: mockCombinedCloudRuleNamespace({ name: 'prometheus' }, prometheus.name),
|
||||
annotations: { [Annotation.summary]: 'prom summary', [Annotation.runbookURL]: 'https://runbook.example.com' },
|
||||
promRule: {
|
||||
...mockPromAlertingRule(),
|
||||
@@ -276,7 +291,7 @@ describe('RuleViewer', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule('prometheus', mockRule);
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule(prometheus.name, mockRule);
|
||||
|
||||
it('should render pending period for vanilla Prometheus alert rule', async () => {
|
||||
renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Details);
|
||||
|
||||
@@ -15,12 +15,10 @@ import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-
|
||||
import {
|
||||
LogMessages,
|
||||
logInfo,
|
||||
trackRulesListViewChange,
|
||||
trackRulesSearchComponentInteraction,
|
||||
trackRulesSearchInputInteraction,
|
||||
} from '../../../Analytics';
|
||||
import { useRulesFilter } from '../../../hooks/useFilteredRules';
|
||||
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
||||
import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions';
|
||||
import { RuleHealth } from '../../../search/rulesSearchParser';
|
||||
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
|
||||
@@ -29,33 +27,11 @@ import { alertStateToReadable } from '../../../utils/rules';
|
||||
import { PopupCard } from '../../HoverCard';
|
||||
import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker';
|
||||
|
||||
const ViewOptions: SelectableValue[] = [
|
||||
{
|
||||
icon: 'folder',
|
||||
label: 'Grouped',
|
||||
value: 'grouped',
|
||||
},
|
||||
{
|
||||
icon: 'list-ul',
|
||||
label: 'List',
|
||||
value: 'list',
|
||||
},
|
||||
{
|
||||
icon: 'heart-rate',
|
||||
label: 'State',
|
||||
value: 'state',
|
||||
},
|
||||
];
|
||||
import { RulesViewModeSelector } from './RulesViewModeSelector';
|
||||
|
||||
const RuleTypeOptions: SelectableValue[] = [
|
||||
{
|
||||
label: 'Alert ',
|
||||
value: PromRuleType.Alerting,
|
||||
},
|
||||
{
|
||||
label: 'Recording ',
|
||||
value: PromRuleType.Recording,
|
||||
},
|
||||
{ label: 'Alert ', value: PromRuleType.Alerting },
|
||||
{ label: 'Recording ', value: PromRuleType.Recording },
|
||||
];
|
||||
|
||||
const RuleHealthOptions: SelectableValue[] = [
|
||||
@@ -75,7 +51,6 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]
|
||||
|
||||
const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [queryParams, updateQueryParams] = useURLSearchParams();
|
||||
const { pluginsFilterEnabled } = usePluginsFilterStatus();
|
||||
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
|
||||
|
||||
@@ -142,11 +117,6 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
|
||||
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
||||
};
|
||||
|
||||
const handleViewChange = (view: string) => {
|
||||
updateQueryParams({ view });
|
||||
trackRulesListViewChange({ view });
|
||||
};
|
||||
|
||||
const handleContactPointChange = (contactPoint: string) => {
|
||||
updateFilters({ ...filterState, contactPoint });
|
||||
trackRulesSearchComponentInteraction('contactPoint');
|
||||
@@ -318,11 +288,7 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => {
|
||||
</form>
|
||||
<div>
|
||||
<Label>View as</Label>
|
||||
<RadioButtonGroup
|
||||
options={ViewOptions}
|
||||
value={queryParams.get('view') ?? ViewOptions[0].value}
|
||||
onChange={handleViewChange}
|
||||
/>
|
||||
<RulesViewModeSelector />
|
||||
</div>
|
||||
</Stack>
|
||||
{hasActiveFilters && (
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RadioButtonGroup } from '@grafana/ui';
|
||||
|
||||
import { trackRulesListViewChange } from '../../../Analytics';
|
||||
import { useRulesFilter } from '../../../hooks/useFilteredRules';
|
||||
import { useURLSearchParams } from '../../../hooks/useURLSearchParams';
|
||||
|
||||
export type SupportedView = 'list' | 'grouped';
|
||||
|
||||
type LegacySupportedView = 'list' | 'grouped' | 'state';
|
||||
|
||||
const ViewOptions: Array<SelectableValue<SupportedView>> = [
|
||||
{ icon: 'folder', label: 'Grouped', value: 'grouped' },
|
||||
{ icon: 'list-ul', label: 'List', value: 'list' },
|
||||
];
|
||||
|
||||
function RulesViewModeSelectorV2() {
|
||||
const [queryParams, updateQueryParams] = useURLSearchParams();
|
||||
const { hasActiveFilters } = useRulesFilter();
|
||||
const wantsListView = queryParams.get('view') === 'list';
|
||||
|
||||
const selectedViewOption = hasActiveFilters || wantsListView ? '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 });
|
||||
trackRulesListViewChange({ view });
|
||||
} else {
|
||||
updateQueryParams({ view: undefined, search: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
return <RadioButtonGroup options={ViewOptions} value={selectedViewOption} onChange={handleViewChange} />;
|
||||
}
|
||||
|
||||
const LegacyViewOptions: Array<SelectableValue<LegacySupportedView>> = [
|
||||
{ label: 'Grouped', value: 'grouped' },
|
||||
{ label: 'List', value: 'list' },
|
||||
{ label: 'State', value: 'state' },
|
||||
];
|
||||
|
||||
function RulesViewModeSelectorV1() {
|
||||
const [queryParams, updateQueryParams] = useURLSearchParams();
|
||||
const viewParam = queryParams.get('view');
|
||||
|
||||
const currentView = viewParamToLegacyView(viewParam);
|
||||
|
||||
const handleViewChange = (view: LegacySupportedView) => {
|
||||
updateQueryParams({ view });
|
||||
};
|
||||
|
||||
return <RadioButtonGroup options={LegacyViewOptions} value={currentView} onChange={handleViewChange} />;
|
||||
}
|
||||
|
||||
function viewParamToLegacyView(viewParam: string | null): LegacySupportedView {
|
||||
if (viewParam === 'list') {
|
||||
return 'list';
|
||||
}
|
||||
|
||||
if (viewParam === 'state') {
|
||||
return 'state';
|
||||
}
|
||||
|
||||
return 'grouped';
|
||||
}
|
||||
|
||||
export const RulesViewModeSelector = config.featureToggles.alertingListViewV2
|
||||
? RulesViewModeSelectorV2
|
||||
: RulesViewModeSelectorV1;
|
||||
@@ -67,7 +67,8 @@ setPluginLinksHook(() => ({
|
||||
}));
|
||||
|
||||
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
|
||||
setupDataSources(mimirDs);
|
||||
const prometheusDs = mockDataSource({ uid: 'prometheus', name: 'Prometheus' });
|
||||
setupDataSources(mimirDs, prometheusDs);
|
||||
|
||||
const clickCopyLink = async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -122,7 +123,7 @@ describe('RuleActionsButtons', () => {
|
||||
document.addEventListener('click', interceptLinkClicks);
|
||||
|
||||
grantAllPermissions();
|
||||
const mockRule = getCloudRule({ name: 'special !@#$%^&*() chars' });
|
||||
const mockRule = getCloudRule({ name: 'special !@#$%^&*() chars' }, { rulesSource: mimirDs });
|
||||
const { user } = render(<RuleActionsButtons rule={mockRule} rulesSource={mimirDs} showViewButton />, {
|
||||
renderWithRouter: true,
|
||||
});
|
||||
@@ -185,16 +186,14 @@ describe('RuleActionsButtons', () => {
|
||||
});
|
||||
|
||||
it('copies correct URL for cloud rule', async () => {
|
||||
const promDataSource = mockDataSource({ name: 'Prometheus-2' });
|
||||
const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' }, { rulesSource: prometheusDs });
|
||||
|
||||
const mockRule = getCloudRule({ name: 'pod-1-cpu-firing' });
|
||||
|
||||
render(<RuleActionsButtons rule={mockRule} rulesSource={promDataSource} />);
|
||||
render(<RuleActionsButtons rule={mockRule} rulesSource={prometheusDs} />);
|
||||
|
||||
await clickCopyLink();
|
||||
|
||||
expect(await navigator.clipboard.readText()).toBe(
|
||||
'http://localhost:3000/sub/alerting/Prometheus-2/pod-1-cpu-firing/find'
|
||||
'http://localhost:3000/sub/alerting/Prometheus/pod-1-cpu-firing/find'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,10 @@ import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-ale
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
|
||||
import { fetchPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
|
||||
import { groupIdentifier } from '../../utils/groupIdentifier';
|
||||
import { createViewLink } from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { getRuleGroupLocationFromCombinedRule, isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { isGrafanaAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { createRelativeUrl } from '../../utils/url';
|
||||
|
||||
import { RedirectToCloneRule } from './CloneRule';
|
||||
@@ -64,7 +65,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
|
||||
const sourceName = getRulesSourceName(rulesSource);
|
||||
|
||||
const identifier = ruleId.fromCombinedRule(sourceName, rule);
|
||||
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
|
||||
const groupId = groupIdentifier.fromCombinedRule(rule);
|
||||
|
||||
if (showViewButton) {
|
||||
buttons.push(
|
||||
@@ -104,10 +105,10 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
|
||||
rulerRule={rule.rulerRule}
|
||||
promRule={rule.promRule}
|
||||
identifier={identifier}
|
||||
groupIdentifier={groupIdentifier}
|
||||
groupIdentifier={groupId}
|
||||
handleDelete={() => {
|
||||
if (rule.rulerRule) {
|
||||
showDeleteModal(rule.rulerRule, groupIdentifier);
|
||||
showDeleteModal(rule.rulerRule, groupId);
|
||||
}
|
||||
}}
|
||||
handleSilence={() => setShowSilenceDrawer(true)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
|
||||
import { AlertRuleAction, useAlertRuleAbility, useRulerRuleAbility } from '../../hooks/useAbilities';
|
||||
import { getCloudRule, getGrafanaRule } from '../../mocks';
|
||||
import { mimirDataSource } from '../../mocks/server/configure';
|
||||
|
||||
import { RulesTable } from './RulesTable';
|
||||
|
||||
@@ -38,6 +39,8 @@ const ui = {
|
||||
const user = userEvent.setup();
|
||||
setupMswServer();
|
||||
|
||||
const { dataSource: mimirDs } = mimirDataSource();
|
||||
|
||||
describe('RulesTable RBAC', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -152,7 +155,7 @@ describe('RulesTable RBAC', () => {
|
||||
});
|
||||
|
||||
describe('Cloud rules action buttons', () => {
|
||||
const cloudRule = getCloudRule({ name: 'Cloud' });
|
||||
const cloudRule = getCloudRule({ name: 'Cloud' }, { rulesSource: mimirDs });
|
||||
|
||||
it('Should not render Edit button for users without the update permission', async () => {
|
||||
mocks.useRulerRuleAbility.mockImplementation((_rule, _groupIdentifier, action) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { grantUserPermissions, mockCombinedRule, mockCombinedRuleGroup, mockGraf
|
||||
import { grafanaRulerGroupName, grafanaRulerNamespace, grafanaRulerRule } from '../../mocks/grafanaRulerApi';
|
||||
import { setUpdateRulerRuleNamespaceHandler } from '../../mocks/server/configure';
|
||||
import { captureRequests, serializeRequests } from '../../mocks/server/events';
|
||||
import { getRuleGroupLocationFromCombinedRule } from '../../utils/rules';
|
||||
import { groupIdentifier } from '../../utils/groupIdentifier';
|
||||
import { SerializeState } from '../useAsync';
|
||||
|
||||
import { usePauseRuleInGroup } from './usePauseAlertRule';
|
||||
@@ -83,9 +83,13 @@ const PauseTestComponent = (options: { rulerRule?: RulerGrafanaRuleDTO }) => {
|
||||
rulerRule,
|
||||
group: mockCombinedRuleGroup(grafanaRulerGroupName, []),
|
||||
});
|
||||
const ruleGroupID = getRuleGroupLocationFromCombinedRule(rule);
|
||||
const ruleGroupID = groupIdentifier.fromCombinedRule(rule);
|
||||
|
||||
const onClick = () => {
|
||||
if (ruleGroupID.groupOrigin !== 'grafana') {
|
||||
throw new Error('not a Grafana rule');
|
||||
}
|
||||
|
||||
// always handle your errors!
|
||||
pauseRule.execute(ruleGroupID, rulerRule.grafana_alert.uid, true).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { GrafanaRuleGroupIdentifier, RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { pauseRuleAction } from '../../reducers/ruler/ruleGroups';
|
||||
@@ -18,15 +18,18 @@ export function usePauseRuleInGroup() {
|
||||
const rulePausedMessage = t('alerting.rules.pause-rule.success', 'Rule evaluation paused');
|
||||
const ruleResumedMessage = t('alerting.rules.resume-rule.success', 'Rule evaluation resumed');
|
||||
|
||||
return useAsync(async (ruleGroup: RuleGroupIdentifier, uid: string, pause: boolean) => {
|
||||
const { namespaceName } = ruleGroup;
|
||||
|
||||
return useAsync(async (ruleGroup: GrafanaRuleGroupIdentifier, uid: string, pause: boolean) => {
|
||||
const groupIdentifierV1: RuleGroupIdentifier = {
|
||||
dataSourceName: ruleGroup.rulesSource.name,
|
||||
namespaceName: ruleGroup.namespace.uid,
|
||||
groupName: ruleGroup.groupName,
|
||||
};
|
||||
const action = pauseRuleAction({ uid, pause });
|
||||
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(ruleGroup, action);
|
||||
const { newRuleGroupDefinition, rulerConfig } = await produceNewRuleGroup(groupIdentifierV1, action);
|
||||
|
||||
return upsertRuleGroup({
|
||||
rulerConfig,
|
||||
namespace: namespaceName,
|
||||
namespace: ruleGroup.namespace.uid,
|
||||
payload: newRuleGroupDefinition,
|
||||
notificationOptions: {
|
||||
successMessage: pause ? rulePausedMessage : ruleResumedMessage,
|
||||
|
||||
@@ -197,7 +197,10 @@ describe('AlertRule abilities', () => {
|
||||
});
|
||||
|
||||
it('should report no permissions while we are loading data for cloud rule', async () => {
|
||||
const rule = getCloudRule();
|
||||
const mimirDs = mockDataSource({ uid: 'mimir', name: 'Mimir' });
|
||||
setupDataSources(mimirDs);
|
||||
|
||||
const rule = getCloudRule({}, { rulesSource: mimirDs });
|
||||
|
||||
const { result } = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: wrapper() });
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useFolder } from 'app/features/alerting/unified/hooks/useFolder';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { CombinedRule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
@@ -165,7 +165,7 @@ export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleActi
|
||||
|
||||
export function useRulerRuleAbility(
|
||||
rule: RulerRuleDTO | undefined,
|
||||
groupIdentifier: RuleGroupIdentifier,
|
||||
groupIdentifier: RuleGroupIdentifierV2,
|
||||
action: AlertRuleAction
|
||||
): Ability {
|
||||
const abilities = useAllRulerRuleAbilities(rule, groupIdentifier);
|
||||
@@ -177,7 +177,7 @@ export function useRulerRuleAbility(
|
||||
|
||||
export function useRulerRuleAbilities(
|
||||
rule: RulerRuleDTO,
|
||||
groupIdentifier: RuleGroupIdentifier,
|
||||
groupIdentifier: RuleGroupIdentifierV2,
|
||||
actions: AlertRuleAction[]
|
||||
): Ability[] {
|
||||
const abilities = useAllRulerRuleAbilities(rule, groupIdentifier);
|
||||
@@ -240,9 +240,9 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
|
||||
export function useAllRulerRuleAbilities(
|
||||
rule: RulerRuleDTO | undefined,
|
||||
groupIdentifier: RuleGroupIdentifier
|
||||
groupIdentifier: RuleGroupIdentifierV2
|
||||
): Abilities<AlertRuleAction> {
|
||||
const rulesSourceName = groupIdentifier.dataSourceName;
|
||||
const rulesSourceName = groupIdentifier.rulesSource.name;
|
||||
|
||||
const { isEditable, isRemovable, isRulerAvailable = false, loading } = useIsRuleEditable(rulesSourceName, rule);
|
||||
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
|
||||
|
||||
@@ -192,7 +192,6 @@ const reduceNamespaces = (filterState: RulesFilter) => {
|
||||
const ufuzzy = getSearchInstance(groupNameFilter);
|
||||
|
||||
const escapedQuery = escapeQueryRegex(groupNameFilter);
|
||||
|
||||
const [idxs, info, order] = ufuzzy.search(
|
||||
groupsHaystack,
|
||||
escapedQuery,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { getRulesPermissions } from '../utils/access-control';
|
||||
import { getDatasourceAPIUid } from '../utils/datasource';
|
||||
import { isGrafanaRulerRule } from '../utils/rules';
|
||||
|
||||
import { useFolder } from './useFolder';
|
||||
@@ -16,7 +17,7 @@ interface ResultBag {
|
||||
|
||||
export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO): ResultBag {
|
||||
const { currentData: dsFeatures, isLoading } = featureDiscoveryApi.endpoints.discoverDsFeatures.useQuery({
|
||||
rulesSourceName,
|
||||
uid: getDatasourceAPIUid(rulesSourceName),
|
||||
});
|
||||
|
||||
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import server, { mockFeatureDiscoveryApi } from 'app/features/alerting/unified/mockApi';
|
||||
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
getDisabledPluginHandler,
|
||||
getPluginMissingHandler,
|
||||
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
|
||||
import { ALERTING_API_SERVER_BASE_URL } from 'app/features/alerting/unified/mocks/server/utils';
|
||||
import { ALERTING_API_SERVER_BASE_URL, paginatedHandlerFor } from 'app/features/alerting/unified/mocks/server/utils';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
import { clearPluginSettingsCache } from 'app/features/plugins/pluginSettings';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { FolderDTO } from 'app/types';
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { buildInfoResponse } from '../../testSetup/featureDiscovery';
|
||||
@@ -114,6 +116,10 @@ export function mimirDataSource() {
|
||||
return { dataSource };
|
||||
}
|
||||
|
||||
export function setPrometheusRules(ds: DataSourceInstanceSettings, groups: PromRuleGroupDTO[]) {
|
||||
server.use(http.get(`/api/prometheus/${ds.uid}/api/v1/rules`, paginatedHandlerFor(groups)));
|
||||
}
|
||||
|
||||
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
|
||||
export const removePlugin = (pluginId: string) => {
|
||||
delete config.apps[pluginId];
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Factory } from 'fishery';
|
||||
|
||||
import { DataSourceInstanceSettings, PluginType } from '@grafana/data';
|
||||
import { config, setDataSourceSrv } from '@grafana/runtime';
|
||||
import {
|
||||
PromAlertingRuleDTO,
|
||||
PromAlertingRuleState,
|
||||
PromRuleGroupDTO,
|
||||
PromRuleType,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { MockDataSourceSrv } from '../../mocks';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
|
||||
const ruleFactory = Factory.define<PromAlertingRuleDTO>(({ sequence }) => ({
|
||||
name: `test-rule-${sequence}`,
|
||||
query: 'test-query',
|
||||
state: PromAlertingRuleState.Inactive,
|
||||
type: PromRuleType.Alerting,
|
||||
health: 'ok',
|
||||
labels: { team: 'infra' },
|
||||
}));
|
||||
|
||||
const groupFactory = Factory.define<PromRuleGroupDTO>(({ sequence }) => {
|
||||
const group = {
|
||||
name: `test-group-${sequence}`,
|
||||
file: `test-namespace`,
|
||||
interval: 10,
|
||||
rules: ruleFactory.buildList(10),
|
||||
};
|
||||
|
||||
ruleFactory.rewindSequence();
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
const dataSourceFactory = Factory.define<DataSourceInstanceSettings>(({ sequence, params, afterBuild }) => {
|
||||
afterBuild((dataSource) => {
|
||||
config.datasources[dataSource.name] = dataSource;
|
||||
setDataSourceSrv(new MockDataSourceSrv(config.datasources));
|
||||
});
|
||||
|
||||
const uid = params.uid ?? `mock-ds-${sequence}`;
|
||||
return {
|
||||
id: params.id ?? sequence,
|
||||
uid,
|
||||
type: DataSourceType.Prometheus,
|
||||
name: `Prometheus-${uid}`,
|
||||
access: 'proxy',
|
||||
url: `/api/datasources/proxy/uid/${uid}`,
|
||||
jsonData: {},
|
||||
meta: {
|
||||
info: {
|
||||
author: { name: 'Grafana Labs' },
|
||||
description: 'Open source time series database & alerting',
|
||||
updated: '',
|
||||
version: '',
|
||||
logos: {
|
||||
small: 'https://prometheus.io/assets/prometheus_logo_grey.svg',
|
||||
large: 'https://prometheus.io/assets/prometheus_logo_grey.svg',
|
||||
},
|
||||
links: [],
|
||||
screenshots: [],
|
||||
},
|
||||
name: 'Prometheus',
|
||||
type: PluginType.datasource,
|
||||
id: 'prometheus',
|
||||
baseUrl: '"public/app/plugins/datasource/prometheus"',
|
||||
module: 'core:plugin/prometheus',
|
||||
},
|
||||
readOnly: false,
|
||||
};
|
||||
});
|
||||
|
||||
export const alertingFactory = {
|
||||
group: groupFactory,
|
||||
rule: ruleFactory,
|
||||
dataSource: dataSourceFactory,
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
import { DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams } from 'msw';
|
||||
|
||||
import { PromRuleGroupDTO, PromRulesResponse } from 'app/types/unified-alerting-dto';
|
||||
|
||||
/** Helper method to help generate a kubernetes-style response with a list of items */
|
||||
export const getK8sResponse = <T>(kind: string, items: T[]) => {
|
||||
return {
|
||||
@@ -10,3 +14,32 @@ export const getK8sResponse = <T>(kind: string, items: T[]) => {
|
||||
|
||||
/** Expected base URL for our k8s APIs */
|
||||
export const ALERTING_API_SERVER_BASE_URL = '/apis/notifications.alerting.grafana.app/v0alpha1';
|
||||
|
||||
export function paginatedHandlerFor(
|
||||
groups: PromRuleGroupDTO[]
|
||||
): HttpResponseResolver<PathParams, DefaultBodyType, PromRulesResponse> {
|
||||
const orderedGroupsWithCursor = groups.map((group) => ({
|
||||
...group,
|
||||
id: Buffer.from(`${group.file}-${group.name}`).toString('base64url'),
|
||||
}));
|
||||
|
||||
return ({ request }) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const groupLimitParam = searchParams.get('group_limit');
|
||||
const groupNextToken = searchParams.get('group_next_token');
|
||||
|
||||
const groupLimit = groupLimitParam ? parseInt(groupLimitParam, 10) : undefined;
|
||||
|
||||
const startIndex = groupNextToken ? orderedGroupsWithCursor.findIndex((group) => group.id === groupNextToken) : 0;
|
||||
const endIndex = groupLimit ? startIndex + groupLimit : orderedGroupsWithCursor.length;
|
||||
|
||||
const groupsResult = orderedGroupsWithCursor.slice(startIndex, endIndex);
|
||||
const nextToken =
|
||||
groupLimit && orderedGroupsWithCursor.length > groupLimit ? orderedGroupsWithCursor.at(endIndex)?.id : undefined;
|
||||
|
||||
return HttpResponse.json<PromRulesResponse>({
|
||||
status: 'success',
|
||||
data: { groups: groupsResult, groupNextToken: nextToken },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { PluginExtensionPoints } from '@grafana/data';
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { CombinedRule, Rule, RuleGroupIdentifier } from 'app/types/unified-alerting';
|
||||
import { CombinedRule, Rule, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
import { PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getRulePluginOrigin } from '../utils/rules';
|
||||
@@ -21,7 +21,7 @@ export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
|
||||
|
||||
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
|
||||
|
||||
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifier) {
|
||||
export function useRulePluginLinkExtension(rule: Rule, groupIdentifier: RuleGroupIdentifierV2) {
|
||||
const ruleExtensionPoint = useRuleExtensionPoint(rule, groupIdentifier);
|
||||
const { links } = usePluginLinks(ruleExtensionPoint);
|
||||
|
||||
@@ -57,9 +57,11 @@ interface EmptyExtensionPoint {
|
||||
|
||||
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
|
||||
|
||||
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier): RuleExtensionPoint {
|
||||
function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifierV2): RuleExtensionPoint {
|
||||
return useMemo<RuleExtensionPoint>(() => {
|
||||
const ruleType = rule.type;
|
||||
const { namespace, groupName } = groupIdentifier;
|
||||
const namespaceIdentifier = 'uid' in namespace ? namespace.uid : namespace.name;
|
||||
|
||||
switch (ruleType) {
|
||||
case PromRuleType.Alerting:
|
||||
@@ -67,8 +69,8 @@ function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier)
|
||||
extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction,
|
||||
context: {
|
||||
name: rule.name,
|
||||
namespace: groupIdentifier.namespaceName,
|
||||
group: groupIdentifier.groupName,
|
||||
namespace: namespaceIdentifier,
|
||||
group: groupName,
|
||||
expression: rule.query,
|
||||
labels: rule.labels ?? {},
|
||||
annotations: rule.annotations ?? {},
|
||||
@@ -79,8 +81,8 @@ function useRuleExtensionPoint(rule: Rule, groupIdentifier: RuleGroupIdentifier)
|
||||
extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction,
|
||||
context: {
|
||||
name: rule.name,
|
||||
namespace: groupIdentifier.namespaceName,
|
||||
group: groupIdentifier.groupName,
|
||||
namespace: namespaceIdentifier,
|
||||
group: groupName,
|
||||
expression: rule.query,
|
||||
labels: rule.labels ?? {},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { DataSourceRuleGroupIdentifier, Rule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { equal, fromRule, fromRulerRule, stringifyIdentifier } from '../utils/rule-id';
|
||||
import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const { useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
|
||||
interface AlertRuleLoaderProps {
|
||||
rule: Rule;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
}
|
||||
|
||||
export const AlertRuleLoader = memo(function AlertRuleLoader({ rule, groupIdentifier }: AlertRuleLoaderProps) {
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleIdentifier = fromRule(rulesSource.name, namespace.name, groupName, rule);
|
||||
const href = createViewLinkFromIdentifier(ruleIdentifier);
|
||||
const originMeta = getRulePluginOrigin(rule);
|
||||
|
||||
// @TODO work with context API to propagate rulerConfig and such
|
||||
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ uid: rulesSource.uid });
|
||||
|
||||
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
|
||||
const {
|
||||
isLoading,
|
||||
data: rulerRuleGroup,
|
||||
// error,
|
||||
} = useGetRuleGroupForNamespaceQuery(
|
||||
{
|
||||
namespace: namespace.name,
|
||||
group: groupName,
|
||||
rulerConfig: dataSourceInfo?.rulerConfig!,
|
||||
},
|
||||
{ skip: !dataSourceInfo?.rulerConfig }
|
||||
);
|
||||
|
||||
const rulerRule = useMemo(() => {
|
||||
if (!rulerRuleGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
return rulerRuleGroup.rules.find((rule) =>
|
||||
equal(fromRulerRule(rulesSource.name, namespace.name, groupName, rule), ruleIdentifier)
|
||||
);
|
||||
}, [rulesSource, namespace, groupName, ruleIdentifier, rulerRuleGroup]);
|
||||
|
||||
// 1. get the rule from the ruler API with "ruleWithLocation"
|
||||
// 1.1 skip this if this datasource does not have a ruler
|
||||
//
|
||||
// 2.1 render action buttons
|
||||
// 2.2 render provisioning badge and contact point metadata, etc.
|
||||
const actions = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return <ActionsLoader />;
|
||||
}
|
||||
|
||||
if (rulerRule) {
|
||||
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [groupIdentifier, isLoading, rule, rulerRule]);
|
||||
|
||||
if (isAlertingRule(rule)) {
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
name={rule.name}
|
||||
rulesSource={rulesSource}
|
||||
application={dataSourceInfo?.application}
|
||||
group={groupName}
|
||||
namespace={namespace.name}
|
||||
href={href}
|
||||
summary={rule.annotations?.summary}
|
||||
state={rule.state}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
instancesCount={rule.alerts?.length}
|
||||
actions={actions}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecordingRule(rule)) {
|
||||
return (
|
||||
<RecordingRuleListItem
|
||||
name={rule.name}
|
||||
rulesSource={rulesSource}
|
||||
application={dataSourceInfo?.application}
|
||||
group={groupName}
|
||||
namespace={namespace.name}
|
||||
href={href}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
actions={actions}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
});
|
||||
|
||||
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
|
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
|
||||
import { act, render, screen, waitForElementToBeRemoved } from 'test/test-utils';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { setPrometheusRules } from '../mocks/server/configure';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
|
||||
import { FilterView } from './FilterView';
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const mimirGroups = alertingFactory.group.buildList(5000, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.group.buildList(200, { file: 'test-prometheus-namespace' });
|
||||
|
||||
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
|
||||
beforeEach(() => {
|
||||
setPrometheusRules(mimirDs, mimirGroups);
|
||||
setPrometheusRules(prometheusDs, prometheusGroups);
|
||||
});
|
||||
|
||||
const io = mockIntersectionObserver();
|
||||
|
||||
describe('RuleList - FilterView', () => {
|
||||
it('should render multiple pages of results', async () => {
|
||||
render(<FilterView filterState={getFilter({ dataSourceNames: ['Mimir'] })} />);
|
||||
|
||||
await loadMoreResults();
|
||||
expect(await screen.findAllByRole('treeitem')).toHaveLength(100);
|
||||
|
||||
await loadMoreResults();
|
||||
expect(await screen.findAllByRole('treeitem')).toHaveLength(200);
|
||||
});
|
||||
|
||||
it('should filter results by group and rule name ', async () => {
|
||||
render(
|
||||
<FilterView
|
||||
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-8' })}
|
||||
/>
|
||||
);
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
const matchingRule = await screen.findByRole('treeitem', {
|
||||
name: /test-rule-8 test-mimir-namespace test-group-4501/,
|
||||
});
|
||||
expect(matchingRule).toBeInTheDocument();
|
||||
|
||||
expect(matchingRule).toHaveTextContent('test-rule-8');
|
||||
expect(matchingRule).toHaveTextContent('test-group-4501');
|
||||
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display rules from multiple datasources', async () => {
|
||||
render(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-5' })} />);
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
// Mimir has 11 matching rules, 181, 1810, 1811 ... 1819
|
||||
const matchingMimirRules = await screen.findAllByRole('treeitem', {
|
||||
name: /test-rule-5 Mimir test-mimir-namespace test-group-181/,
|
||||
});
|
||||
const matchingPrometheusRule = await screen.findByRole('treeitem', {
|
||||
name: /test-rule-5 Prometheus test-prometheus-namespace test-group-181/,
|
||||
});
|
||||
|
||||
expect(matchingMimirRules).toHaveLength(11);
|
||||
expect(matchingPrometheusRule).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/No more results/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display empty state when no rules are found', async () => {
|
||||
render(<FilterView filterState={getFilter({ groupName: 'non-existing-group' })} />);
|
||||
|
||||
await loadMoreResults();
|
||||
|
||||
expect(await screen.findByText(/No matching rules found/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
async function loadMoreResults() {
|
||||
act(() => {
|
||||
io.enterNode(screen.getByTestId('load-more-helper'));
|
||||
});
|
||||
await waitForElementToBeRemoved(screen.queryAllByTestId('alert-rule-list-item-loader'), { timeout: 8000 });
|
||||
}
|
||||
|
||||
function getFilter(overrides: Partial<RulesFilter> = {}): RulesFilter {
|
||||
return {
|
||||
dataSourceNames: [],
|
||||
freeFormWords: [],
|
||||
labels: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { take, tap, withAbort } from 'ix/asynciterable/operators';
|
||||
import { useEffect, useRef, useState, useTransition } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { Card, EmptyState, Stack, Text } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { isLoading, useAsync } from '../hooks/useAsync';
|
||||
import { RulesFilter } from '../search/rulesSearchParser';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
|
||||
import { AlertRuleLoader } from './AlertRuleLoader';
|
||||
import LoadMoreHelper from './LoadMoreHelper';
|
||||
import { ListItem } from './components/ListItem';
|
||||
import { ActionsLoader } from './components/RuleActionsButtons.V2';
|
||||
import { RuleListIcon } from './components/RuleListIcon';
|
||||
import { RuleWithOrigin, useFilteredRulesIteratorProvider } from './hooks/useFilteredRulesIterator';
|
||||
|
||||
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
|
||||
// so even for the same params we get a new object or new properties in it
|
||||
return <FilterViewResults filterState={filterState} key={JSON.stringify(filterState)} />;
|
||||
}
|
||||
|
||||
interface KeyedRuleWithOrigin extends RuleWithOrigin {
|
||||
/**
|
||||
* Artificial frontend-only identifier for the rule.
|
||||
* It's used as a key for the rule in the rule list to prevent key duplication
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of rules that match the filter.
|
||||
* It doesn't update results when the filter changes, use key property to force a remount with a new filter
|
||||
* Internally it needs to reset rules generator to get new results
|
||||
* While a bit counter-intuitive resetting using key simplifies a lot of logic in the component
|
||||
* The component implements infinite scrolling. It loads next page when the user scrolls to the bottom of the list
|
||||
*/
|
||||
function FilterViewResults({ filterState }: FilterViewProps) {
|
||||
const [transitionPending, startTransition] = useTransition();
|
||||
|
||||
/* this hook returns a function that creates an AsyncIterable<RuleWithOrigin> which we will use to populate the front-end */
|
||||
const { getFilteredRulesIterator } = useFilteredRulesIteratorProvider();
|
||||
|
||||
/* this is the abort controller that allows us to stop an AsyncIterable */
|
||||
const controller = useRef(new AbortController());
|
||||
|
||||
/**
|
||||
* This an iterator that we can use to populate the search results.
|
||||
* It also uses the signal from the AbortController above to cancel retrieving more results and sets up a
|
||||
* callback function to detect when we've exhausted the source.
|
||||
* This is the main AsyncIterable<RuleWithOrigin> we will use for the search results */
|
||||
const rulesIterator = useRef(
|
||||
getFilteredRulesIterator(filterState, API_PAGE_SIZE).pipe(
|
||||
withAbort(controller.current.signal),
|
||||
onFinished(() => setDoneSearching(true))
|
||||
)
|
||||
);
|
||||
|
||||
const [rules, setRules] = useState<KeyedRuleWithOrigin[]>([]);
|
||||
const [doneSearching, setDoneSearching] = useState(false);
|
||||
|
||||
/* This function will fetch a page of results from the iterable */
|
||||
const [{ execute: loadResultPage }, state] = useAsync(async () => {
|
||||
for await (const rule of rulesIterator.current.pipe(take(FRONTENT_PAGE_SIZE))) {
|
||||
startTransition(() => {
|
||||
// Rule key could be computed on the fly, but we do it here to avoid recalculating it with each render
|
||||
// It's a not trivial computation because it involves hashing the rule
|
||||
setRules((rules) => rules.concat({ key: getRuleKey(rule), ...rule }));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* When we unmount the component we make sure to abort all iterables */
|
||||
useEffect(() => {
|
||||
const currentAbortController = controller.current;
|
||||
|
||||
return () => {
|
||||
currentAbortController.abort();
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
const loading = isLoading(state) || transitionPending;
|
||||
const numberOfRules = rules.length;
|
||||
const noRulesFound = numberOfRules === 0 && !loading;
|
||||
|
||||
/* If we don't have any rules and have exhausted all sources, show a EmptyState */
|
||||
if (noRulesFound && doneSearching) {
|
||||
return (
|
||||
<EmptyState variant="not-found" message="No matching rules found">
|
||||
<Trans i18nKey="alerting.rule-list.filter-view.no-rules-found">
|
||||
No alert or recording rules matched your current set of filters.
|
||||
</Trans>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={0}>
|
||||
<ul aria-label="filtered-rule-list">
|
||||
{rules.map(({ key, rule, groupIdentifier }) => (
|
||||
<AlertRuleLoader key={key} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
{loading && (
|
||||
<>
|
||||
<AlertRuleListItemLoader />
|
||||
<AlertRuleListItemLoader />
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
{doneSearching && !noRulesFound && (
|
||||
<Card>
|
||||
<Text color="secondary">
|
||||
<Trans i18nKey="alerting.rule-list.filter-view.no-more-results">
|
||||
No more results – showing {{ numberOfRules }} rules
|
||||
</Trans>
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
{!doneSearching && <LoadMoreHelper handleLoad={loadResultPage} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const AlertRuleListItemLoader = () => (
|
||||
<ListItem
|
||||
title={<Skeleton width={64} />}
|
||||
icon={<RuleListIcon isPaused={false} />}
|
||||
description={<Skeleton width={256} />}
|
||||
actions={<ActionsLoader />}
|
||||
data-testid="alert-rule-list-item-loader"
|
||||
/>
|
||||
);
|
||||
|
||||
// simple helper function to detect the end of the source async iterable
|
||||
function onFinished<T>(fn: () => void) {
|
||||
return tap<T>(undefined, undefined, fn);
|
||||
}
|
||||
|
||||
function getRuleKey(ruleWithOrigin: RuleWithOrigin) {
|
||||
const {
|
||||
rule,
|
||||
groupIdentifier: { rulesSource, namespace, groupName },
|
||||
} = ruleWithOrigin;
|
||||
return `${rulesSource.name}-${namespace.name}-${groupName}-${rule.name}-${rule.type}-${hashRule(rule)}`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { render, screen, waitFor, within } from 'test/test-utils';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook, setReturnToPreviousHook } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { setPrometheusRules } from '../mocks/server/configure';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
|
||||
import { GroupedView } from './GroupedView';
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
setReturnToPreviousHook(() => () => {});
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
const mimirGroups = alertingFactory.group.buildList(500, { file: 'test-mimir-namespace' });
|
||||
alertingFactory.group.rewindSequence();
|
||||
const prometheusGroups = alertingFactory.group.buildList(130, { file: 'test-prometheus-namespace' });
|
||||
|
||||
const mimirDs = alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
const prometheusDs = alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
|
||||
beforeEach(() => {
|
||||
setPrometheusRules(mimirDs, mimirGroups);
|
||||
setPrometheusRules(prometheusDs, prometheusGroups);
|
||||
});
|
||||
|
||||
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/ }),
|
||||
};
|
||||
|
||||
describe('RuleList - GroupedView', () => {
|
||||
it('should render datasource sections', async () => {
|
||||
render(<GroupedView />);
|
||||
|
||||
const mimirSection = await screen.findByRole('listitem', { name: /Mimir/ });
|
||||
const prometheusSection = await screen.findByRole('listitem', { name: /Prometheus/ });
|
||||
|
||||
expect(mimirSection).toBeInTheDocument();
|
||||
expect(prometheusSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should paginate through groups', async () => {
|
||||
const { user } = render(<GroupedView />);
|
||||
|
||||
const mimirSection = await ui.dsSection(/Mimir/).find();
|
||||
|
||||
expect(mimirSection).toBeInTheDocument();
|
||||
|
||||
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(40);
|
||||
expect(firstPageGroups[0]).toHaveTextContent('test-group-1');
|
||||
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);
|
||||
|
||||
await waitFor(() => expect(nextButton).toBeEnabled());
|
||||
|
||||
const secondPageGroups = await ui.group(/test-group-(4[1-9]|[5-7][0-9]|80)/).findAll(mimirNamespace);
|
||||
|
||||
expect(secondPageGroups).toHaveLength(40);
|
||||
expect(secondPageGroups[0]).toHaveTextContent('test-group-41');
|
||||
expect(secondPageGroups[24]).toHaveTextContent('test-group-65');
|
||||
expect(secondPageGroups[39]).toHaveTextContent('test-group-80');
|
||||
});
|
||||
|
||||
it('should disable next button when there is no more data', async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Dropdown, Icon, IconButton, LinkButton, Menu, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { DataSourceNamespaceIdentifier, DataSourceRuleGroupIdentifier, RuleGroup } from 'app/types/unified-alerting';
|
||||
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { Spacer } from '../components/Spacer';
|
||||
import { WithReturnButton } from '../components/WithReturnButton';
|
||||
import { getDatasourceAPIUid, getExternalRulesSources } from '../utils/datasource';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
|
||||
import { AlertRuleLoader } from './AlertRuleLoader';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
import { ListSection } from './components/ListSection';
|
||||
import { DataSourceIcon } from './components/Namespace';
|
||||
import { LoadingIndicator } from './components/RuleGroup';
|
||||
import { usePaginatedPrometheusRuleNamespaces } from './hooks/usePaginatedPrometheusRuleNamespaces';
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
const GROUP_PAGE_SIZE = 40;
|
||||
|
||||
export function GroupedView() {
|
||||
const externalRuleSources = useMemo(() => getExternalRulesSources(), []);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1} role="list">
|
||||
<GrafanaDataSourceLoader />
|
||||
{externalRuleSources.map((ruleSource) => {
|
||||
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceLoaderProps {
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export function GrafanaDataSourceLoader() {
|
||||
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true}></DataSourceSection>;
|
||||
}
|
||||
|
||||
export function DataSourceLoader({ uid, name }: DataSourceLoaderProps) {
|
||||
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
|
||||
|
||||
if (isLoading) {
|
||||
return <DataSourceSection loader={<Skeleton width={250} height={16} />} uid={uid} name={name} />;
|
||||
}
|
||||
|
||||
// 2. grab prometheus rule groups with max_groups if supported
|
||||
if (dataSourceInfo) {
|
||||
return (
|
||||
<PaginatedDataSourceLoader
|
||||
ruleSourceName={dataSourceInfo.name}
|
||||
uid={uid}
|
||||
name={name}
|
||||
application={dataSourceInfo.application}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO Try to use a better rules source identifier
|
||||
interface PaginatedDataSourceLoaderProps
|
||||
extends Required<Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'>> {
|
||||
ruleSourceName: string;
|
||||
}
|
||||
|
||||
function PaginatedDataSourceLoader({ ruleSourceName, name, uid, application }: PaginatedDataSourceLoaderProps) {
|
||||
const {
|
||||
page: ruleNamespaces,
|
||||
nextPage,
|
||||
previousPage,
|
||||
canMoveForward,
|
||||
canMoveBackward,
|
||||
isLoading,
|
||||
} = usePaginatedPrometheusRuleNamespaces(ruleSourceName, GROUP_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{ruleNamespaces.map((namespace) => (
|
||||
<ListSection
|
||||
key={namespace.name}
|
||||
title={
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Icon name="folder" />{' '}
|
||||
<Text variant="body" element="h3">
|
||||
{namespace.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{namespace.groups.map((group) => (
|
||||
<RuleGroupListItem
|
||||
key={`${ruleSourceName}-${namespace.name}-${group.name}`}
|
||||
group={group}
|
||||
ruleSourceName={ruleSourceName}
|
||||
namespaceId={namespace}
|
||||
/>
|
||||
))}
|
||||
</ListSection>
|
||||
))}
|
||||
<LazyPagination
|
||||
nextPage={nextPage}
|
||||
previousPage={previousPage}
|
||||
canMoveForward={canMoveForward}
|
||||
canMoveBackward={canMoveBackward}
|
||||
/>
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleGroupListItemProps {
|
||||
group: RuleGroup;
|
||||
ruleSourceName: string;
|
||||
namespaceId: DataSourceNamespaceIdentifier;
|
||||
}
|
||||
|
||||
function RuleGroupListItem({ group, ruleSourceName, namespaceId }: RuleGroupListItemProps) {
|
||||
const rulesWithGroupId = useMemo(
|
||||
() =>
|
||||
group.rules.map((rule) => {
|
||||
const groupIdentifier: DataSourceRuleGroupIdentifier = {
|
||||
rulesSource: { uid: getDatasourceAPIUid(ruleSourceName), name: ruleSourceName },
|
||||
namespace: namespaceId,
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
return { rule, groupIdentifier };
|
||||
}),
|
||||
[group, namespaceId, ruleSourceName]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListGroup
|
||||
key={group.name}
|
||||
name={group.name}
|
||||
isOpen={false}
|
||||
actions={
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
|
||||
<Menu.Item label="Re-order rules" icon="flip" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Export" icon="download-alt" />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<IconButton name="ellipsis-h" aria-label="rule group actions" />
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rulesWithGroupId.map(({ rule, groupIdentifier }) => (
|
||||
<AlertRuleLoader key={hashRule(rule)} rule={rule} groupIdentifier={groupIdentifier} />
|
||||
))}
|
||||
</ListGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceSectionProps extends PropsWithChildren {
|
||||
uid: string;
|
||||
name: string;
|
||||
loader?: ReactNode;
|
||||
application?: RulesSourceApplication;
|
||||
isLoading?: boolean;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
const DataSourceSection = ({
|
||||
uid,
|
||||
name,
|
||||
application,
|
||||
children,
|
||||
loader,
|
||||
isLoading = false,
|
||||
description = null,
|
||||
}: DataSourceSectionProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`datasource-${uid}-heading`} role="listitem">
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="column" gap={0}>
|
||||
{isLoading && <LoadingIndicator datasourceUid={uid} />}
|
||||
<div className={styles.dataSourceSectionTitle}>
|
||||
{loader ?? (
|
||||
<Stack alignItems="center">
|
||||
{application && <DataSourceIcon application={application} />}
|
||||
<Text variant="body" weight="bold" element="h2" id={`datasource-${uid}-heading`}>
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<>
|
||||
{'·'}
|
||||
{description}
|
||||
</>
|
||||
)}
|
||||
<Spacer />
|
||||
<WithReturnButton
|
||||
title="alert rules"
|
||||
component={
|
||||
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
|
||||
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
<div className={styles.itemsWrapper}>{children}</div>
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
interface LazyPaginationProps {
|
||||
canMoveForward: boolean;
|
||||
canMoveBackward: boolean;
|
||||
nextPage: () => void;
|
||||
previousPage: () => void;
|
||||
}
|
||||
|
||||
function LazyPagination({ canMoveForward, canMoveBackward, nextPage, previousPage }: LazyPaginationProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<Button
|
||||
aria-label={`previous page`}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={previousPage}
|
||||
disabled={!canMoveBackward}
|
||||
>
|
||||
<Icon name="angle-left" />
|
||||
</Button>
|
||||
<Button aria-label={`next page`} size="sm" variant="secondary" onClick={nextPage} disabled={!canMoveForward}>
|
||||
<Icon name="angle-right" />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useIntersection } from 'react-use';
|
||||
|
||||
type Props = {
|
||||
handleLoad: () => void;
|
||||
};
|
||||
|
||||
function LoadMoreHelper({ handleLoad }: Props) {
|
||||
const intersectionRef = useRef<HTMLDivElement>(null);
|
||||
const intersection = useIntersection(intersectionRef, {
|
||||
root: null,
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const completelyInView = intersection && intersection.intersectionRatio > 0;
|
||||
if (completelyInView) {
|
||||
handleLoad();
|
||||
}
|
||||
}, [intersection, handleLoad]);
|
||||
|
||||
return <div ref={intersectionRef} data-testid="load-more-helper" />;
|
||||
}
|
||||
|
||||
export default LoadMoreHelper;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render } from 'test/test-utils';
|
||||
import { byTestId } from 'testing-library-selector';
|
||||
|
||||
import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { setupMswServer } from '../mockApi';
|
||||
import { grantUserPermissions } from '../mocks';
|
||||
import { alertingFactory } from '../mocks/server/db';
|
||||
|
||||
import RuleList from './RuleList.v2';
|
||||
|
||||
// This tests only checks if proper components are rendered, so we mock them
|
||||
// Both FilterView and GroupedView are tested in their own tests
|
||||
jest.mock('./FilterView', () => ({
|
||||
FilterView: () => <div data-testid="filter-view">Filter View</div>,
|
||||
}));
|
||||
|
||||
jest.mock('./GroupedView', () => ({
|
||||
GroupedView: () => <div data-testid="grouped-view">Grouped View</div>,
|
||||
}));
|
||||
|
||||
const ui = {
|
||||
filterView: byTestId('filter-view'),
|
||||
groupedView: byTestId('grouped-view'),
|
||||
};
|
||||
|
||||
setPluginLinksHook(() => ({ links: [], isLoading: false }));
|
||||
setPluginComponentsHook(() => ({ components: [], isLoading: false }));
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]);
|
||||
|
||||
setupMswServer();
|
||||
|
||||
alertingFactory.dataSource.build({ name: 'Mimir', uid: 'mimir' });
|
||||
alertingFactory.dataSource.build({ name: 'Prometheus', uid: 'prometheus' });
|
||||
|
||||
describe('RuleList v2', () => {
|
||||
it('should show grouped view by default', () => {
|
||||
render(<RuleList />);
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show grouped view when invalid view parameter is provided', () => {
|
||||
render(<RuleList />, {
|
||||
historyOptions: {
|
||||
initialEntries: ['/?view=invalid'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(ui.groupedView.get()).toBeInTheDocument();
|
||||
expect(ui.filterView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when "view=list" URL parameter is present', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?view=list'] } });
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show list view when a filter is applied', () => {
|
||||
render(<RuleList />, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } });
|
||||
|
||||
expect(ui.filterView.get()).toBeInTheDocument();
|
||||
expect(ui.groupedView.query()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,370 +1,32 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { withErrorBoundary } from '@grafana/ui';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import {
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
LinkButton,
|
||||
Menu,
|
||||
Pagination,
|
||||
Stack,
|
||||
Text,
|
||||
useStyles2,
|
||||
withErrorBoundary,
|
||||
} from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../api/alertRuleApi';
|
||||
import { featureDiscoveryApi } from '../api/featureDiscoveryApi';
|
||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||
import { Spacer } from '../components/Spacer';
|
||||
import { WithReturnButton } from '../components/WithReturnButton';
|
||||
import RulesFilter from '../components/rules/Filter/RulesFilter';
|
||||
import { getAllRulesSources, isGrafanaRulesSource } from '../utils/datasource';
|
||||
import { equal, fromRule, fromRulerRule, hashRule, stringifyIdentifier } from '../utils/rule-id';
|
||||
import { getRulePluginOrigin, isAlertingRule, isRecordingRule } from '../utils/rules';
|
||||
import { createRelativeUrl } from '../utils/url';
|
||||
import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector';
|
||||
import { useRulesFilter } from '../hooks/useFilteredRules';
|
||||
import { useURLSearchParams } from '../hooks/useURLSearchParams';
|
||||
|
||||
import { AlertRuleListItem, RecordingRuleListItem, UnknownRuleListItem } from './components/AlertRuleListItem';
|
||||
import { ListGroup } from './components/ListGroup';
|
||||
import { ListSection } from './components/ListSection';
|
||||
import { DataSourceIcon } from './components/Namespace';
|
||||
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
import { LoadingIndicator } from './components/RuleGroup';
|
||||
|
||||
const noop = () => {};
|
||||
const { usePrometheusRuleNamespacesQuery, useGetRuleGroupForNamespaceQuery } = alertRuleApi;
|
||||
import { FilterView } from './FilterView';
|
||||
import { GroupedView } from './GroupedView';
|
||||
|
||||
const RuleList = withErrorBoundary(
|
||||
() => {
|
||||
const ruleSources = getAllRulesSources();
|
||||
const [queryParams] = useURLSearchParams();
|
||||
const { filterState, hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
|
||||
const showListView = hasActiveFilters || view === 'list';
|
||||
|
||||
return (
|
||||
// We don't want to show the Loading... indicator for the whole page.
|
||||
// We show separate indicators for Grafana-managed and Cloud rules
|
||||
<AlertingPageWrapper navId="alert-list" isLoading={false} actions={null}>
|
||||
<RulesFilter onClear={() => {}} />
|
||||
<Stack direction="column" gap={1}>
|
||||
{ruleSources.map((ruleSource) => {
|
||||
if (isGrafanaRulesSource(ruleSource)) {
|
||||
return <GrafanaDataSourceLoader key={ruleSource} />;
|
||||
} else {
|
||||
return <DataSourceLoader key={ruleSource.uid} uid={ruleSource.uid} name={ruleSource.name} />;
|
||||
}
|
||||
})}
|
||||
</Stack>
|
||||
{showListView ? <FilterView filterState={filterState} /> : <GroupedView />}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
},
|
||||
{ style: 'page' }
|
||||
);
|
||||
|
||||
const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi;
|
||||
|
||||
interface DataSourceLoaderProps {
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
const GrafanaDataSourceLoader = () => {
|
||||
return <DataSourceSection name="Grafana" application="grafana" isLoading={true}></DataSourceSection>;
|
||||
};
|
||||
|
||||
const DataSourceLoader = ({ uid, name }: DataSourceLoaderProps) => {
|
||||
const { data: dataSourceInfo, isLoading } = useDiscoverDsFeaturesQuery({ uid });
|
||||
|
||||
if (isLoading) {
|
||||
return <DataSourceSection loader={<Skeleton width={250} height={16} />} />;
|
||||
}
|
||||
|
||||
// 2. grab prometheus rule groups with max_groups if supported
|
||||
if (dataSourceInfo) {
|
||||
const rulerEnabled = Boolean(dataSourceInfo.rulerConfig);
|
||||
|
||||
return (
|
||||
<PaginatedDataSourceLoader
|
||||
ruleSourceName={dataSourceInfo.name}
|
||||
rulerEnabled={rulerEnabled}
|
||||
uid={uid}
|
||||
name={name}
|
||||
application={dataSourceInfo.application}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface PaginatedDataSourceLoaderProps extends Pick<DataSourceSectionProps, 'application' | 'uid' | 'name'> {
|
||||
ruleSourceName: string;
|
||||
rulerEnabled?: boolean;
|
||||
}
|
||||
|
||||
function PaginatedDataSourceLoader({
|
||||
ruleSourceName,
|
||||
rulerEnabled = false,
|
||||
name,
|
||||
uid,
|
||||
application,
|
||||
}: PaginatedDataSourceLoaderProps) {
|
||||
const { data: ruleNamespaces = [], isLoading } = usePrometheusRuleNamespacesQuery({
|
||||
ruleSourceName,
|
||||
maxGroups: 25,
|
||||
limitAlerts: 0,
|
||||
excludeAlerts: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<DataSourceSection name={name} application={application} uid={uid} isLoading={isLoading}>
|
||||
<Stack direction="column" gap={1}>
|
||||
{ruleNamespaces.map((namespace) => (
|
||||
<ListSection
|
||||
key={namespace.name}
|
||||
title={
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Icon name="folder" /> {namespace.name}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{namespace.groups.map((group) => (
|
||||
<ListGroup
|
||||
key={group.name}
|
||||
name={group.name}
|
||||
isOpen={false}
|
||||
actions={
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<Menu.Item label="Edit" icon="pen" data-testid="edit-group-action" />
|
||||
<Menu.Item label="Re-order rules" icon="flip" />
|
||||
<Menu.Divider />
|
||||
<Menu.Item label="Export" icon="download-alt" />
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<IconButton name="ellipsis-h" aria-label="rule group actions" />
|
||||
</Dropdown>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{group.rules.map((rule) => {
|
||||
const groupIdentifier: RuleGroupIdentifier = {
|
||||
dataSourceName: ruleSourceName,
|
||||
groupName: group.name,
|
||||
namespaceName: namespace.name,
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertRuleLoader
|
||||
key={hashRule(rule)}
|
||||
rule={rule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
rulerEnabled={rulerEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ListGroup>
|
||||
))}
|
||||
</ListSection>
|
||||
))}
|
||||
{!isLoading && <Pagination currentPage={1} numberOfPages={0} onNavigate={noop} />}
|
||||
</Stack>
|
||||
</DataSourceSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlertRuleLoaderProps {
|
||||
rule: Rule;
|
||||
groupIdentifier: RuleGroupIdentifier;
|
||||
rulerEnabled?: boolean;
|
||||
}
|
||||
|
||||
function AlertRuleLoader({ rule, groupIdentifier, rulerEnabled = false }: AlertRuleLoaderProps) {
|
||||
const { dataSourceName, namespaceName, groupName } = groupIdentifier;
|
||||
|
||||
const ruleIdentifier = fromRule(dataSourceName, namespaceName, groupName, rule);
|
||||
const href = createViewLinkFromIdentifier(ruleIdentifier);
|
||||
const originMeta = getRulePluginOrigin(rule);
|
||||
|
||||
// @TODO work with context API to propagate rulerConfig and such
|
||||
const { data: dataSourceInfo } = useDiscoverDsFeaturesQuery({ rulesSourceName: dataSourceName });
|
||||
|
||||
// @TODO refactor this to use a separate hook (useRuleWithLocation() and useCombinedRule() seems to introduce infinite loading / recursion)
|
||||
const {
|
||||
isLoading,
|
||||
data: rulerRuleGroup,
|
||||
// error,
|
||||
} = useGetRuleGroupForNamespaceQuery(
|
||||
{
|
||||
namespace: namespaceName,
|
||||
group: groupName,
|
||||
rulerConfig: dataSourceInfo?.rulerConfig!,
|
||||
},
|
||||
{ skip: !dataSourceInfo?.rulerConfig }
|
||||
);
|
||||
|
||||
const rulerRule = useMemo(() => {
|
||||
if (!rulerRuleGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
return rulerRuleGroup.rules.find((rule) =>
|
||||
equal(fromRulerRule(dataSourceName, namespaceName, groupName, rule), ruleIdentifier)
|
||||
);
|
||||
}, [dataSourceName, groupName, namespaceName, ruleIdentifier, rulerRuleGroup]);
|
||||
|
||||
// 1. get the rule from the ruler API with "ruleWithLocation"
|
||||
// 1.1 skip this if this datasource does not have a ruler
|
||||
//
|
||||
// 2.1 render action buttons
|
||||
// 2.2 render provisioning badge and contact point metadata, etc.
|
||||
|
||||
const actions = useMemo(() => {
|
||||
if (!rulerEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <ActionsLoader />;
|
||||
}
|
||||
|
||||
if (rulerRule) {
|
||||
return <RuleActionsButtons rule={rulerRule} promRule={rule} groupIdentifier={groupIdentifier} compact />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [groupIdentifier, isLoading, rule, rulerEnabled, rulerRule]);
|
||||
|
||||
if (isAlertingRule(rule)) {
|
||||
return (
|
||||
<AlertRuleListItem
|
||||
name={rule.name}
|
||||
href={href}
|
||||
summary={rule.annotations?.summary}
|
||||
state={rule.state}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
instancesCount={undefined}
|
||||
actions={actions}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRecordingRule(rule)) {
|
||||
return (
|
||||
<RecordingRuleListItem
|
||||
name={rule.name}
|
||||
href={href}
|
||||
health={rule.health}
|
||||
error={rule.lastError}
|
||||
labels={rule.labels}
|
||||
isProvisioned={undefined}
|
||||
actions={null}
|
||||
origin={originMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <UnknownRuleListItem rule={rule} groupIdentifier={groupIdentifier} />;
|
||||
}
|
||||
|
||||
function createViewLinkFromIdentifier(identifier: RuleIdentifier, returnTo?: string) {
|
||||
const paramId = encodeURIComponent(stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(identifier.ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
interface DataSourceSectionProps extends PropsWithChildren {
|
||||
uid?: string;
|
||||
name?: string;
|
||||
loader?: ReactNode;
|
||||
application?: RulesSourceApplication;
|
||||
isLoading?: boolean;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
const DataSourceSection = ({
|
||||
uid,
|
||||
name,
|
||||
application,
|
||||
children,
|
||||
loader,
|
||||
isLoading = false,
|
||||
description = null,
|
||||
}: DataSourceSectionProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={1}>
|
||||
<Stack direction="column" gap={0}>
|
||||
{isLoading && <LoadingIndicator />}
|
||||
<div className={styles.dataSourceSectionTitle}>
|
||||
{loader ?? (
|
||||
<Stack alignItems="center">
|
||||
{application && <DataSourceIcon application={application} />}
|
||||
{name && (
|
||||
<Text variant="body" weight="bold">
|
||||
{name}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<>
|
||||
{'·'}
|
||||
{description}
|
||||
</>
|
||||
)}
|
||||
<Spacer />
|
||||
{uid && (
|
||||
<WithReturnButton
|
||||
title="alert rules"
|
||||
component={
|
||||
<LinkButton variant="secondary" size="sm" href={`/connections/datasources/edit/${uid}`}>
|
||||
<Trans i18nKey="alerting.rule-list.configure-datasource">Configure</Trans>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
<div className={styles.itemsWrapper}>{children}</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
export default RuleList;
|
||||
|
||||
@@ -10,14 +10,10 @@ import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
import { usePagination } from '..//hooks/usePagination';
|
||||
import { calculateTotalInstances } from '../components/rule-viewer/RuleViewer';
|
||||
import { ListSection } from '../rule-list/components/ListSection';
|
||||
import { groupIdentifier } from '../utils/groupIdentifier';
|
||||
import { createViewLink } from '../utils/misc';
|
||||
import { hashRule } from '../utils/rule-id';
|
||||
import {
|
||||
getRuleGroupLocationFromCombinedRule,
|
||||
getRulePluginOrigin,
|
||||
isAlertingRule,
|
||||
isGrafanaRulerRule,
|
||||
} from '../utils/rules';
|
||||
import { getRulePluginOrigin, isAlertingRule, isGrafanaRulerRule } from '../utils/rules';
|
||||
|
||||
import { AlertRuleListItem } from './components/AlertRuleListItem';
|
||||
import { ActionsLoader, RuleActionsButtons } from './components/RuleActionsButtons.V2';
|
||||
@@ -102,7 +98,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
|
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rulerRule) && Boolean(rulerRule.grafana_alert.provenance);
|
||||
const instancesCount = isAlertingRule(rule.promRule) ? calculateTotalInstances(rule.instanceTotals) : undefined;
|
||||
const groupIdentifier = getRuleGroupLocationFromCombinedRule(rule);
|
||||
const groupId = groupIdentifier.fromCombinedRule(rule);
|
||||
|
||||
if (!promRule) {
|
||||
return null;
|
||||
@@ -126,12 +122,7 @@ const RulesByState = ({ state, rules }: { state: PromAlertingRuleState; rules: C
|
||||
group={rule.group.name}
|
||||
actions={
|
||||
rule.rulerRule ? (
|
||||
<RuleActionsButtons
|
||||
compact
|
||||
rule={rule.rulerRule}
|
||||
promRule={promRule}
|
||||
groupIdentifier={groupIdentifier}
|
||||
/>
|
||||
<RuleActionsButtons compact rule={rule.rulerRule} promRule={promRule} groupIdentifier={groupId} />
|
||||
) : (
|
||||
<ActionsLoader />
|
||||
)
|
||||
|
||||
@@ -3,10 +3,16 @@ import pluralize from 'pluralize';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Icon, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Rule, RuleGroupIdentifier, RuleHealth } from 'app/types/unified-alerting';
|
||||
import { Labels, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
||||
import {
|
||||
GrafanaRulesSourceSymbol,
|
||||
Rule,
|
||||
RuleGroupIdentifierV2,
|
||||
RuleHealth,
|
||||
RulesSourceIdentifier,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { Labels, PromAlertingRuleState, RulesSourceApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { logError } from '../../Analytics';
|
||||
import { MetaText } from '../../components/MetaText';
|
||||
@@ -18,6 +24,7 @@ import { createContactPointSearchLink } from '../../utils/misc';
|
||||
import { RulePluginOrigin } from '../../utils/rules';
|
||||
|
||||
import { ListItem } from './ListItem';
|
||||
import { DataSourceIcon } from './Namespace';
|
||||
import { RuleListIcon } from './RuleListIcon';
|
||||
import { calculateNextEvaluationEstimate } from './util';
|
||||
|
||||
@@ -36,6 +43,8 @@ interface AlertRuleListItemProps {
|
||||
instancesCount?: number;
|
||||
namespace?: string;
|
||||
group?: string;
|
||||
rulesSource?: RulesSourceIdentifier;
|
||||
application?: RulesSourceApplication;
|
||||
// used for alert rules that use simplified routing
|
||||
contactPoint?: string;
|
||||
actions?: ReactNode;
|
||||
@@ -57,6 +66,8 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
instancesCount = 0,
|
||||
namespace,
|
||||
group,
|
||||
rulesSource,
|
||||
application,
|
||||
contactPoint,
|
||||
labels,
|
||||
origin,
|
||||
@@ -67,7 +78,7 @@ export const AlertRuleListItem = (props: AlertRuleListItemProps) => {
|
||||
if (namespace && group) {
|
||||
metadata.push(
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<RuleLocation namespace={namespace} group={group} />
|
||||
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -142,13 +153,27 @@ type RecordingRuleListItemProps = Omit<AlertRuleListItemProps, 'summary' | 'stat
|
||||
|
||||
export function RecordingRuleListItem({
|
||||
name,
|
||||
namespace,
|
||||
group,
|
||||
rulesSource,
|
||||
application,
|
||||
href,
|
||||
health,
|
||||
isProvisioned,
|
||||
error,
|
||||
isPaused,
|
||||
origin,
|
||||
actions,
|
||||
}: RecordingRuleListItemProps) {
|
||||
const metadata: ReactNode[] = [];
|
||||
if (namespace && group) {
|
||||
metadata.push(
|
||||
<Text color="secondary" variant="bodySmall">
|
||||
<RuleLocation namespace={namespace} group={group} rulesSource={rulesSource} application={application} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={
|
||||
@@ -165,8 +190,8 @@ export function RecordingRuleListItem({
|
||||
}
|
||||
description={<Summary error={error} />}
|
||||
icon={<RuleListIcon recording={true} health={health} isPaused={isPaused} />}
|
||||
actions={null}
|
||||
meta={[]}
|
||||
actions={actions}
|
||||
meta={metadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -236,13 +261,19 @@ function EvaluationMetadata({ lastEvaluation, evaluationInterval, state }: Evalu
|
||||
|
||||
interface UnknownRuleListItemProps {
|
||||
rule: Rule;
|
||||
groupIdentifier: RuleGroupIdentifier;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
}
|
||||
|
||||
export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { rulesSource, namespace, groupName } = groupIdentifier;
|
||||
|
||||
const ruleContext = { ...groupIdentifier, name: rule.name };
|
||||
const ruleContext = {
|
||||
name: rule.name,
|
||||
groupName,
|
||||
namespace: JSON.stringify(namespace),
|
||||
rulesSource: rulesSource.uid === GrafanaRulesSourceSymbol ? GRAFANA_RULES_SOURCE_NAME : rulesSource.uid,
|
||||
};
|
||||
logError(new Error('unknown rule type'), ruleContext);
|
||||
|
||||
return (
|
||||
@@ -262,18 +293,34 @@ export const UnknownRuleListItem = ({ rule, groupIdentifier }: UnknownRuleListIt
|
||||
interface RuleLocationProps {
|
||||
namespace: string;
|
||||
group: string;
|
||||
rulesSource?: RulesSourceIdentifier;
|
||||
application?: RulesSourceApplication;
|
||||
}
|
||||
|
||||
export const RuleLocation = ({ namespace, group }: RuleLocationProps) => (
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<Icon size="xs" name="folder" />
|
||||
<Stack direction="row" alignItems="center" gap={0}>
|
||||
{namespace}
|
||||
<Icon size="sm" name="angle-right" />
|
||||
{group}
|
||||
// @TODO make the datasource / namespace / group click-able to allow further filtering of the list
|
||||
export const RuleLocation = ({ namespace, group, rulesSource, application }: RuleLocationProps) => {
|
||||
const isGrafanaApp = application === 'grafana';
|
||||
const isDataSourceApp = !!rulesSource && !!application && !isGrafanaApp;
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
{isGrafanaApp && <Icon size="xs" name="folder" />}
|
||||
{isDataSourceApp && (
|
||||
<Tooltip content={rulesSource.name}>
|
||||
<span>
|
||||
<DataSourceIcon application={application} size={14} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Stack direction="row" alignItems="center" gap={0}>
|
||||
{namespace}
|
||||
<Icon size="sm" name="angle-right" />
|
||||
{group}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
alertListItemContainer: css({
|
||||
|
||||
@@ -60,7 +60,7 @@ const GroupHeader = (props: GroupHeaderProps) => {
|
||||
onClick={onToggle}
|
||||
aria-label={t('common.collapse', 'Collapse')}
|
||||
/>
|
||||
<Text truncate variant="body">
|
||||
<Text truncate variant="body" element="h4">
|
||||
{name}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -12,14 +12,15 @@ interface ListItemProps {
|
||||
meta?: ReactNode[];
|
||||
metaRight?: ReactNode[];
|
||||
actions?: ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export const ListItem = (props: ListItemProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { icon = null, title, description, meta, metaRight, actions } = props;
|
||||
const { icon = null, title, description, meta, metaRight, actions, 'data-testid': testId } = props;
|
||||
|
||||
return (
|
||||
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false">
|
||||
<li className={styles.alertListItemContainer} role="treeitem" aria-selected="false" data-testid={testId}>
|
||||
<Stack direction="row" alignItems="start" gap={1} wrap={false}>
|
||||
{/* icon */}
|
||||
{icon}
|
||||
|
||||
@@ -47,25 +47,26 @@ const Namespace = ({ children, name, href, application }: NamespaceProps) => {
|
||||
|
||||
interface NamespaceIconProps {
|
||||
application?: RulesSourceApplication;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const DataSourceIcon = ({ application }: NamespaceIconProps) => {
|
||||
export const DataSourceIcon = ({ application, size = 16 }: NamespaceIconProps) => {
|
||||
switch (application) {
|
||||
case PromApplication.Prometheus:
|
||||
return (
|
||||
<img
|
||||
width={16}
|
||||
height={16}
|
||||
width={size}
|
||||
height={size}
|
||||
src="public/app/plugins/datasource/prometheus/img/prometheus_logo.svg"
|
||||
alt="Prometheus"
|
||||
/>
|
||||
);
|
||||
case PromApplication.Mimir:
|
||||
return (
|
||||
<img width={16} height={16} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
|
||||
<img width={size} height={size} src="public/app/plugins/datasource/prometheus/img/mimir_logo.svg" alt="Mimir" />
|
||||
);
|
||||
case 'Loki':
|
||||
return <img width={16} height={16} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
|
||||
return <img width={size} height={size} src="public/app/plugins/datasource/loki/img/loki_icon.svg" alt="Loki" />;
|
||||
case 'grafana':
|
||||
default:
|
||||
return <Icon name="grafana" />;
|
||||
|
||||
@@ -11,7 +11,7 @@ import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/s
|
||||
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { Rule, RuleGroupIdentifier, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { AlertRuleAction, useRulerRuleAbility } from '../../hooks/useAbilities';
|
||||
@@ -24,7 +24,7 @@ import { createRelativeUrl } from '../../utils/url';
|
||||
interface Props {
|
||||
rule: RulerRuleDTO;
|
||||
promRule: Rule;
|
||||
groupIdentifier: RuleGroupIdentifier;
|
||||
groupIdentifier: RuleGroupIdentifierV2;
|
||||
/**
|
||||
* Should we show the buttons in a "compact" state?
|
||||
* i.e. without text and using smaller button sizes
|
||||
@@ -34,7 +34,7 @@ interface Props {
|
||||
|
||||
// For now this is just a copy of RuleActionsButtons.tsx but with the View button removed.
|
||||
// This is only done to keep the new list behind a feature flag and limit changes in the existing components
|
||||
export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }: Props) => {
|
||||
export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const redirectToListView = compact ? false : true;
|
||||
@@ -46,7 +46,6 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
|
||||
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
|
||||
>(undefined);
|
||||
|
||||
const { namespaceName, groupName, dataSourceName } = groupIdentifier;
|
||||
const { hasActiveFilters } = useRulesFilter();
|
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rule) && Boolean(rule.grafana_alert.provenance);
|
||||
@@ -58,11 +57,9 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
|
||||
const buttons: JSX.Element[] = [];
|
||||
const buttonSize = compact ? 'sm' : 'md';
|
||||
|
||||
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule);
|
||||
const identifier = ruleId.fromRulerRuleAndGroupIdentifierV2(groupIdentifier, rule);
|
||||
|
||||
if (canEditRule) {
|
||||
const identifier = ruleId.fromRulerRule(dataSourceName, namespaceName, groupName, rule);
|
||||
|
||||
const editURL = createRelativeUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`);
|
||||
|
||||
buttons.push(
|
||||
@@ -109,6 +106,6 @@ export const RuleActionsButtons = ({ compact, rule, promRule, groupIdentifier }:
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const ActionsLoader = () => <Skeleton width={50} height={16} />;
|
||||
|
||||
@@ -75,11 +75,11 @@ export const EvaluationGroupLoader = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingIndicator = () => {
|
||||
export const LoadingIndicator = ({ datasourceUid }: { datasourceUid: string }) => {
|
||||
const [ref, { width }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div ref={ref} data-testid={`ds-loading-indicator-${datasourceUid}`}>
|
||||
<LoadingBar width={width} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { merge } from 'ix/asynciterable/merge';
|
||||
import { filter, flatMap, map } from 'ix/asynciterable/operators';
|
||||
import { compact } from 'lodash';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Matcher } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { DataSourceRuleGroupIdentifier, ExternalRulesSourceIdentifier } from 'app/types/unified-alerting';
|
||||
import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { prometheusApi } from '../../api/prometheusApi';
|
||||
import { RulesFilter } from '../../search/rulesSearchParser';
|
||||
import { labelsMatchMatchers } from '../../utils/alertmanager';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { getDatasourceAPIUid, getExternalRulesSources } from '../../utils/datasource';
|
||||
import { parseMatcher } from '../../utils/matchers';
|
||||
import { isAlertingRule } from '../../utils/rules';
|
||||
|
||||
export interface RuleWithOrigin {
|
||||
rule: PromRuleDTO;
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier;
|
||||
}
|
||||
|
||||
const { useLazyGroupsQuery } = prometheusApi;
|
||||
|
||||
export function useFilteredRulesIteratorProvider() {
|
||||
const [fetchGroups] = useLazyGroupsQuery();
|
||||
const allExternalRulesSources = getExternalRulesSources();
|
||||
|
||||
/**
|
||||
* This async generator will continue to yield rule groups and will keep fetching backend pages as long as the consumer
|
||||
* is iterating.
|
||||
*/
|
||||
const fetchRuleSourceGroups = useCallback(
|
||||
async function* (ruleSource: ExternalRulesSourceIdentifier, maxGroups: number) {
|
||||
const response = await fetchGroups({ ruleSource: { uid: ruleSource.uid }, groupLimit: maxGroups });
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSource.uid },
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups.map((group) => [ruleSource, group] as const);
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
}
|
||||
},
|
||||
[fetchGroups]
|
||||
);
|
||||
|
||||
const getFilteredRulesIterator = (filterState: RulesFilter, groupLimit: number) => {
|
||||
const ruleSourcesToFetchFrom = filterState.dataSourceNames.length
|
||||
? filterState.dataSourceNames.map((ds) => ({ name: ds, uid: getDatasourceAPIUid(ds) }))
|
||||
: allExternalRulesSources;
|
||||
|
||||
// This split into the first one and the rest is only for compatibility with the merge function from ix
|
||||
const [source, ...iterables] = ruleSourcesToFetchFrom.map((ds) => fetchRuleSourceGroups(ds, groupLimit));
|
||||
|
||||
return merge(source, ...iterables).pipe(
|
||||
filter(([_, group]) => groupFilter(group, filterState)),
|
||||
flatMap(([rulesSource, group]) => group.rules.map((rule) => [rulesSource, group, rule] as const)),
|
||||
filter(([_, __, rule]) => ruleFilter(rule, filterState)),
|
||||
map(([rulesSource, group, rule]) => mapRuleToRuleWithOrigin(rulesSource, group, rule))
|
||||
);
|
||||
};
|
||||
|
||||
return { getFilteredRulesIterator };
|
||||
}
|
||||
|
||||
function mapRuleToRuleWithOrigin(
|
||||
rulesSource: ExternalRulesSourceIdentifier,
|
||||
group: PromRuleGroupDTO,
|
||||
rule: PromRuleDTO
|
||||
): RuleWithOrigin {
|
||||
return {
|
||||
rule,
|
||||
groupIdentifier: {
|
||||
rulesSource,
|
||||
namespace: { name: group.file },
|
||||
groupName: group.name,
|
||||
groupOrigin: 'datasource',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new group with only the rules that match the filter.
|
||||
* @returns A new group with filtered rules, or undefined if the group does not match the filter or all rules are filtered out.
|
||||
*/
|
||||
function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean {
|
||||
const { name, file } = group;
|
||||
|
||||
// TODO Add fuzzy filtering or not
|
||||
if (filterState.namespace && !file.includes(filterState.namespace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.groupName && !name.includes(filterState.groupName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
|
||||
const { name, labels = {}, health, type } = rule;
|
||||
|
||||
if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => name.includes(word))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.ruleName && !name.includes(filterState.ruleName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.labels.length > 0) {
|
||||
const matchers = compact(filterState.labels.map(looseParseMatcher));
|
||||
const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(labels, matchers);
|
||||
if (!doRuleLabelsMatchQuery) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterState.ruleType && type !== filterState.ruleType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.ruleState) {
|
||||
if (!isAlertingRule(rule)) {
|
||||
return false;
|
||||
}
|
||||
if (rule.state !== filterState.ruleState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterState.ruleHealth && health !== filterState.ruleHealth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filterState.dashboardUid) {
|
||||
return rule.labels ? rule.labels[Annotation.dashboardUID] === filterState.dashboardUid : false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function looseParseMatcher(matcherQuery: string): Matcher | undefined {
|
||||
try {
|
||||
return parseMatcher(matcherQuery);
|
||||
} catch {
|
||||
// Try to createa a matcher than matches all values for a given key
|
||||
return { name: matcherQuery, value: '', isRegex: true, isEqual: true };
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { groupRulesByFileName } from '../../api/prometheus';
|
||||
import { prometheusApi } from '../../api/prometheusApi';
|
||||
import { isLoading, useAsync } from '../../hooks/useAsync';
|
||||
import { getDatasourceAPIUid } from '../../utils/datasource';
|
||||
|
||||
const { useLazyGroupsQuery } = prometheusApi;
|
||||
|
||||
export function usePaginatedPrometheusRuleNamespaces(ruleSourceName: string, pageSize: number) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [groups, setGroups] = useState<PromRuleGroupDTO[]>([]);
|
||||
const [lastPage, setLastPage] = useState<number | undefined>(undefined);
|
||||
|
||||
const { groupsGenerator } = usePrometheusGroupsGenerator(ruleSourceName, pageSize);
|
||||
|
||||
const [{ execute: fetchMoreGroups }, groupsRequestState] = useAsync(async (groupsCount: number) => {
|
||||
let done = false;
|
||||
const currentGroups: PromRuleGroupDTO[] = [];
|
||||
|
||||
while (currentGroups.length < groupsCount) {
|
||||
const group = await groupsGenerator.next();
|
||||
if (group.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentGroups.push(group.value);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
const groupsTotal = groups.length + currentGroups.length;
|
||||
setLastPage(Math.ceil(groupsTotal / pageSize));
|
||||
}
|
||||
|
||||
setGroups((groups) => [...groups, ...currentGroups]);
|
||||
});
|
||||
|
||||
const fetchInProgress = isLoading(groupsRequestState);
|
||||
const canMoveForward = !fetchInProgress && (!lastPage || currentPage < lastPage);
|
||||
const canMoveBackward = currentPage > 1 && !fetchInProgress;
|
||||
|
||||
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 pageNamespaces = useMemo(() => {
|
||||
const pageGroups = groups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
// groupRulesByFileName mutates the array and RTKQ query freezes the response data
|
||||
return groupRulesByFileName(structuredClone(pageGroups), ruleSourceName);
|
||||
}, [groups, ruleSourceName, currentPage, pageSize]);
|
||||
|
||||
return { isLoading: fetchInProgress, page: pageNamespaces, nextPage, previousPage, canMoveForward, canMoveBackward };
|
||||
}
|
||||
|
||||
function usePrometheusGroupsGenerator(ruleSourceName: string, pageSize: number) {
|
||||
const [fetchGroups, { isLoading }] = useLazyGroupsQuery();
|
||||
|
||||
const prevRuleSourceName = usePrevious(ruleSourceName);
|
||||
// Generator lazily provides groups one by one only when needed
|
||||
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
|
||||
// For unpaginated data sources we just fetch everything in one go
|
||||
// For paginated we fetch the next page when needed
|
||||
const getGroups = useCallback(
|
||||
async function* (ruleSourceName: string, maxGroups: number) {
|
||||
const ruleSourceUid = getDatasourceAPIUid(ruleSourceName);
|
||||
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSourceUid },
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
let lastToken: string | undefined = undefined;
|
||||
if (response.data?.data?.groupNextToken) {
|
||||
lastToken = response.data.data.groupNextToken;
|
||||
}
|
||||
|
||||
while (lastToken) {
|
||||
const response = await fetchGroups({
|
||||
ruleSource: { uid: ruleSourceUid },
|
||||
groupNextToken: lastToken,
|
||||
groupLimit: maxGroups,
|
||||
});
|
||||
|
||||
if (!response.isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.data) {
|
||||
yield* response.data.data.groups;
|
||||
}
|
||||
|
||||
lastToken = response.data?.data?.groupNextToken;
|
||||
}
|
||||
},
|
||||
[fetchGroups]
|
||||
);
|
||||
|
||||
const [groupsGenerator, setGroupsGenerator] = useState<AsyncGenerator<PromRuleGroupDTO, void, unknown>>(
|
||||
getGroups(ruleSourceName, pageSize)
|
||||
);
|
||||
|
||||
const resetGenerator = useCallback(() => {
|
||||
setGroupsGenerator(getGroups(ruleSourceName, pageSize));
|
||||
}, [ruleSourceName, getGroups, pageSize]);
|
||||
|
||||
if (prevRuleSourceName && prevRuleSourceName !== ruleSourceName) {
|
||||
resetGenerator();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentGenerator = groupsGenerator;
|
||||
return () => {
|
||||
currentGenerator.return();
|
||||
};
|
||||
}, [groupsGenerator]);
|
||||
|
||||
return { groupsGenerator, isLoading };
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
AlertmanagerChoice,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { RulesSource } from 'app/types/unified-alerting';
|
||||
import {
|
||||
ExternalRulesSourceIdentifier,
|
||||
GrafanaRulesSourceSymbol,
|
||||
RulesSource,
|
||||
RulesSourceUid,
|
||||
} from 'app/types/unified-alerting';
|
||||
|
||||
import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { PERMISSIONS_CONTACT_POINTS } from '../components/contact-points/permissions';
|
||||
@@ -23,8 +28,6 @@ import { getAllDataSources } from './config';
|
||||
export const GRAFANA_RULES_SOURCE_NAME = 'grafana';
|
||||
export const GRAFANA_DATASOURCE_NAME = '-- Grafana --';
|
||||
|
||||
export type RulesSourceIdentifier = { rulesSourceName: string } | { uid: string };
|
||||
|
||||
export enum DataSourceType {
|
||||
Alertmanager = 'alertmanager',
|
||||
Loki = 'loki',
|
||||
@@ -211,6 +214,13 @@ export function getAllRulesSourceNames(): string[] {
|
||||
return availableRulesSources;
|
||||
}
|
||||
|
||||
export function getExternalRulesSources(): ExternalRulesSourceIdentifier[] {
|
||||
return getRulesDataSources().map((ds) => ({
|
||||
name: ds.name,
|
||||
uid: ds.uid,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAllRulesSources(): RulesSource[] {
|
||||
const availableRulesSources: RulesSource[] = getRulesDataSources();
|
||||
|
||||
@@ -293,13 +303,13 @@ export function getDatasourceAPIUid(dataSourceName: string) {
|
||||
return ds.uid;
|
||||
}
|
||||
|
||||
export function getDataSourceUID(rulesSourceIdentifier: RulesSourceIdentifier) {
|
||||
export function getDataSourceUID(rulesSourceIdentifier: { rulesSourceName: string } | { uid: RulesSourceUid }) {
|
||||
if ('uid' in rulesSourceIdentifier) {
|
||||
return rulesSourceIdentifier.uid;
|
||||
}
|
||||
|
||||
if (rulesSourceIdentifier.rulesSourceName === GRAFANA_RULES_SOURCE_NAME) {
|
||||
return GRAFANA_RULES_SOURCE_NAME;
|
||||
return GrafanaRulesSourceSymbol;
|
||||
}
|
||||
|
||||
const ds = getRulesDataSource(rulesSourceIdentifier.rulesSourceName);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CombinedRule, GrafanaRulesSourceSymbol, RuleGroupIdentifierV2 } from 'app/types/unified-alerting';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid, getRulesSourceName, isGrafanaRulesSource } from './datasource';
|
||||
import { isGrafanaRulerRule } from './rules';
|
||||
|
||||
function fromCombinedRule(rule: CombinedRule): RuleGroupIdentifierV2 {
|
||||
if (isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulesSource(rule.namespace.rulesSource)) {
|
||||
return {
|
||||
rulesSource: { uid: GrafanaRulesSourceSymbol, name: GRAFANA_RULES_SOURCE_NAME },
|
||||
namespace: { uid: rule.rulerRule.grafana_alert.namespace_uid },
|
||||
groupName: rule.group.name,
|
||||
groupOrigin: 'grafana',
|
||||
};
|
||||
}
|
||||
|
||||
const rulesSourceName = getRulesSourceName(rule.namespace.rulesSource);
|
||||
const rulesSourceUid = getDatasourceAPIUid(rulesSourceName);
|
||||
return {
|
||||
rulesSource: { uid: rulesSourceUid, name: rulesSourceName },
|
||||
namespace: { name: rule.namespace.name },
|
||||
groupName: rule.group.name,
|
||||
groupOrigin: 'datasource',
|
||||
};
|
||||
}
|
||||
|
||||
export const groupIdentifier = {
|
||||
fromCombinedRule,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { SortOrder } from 'app/plugins/panel/alertlist/types';
|
||||
import {
|
||||
Alert,
|
||||
CombinedRule,
|
||||
DataSourceRuleGroupIdentifier,
|
||||
FilterState,
|
||||
RuleIdentifier,
|
||||
RulesSource,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import {
|
||||
GrafanaAlertState,
|
||||
PromAlertingRuleState,
|
||||
PromRuleDTO,
|
||||
mapStateWithReasonToBaseState,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
@@ -43,6 +45,19 @@ export function createViewLink(ruleSource: RulesSource, rule: CombinedRule, retu
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
export function createViewLinkV2(
|
||||
groupIdentifier: DataSourceRuleGroupIdentifier,
|
||||
rule: PromRuleDTO,
|
||||
returnTo?: string
|
||||
): string {
|
||||
const ruleSourceName = groupIdentifier.rulesSource.name;
|
||||
const identifier = ruleId.fromRule(ruleSourceName, groupIdentifier.namespace.name, groupIdentifier.groupName, rule);
|
||||
const paramId = encodeURIComponent(ruleId.stringifyIdentifier(identifier));
|
||||
const paramSource = encodeURIComponent(ruleSourceName);
|
||||
|
||||
return createRelativeUrl(`/alerting/${paramSource}/${paramId}/view`, returnTo ? { returnTo } : {});
|
||||
}
|
||||
|
||||
export function createExploreLink(datasource: DataSourceRef, query: string) {
|
||||
const { uid, type } = datasource;
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
EditableRuleIdentifier,
|
||||
Rule,
|
||||
RuleGroupIdentifier,
|
||||
RuleGroupIdentifierV2,
|
||||
RuleIdentifier,
|
||||
RuleWithLocation,
|
||||
} from 'app/types/unified-alerting';
|
||||
import { Annotations, Labels, PromRuleType, RulerCloudRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { logError } from '../Analytics';
|
||||
import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
|
||||
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './datasource';
|
||||
@@ -44,6 +46,21 @@ export function fromRulerRule(
|
||||
} satisfies CloudRuleIdentifier;
|
||||
}
|
||||
|
||||
export function fromRulerRuleAndGroupIdentifierV2(
|
||||
ruleGroup: RuleGroupIdentifierV2,
|
||||
rule: RulerRuleDTO
|
||||
): EditableRuleIdentifier {
|
||||
if (ruleGroup.groupOrigin === 'grafana') {
|
||||
if (isGrafanaRulerRule(rule)) {
|
||||
return { uid: rule.grafana_alert.uid, ruleSourceName: 'grafana' };
|
||||
}
|
||||
logError(new Error('Rule is not a Grafana Ruler rule'));
|
||||
throw new Error('Rule is not a Grafana Ruler rule');
|
||||
}
|
||||
|
||||
return fromRulerRule(ruleGroup.rulesSource.name, ruleGroup.namespace.name, ruleGroup.groupName, rule);
|
||||
}
|
||||
|
||||
export function fromRulerRuleAndRuleGroupIdentifier(
|
||||
ruleGroup: RuleGroupIdentifier,
|
||||
rule: RulerRuleDTO
|
||||
|
||||
@@ -169,6 +169,7 @@ export interface PromResponse<T> {
|
||||
|
||||
export type PromRulesResponse = PromResponse<{
|
||||
groups: PromRuleGroupDTO[];
|
||||
groupNextToken?: string;
|
||||
totals?: AlertGroupTotals;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AlertState, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { PromOptions } from '@grafana/prometheus';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { LokiOptions } from 'app/plugins/datasource/loki/types';
|
||||
|
||||
import {
|
||||
@@ -96,6 +97,7 @@ export interface RulesSourceResult {
|
||||
namespaces?: RuleNamespace[];
|
||||
}
|
||||
|
||||
/** @deprecated use RulesSourceIdentifier instead */
|
||||
export type RulesSource = DataSourceInstanceSettings<PromOptions | LokiOptions> | 'grafana';
|
||||
|
||||
// combined prom and ruler result
|
||||
@@ -149,7 +151,21 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
|
||||
rule: T;
|
||||
}
|
||||
|
||||
// identifier for where we can find a RuleGroup
|
||||
export const GrafanaRulesSourceSymbol = Symbol('grafana');
|
||||
export type RulesSourceUid = string | typeof GrafanaRulesSourceSymbol;
|
||||
|
||||
export interface ExternalRulesSourceIdentifier {
|
||||
uid: string;
|
||||
name: string;
|
||||
}
|
||||
export interface GrafanaRulesSourceIdentifier {
|
||||
uid: typeof GrafanaRulesSourceSymbol;
|
||||
name: typeof GRAFANA_RULES_SOURCE_NAME;
|
||||
}
|
||||
|
||||
export type RulesSourceIdentifier = ExternalRulesSourceIdentifier | GrafanaRulesSourceIdentifier;
|
||||
|
||||
/** @deprecated use RuleGroupIdentifierV2 instead */
|
||||
export interface RuleGroupIdentifier {
|
||||
dataSourceName: string;
|
||||
/** ⚠️ use the Grafana folder UID for Grafana-managed rules */
|
||||
@@ -157,6 +173,30 @@ export interface RuleGroupIdentifier {
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export interface GrafanaNamespaceIdentifier {
|
||||
uid: string;
|
||||
}
|
||||
|
||||
export interface DataSourceNamespaceIdentifier {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GrafanaRuleGroupIdentifier {
|
||||
rulesSource: GrafanaRulesSourceIdentifier;
|
||||
groupName: string;
|
||||
namespace: GrafanaNamespaceIdentifier;
|
||||
groupOrigin: 'grafana';
|
||||
}
|
||||
|
||||
export interface DataSourceRuleGroupIdentifier {
|
||||
rulesSource: ExternalRulesSourceIdentifier;
|
||||
groupName: string;
|
||||
namespace: DataSourceNamespaceIdentifier;
|
||||
groupOrigin: 'datasource';
|
||||
}
|
||||
|
||||
export type RuleGroupIdentifierV2 = GrafanaRuleGroupIdentifier | DataSourceRuleGroupIdentifier;
|
||||
|
||||
export type CombinedRuleWithLocation = CombinedRule & RuleGroupIdentifier;
|
||||
|
||||
export interface PromRuleWithLocation {
|
||||
|
||||
@@ -479,6 +479,10 @@
|
||||
},
|
||||
"rule-list": {
|
||||
"configure-datasource": "Configure",
|
||||
"filter-view": {
|
||||
"no-more-results": "No more results – showing {{numberOfRules}} rules",
|
||||
"no-rules-found": "No alert or recording rules matched your current set of filters."
|
||||
},
|
||||
"new-alert-rule": "New alert rule"
|
||||
},
|
||||
"rule-state": {
|
||||
|
||||
@@ -479,6 +479,10 @@
|
||||
},
|
||||
"rule-list": {
|
||||
"configure-datasource": "Cőʼnƒįģūřę",
|
||||
"filter-view": {
|
||||
"no-more-results": "Ńő mőřę řęşūľŧş – şĥőŵįʼnģ {{numberOfRules}} řūľęş",
|
||||
"no-rules-found": "Ńő äľęřŧ őř řęčőřđįʼnģ řūľęş mäŧčĥęđ yőūř čūřřęʼnŧ şęŧ őƒ ƒįľŧęřş."
|
||||
},
|
||||
"new-alert-rule": "Ńęŵ äľęřŧ řūľę"
|
||||
},
|
||||
"rule-state": {
|
||||
|
||||
@@ -9511,7 +9511,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0":
|
||||
"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0":
|
||||
version: 22.9.0
|
||||
resolution: "@types/node@npm:22.9.0"
|
||||
dependencies:
|
||||
@@ -9538,6 +9538,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:>=10.0.0, @types/node@npm:>=13.7.4":
|
||||
version: 22.10.1
|
||||
resolution: "@types/node@npm:22.10.1"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.20.0"
|
||||
checksum: 10/c802a526da2f3fa3ccefd00a71244e7cb825329951719e79e8fec62b1dbc2855388c830489770611584665ce10be23c05ed585982038b24924e1ba2c2cce03fd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/nodemailer@npm:*":
|
||||
version: 6.4.15
|
||||
resolution: "@types/nodemailer@npm:6.4.15"
|
||||
@@ -11727,6 +11736,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bezier-easing@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "bezier-easing@npm:2.1.0"
|
||||
checksum: 10/086dfd042ccf91c3a9de811b635381aa6580e9f83d1951ed4ce4d4007645d8f3cebd491cb6259719ce9320df213316b99dd8e39e78997944b1c252a1d4b424b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"big.js@npm:^5.2.2":
|
||||
version: 5.2.2
|
||||
resolution: "big.js@npm:5.2.2"
|
||||
@@ -13492,6 +13508,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-mediaquery@npm:^0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "css-mediaquery@npm:0.1.2"
|
||||
checksum: 10/f2f7512daa015f98b82bd65bbdd7c2100b16dddf22784bde2a8085f10f11e8775da57108016fe767f5e400afe64581c15d4e649c47725af7eac5f4094fa7ae43
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-minimizer-webpack-plugin@npm:6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "css-minimizer-webpack-plugin@npm:6.0.0"
|
||||
@@ -16663,6 +16686,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fishery@npm:^2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "fishery@npm:2.2.2"
|
||||
dependencies:
|
||||
lodash.mergewith: "npm:^4.6.2"
|
||||
checksum: 10/68120995b0cd7827f0d50ef3b552a74289c99fa6195cafb6e5d376aa14421f12873274e1fc5aa101073fdae35ad6543cb5b04687bb295d8c37ff3b99dbdeb9ae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"flat-cache@npm:^3.0.4":
|
||||
version: 3.1.1
|
||||
resolution: "flat-cache@npm:3.1.1"
|
||||
@@ -17724,6 +17756,7 @@ __metadata:
|
||||
fast-deep-equal: "npm:^3.1.3"
|
||||
fast-json-patch: "npm:3.1.1"
|
||||
file-saver: "npm:2.0.5"
|
||||
fishery: "npm:^2.2.2"
|
||||
fork-ts-checker-webpack-plugin: "npm:9.0.2"
|
||||
glob: "npm:11.0.0"
|
||||
history: "npm:4.10.1"
|
||||
@@ -17736,6 +17769,7 @@ __metadata:
|
||||
immer: "npm:10.1.1"
|
||||
immutable: "npm:4.3.7"
|
||||
ini: "npm:^4.1.3"
|
||||
ix: "npm:^7.0.0"
|
||||
jest: "npm:29.7.0"
|
||||
jest-canvas-mock: "npm:2.5.2"
|
||||
jest-date-mock: "npm:1.0.10"
|
||||
@@ -17746,6 +17780,7 @@ __metadata:
|
||||
jest-watch-typeahead: "npm:^2.2.2"
|
||||
jquery: "npm:3.7.1"
|
||||
js-yaml: "npm:^4.1.0"
|
||||
jsdom-testing-mocks: "npm:^1.13.1"
|
||||
json-markup: "npm:^1.1.0"
|
||||
json-source-map: "npm:0.6.1"
|
||||
jsurl: "npm:^0.1.5"
|
||||
@@ -19633,6 +19668,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ix@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "ix@npm:7.0.0"
|
||||
dependencies:
|
||||
"@types/node": "npm:>=13.7.4"
|
||||
tslib: "npm:^2.6.2"
|
||||
checksum: 10/913cfbf645e04820f831bb2bd74f51e734284aa6b64819959964aaa2d8ee88ef246460f5a1cb4d71b4bda07dab241e3098210342dfb05ff73cfd20c9b2797f51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jackspeak@npm:^3.1.2":
|
||||
version: 3.1.2
|
||||
resolution: "jackspeak@npm:3.1.2"
|
||||
@@ -20313,6 +20358,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdom-testing-mocks@npm:^1.13.1":
|
||||
version: 1.13.1
|
||||
resolution: "jsdom-testing-mocks@npm:1.13.1"
|
||||
dependencies:
|
||||
bezier-easing: "npm:^2.1.0"
|
||||
css-mediaquery: "npm:^0.1.2"
|
||||
checksum: 10/434acae65fc89f4d8e0e2dc23b830ebaf4568483d5127da60bf825c8357c489955105b98a5d95ed5c4e1ed48f2fa5369b138f141d55b290cd656008493ac6a65
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsdom@npm:^20.0.0":
|
||||
version: 20.0.2
|
||||
resolution: "jsdom@npm:20.0.2"
|
||||
@@ -21196,6 +21251,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.mergewith@npm:^4.6.2":
|
||||
version: 4.6.2
|
||||
resolution: "lodash.mergewith@npm:4.6.2"
|
||||
checksum: 10/aea75a4492541a4902ac7e551dc6c54b722da0c187f84385d02e8fc33a7ae3454b837822446e5f63fcd5ad1671534ea408740b776670ea4d9c7890b10105fce0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.once@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "lodash.once@npm:4.1.1"
|
||||
@@ -29777,6 +29839,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~6.20.0":
|
||||
version: 6.20.0
|
||||
resolution: "undici-types@npm:6.20.0"
|
||||
checksum: 10/583ac7bbf4ff69931d3985f4762cde2690bb607844c16a5e2fbb92ed312fe4fa1b365e953032d469fa28ba8b224e88a595f0b10a449332f83fa77c695e567dbe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici@npm:^6.19.5":
|
||||
version: 6.19.8
|
||||
resolution: "undici@npm:6.19.8"
|
||||
|
||||
Reference in New Issue
Block a user