mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Immutable plugin rules and alerting plugins extensions (#86042)
* Add pluginsApi * Add rule origin badge * Make plugin provided rules read-only * Add plugin settings caching, add plugin icon on the rule detail page * Add basic extension point for custom plugin actions * Add support for alerting and recording rule extensions * Move plugin hooks to their own files * Add plugin custom actions to the alert list more actions menu * Add custom actions renderign test * Add more tests * Cleanup * Use test-utils in RuleViewer tests * Remove __grafana_origin label from the label autocomplete * Remove pluginsApi * Add plugin badge tooltip * Update tests * Add grafana origin constant key, remove unused code * Hide the grafana origin label * Fix typo, rename alerting extension points * Unify private labels handling * Add reactive plugins registry handling * Update tests * Fix tests * Fix tests * Fix panel tests * Add getRuleOrigin tests * Tests refactor, smalle improvements * Rename rule origin to better reflect the intent --------- Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
This commit is contained in:
parent
78cda7ff5c
commit
9369f07e32
@ -117,6 +117,8 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
|
||||
export enum PluginExtensionPoints {
|
||||
AlertInstanceAction = 'grafana/alerting/instance/action',
|
||||
AlertingHomePage = 'grafana/alerting/home',
|
||||
AlertingAlertingRuleAction = 'grafana/alerting/alertingrule/action',
|
||||
AlertingRecordingRuleAction = 'grafana/alerting/recordingrule/action',
|
||||
CommandPalette = 'grafana/commandpalette/action',
|
||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||
DataSourceConfig = 'grafana/datasources/config',
|
||||
|
@ -6,6 +6,8 @@ import React, { useState } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, getTagColorsFromName, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { isPrivateLabel } from '../utils/labels';
|
||||
|
||||
import { Label, LabelSize } from './Label';
|
||||
|
||||
interface Props {
|
||||
@ -20,7 +22,7 @@ export const AlertLabels = ({ labels, commonLabels = {}, size }: Props) => {
|
||||
|
||||
const labelsToShow = chain(labels)
|
||||
.toPairs()
|
||||
.reject(isPrivateKey)
|
||||
.reject(isPrivateLabel)
|
||||
.reject(([key]) => (showCommonLabels ? false : key in commonLabels))
|
||||
.value();
|
||||
|
||||
@ -63,8 +65,6 @@ function getLabelColor(input: string): string {
|
||||
return getTagColorsFromName(input).color;
|
||||
}
|
||||
|
||||
const isPrivateKey = ([key, _]: [string, string]) => key.startsWith('__') && key.endsWith('__');
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, size?: LabelSize) => ({
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
|
@ -5,6 +5,8 @@ import { SelectableValue } from '@grafana/data';
|
||||
import { Icon, Label, MultiSelect } from '@grafana/ui';
|
||||
import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { isPrivateLabelKey } from '../../utils/labels';
|
||||
|
||||
interface Props {
|
||||
groups: AlertmanagerGroup[];
|
||||
groupBy: string[];
|
||||
@ -13,7 +15,7 @@ interface Props {
|
||||
|
||||
export const GroupBy = ({ groups, groupBy, onGroupingChange }: Props) => {
|
||||
const labelKeyOptions = uniq(groups.flatMap((group) => group.alerts).flatMap(({ labels }) => Object.keys(labels)))
|
||||
.filter((label) => !(label.startsWith('__') && label.endsWith('__'))) // Filter out private labels
|
||||
.filter((label) => !isPrivateLabelKey(label)) // Filter out private labels
|
||||
.map<SelectableValue>((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
|
@ -12,6 +12,7 @@ import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSel
|
||||
import { fetchRulerRulesIfNotFetchedYet } from '../../../state/actions';
|
||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||
import { RuleFormValues } from '../../../types/rule-form';
|
||||
import { isPrivateLabelKey } from '../../../utils/labels';
|
||||
import AlertLabelDropdown from '../../AlertLabelDropdown';
|
||||
import { AlertLabels } from '../../AlertLabels';
|
||||
import { NeedHelpInfo } from '../NeedHelpInfo';
|
||||
@ -146,6 +147,9 @@ export function LabelsSubForm({ dataSourceName, onClose, initialLabels }: Labels
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const isKeyAllowed = (labelKey: string) => !isPrivateLabelKey(labelKey);
|
||||
|
||||
export function useCombinedLabels(
|
||||
dataSourceName: string,
|
||||
labelsPluginInstalled: boolean,
|
||||
@ -169,12 +173,12 @@ export function useCombinedLabels(
|
||||
|
||||
//------- Convert the keys from the ops labels to options for the dropdown
|
||||
const keysFromGopsLabels = useMemo(() => {
|
||||
return mapLabelsToOptions(Object.keys(labelsByKeyOps), labelsInSubform);
|
||||
return mapLabelsToOptions(Object.keys(labelsByKeyOps).filter(isKeyAllowed), labelsInSubform);
|
||||
}, [labelsByKeyOps, labelsInSubform]);
|
||||
|
||||
//------- Convert the keys from the existing alerts to options for the dropdown
|
||||
const keysFromExistingAlerts = useMemo(() => {
|
||||
return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts), labelsInSubform);
|
||||
return mapLabelsToOptions(Object.keys(labelsByKeyFromExisingAlerts).filter(isKeyAllowed), labelsInSubform);
|
||||
}, [labelsByKeyFromExisingAlerts, labelsInSubform]);
|
||||
|
||||
// create two groups of labels, one for ops and one for custom
|
||||
@ -238,6 +242,10 @@ export function useCombinedLabels(
|
||||
|
||||
const getValuesForLabel = useCallback(
|
||||
(key: string) => {
|
||||
if (!isKeyAllowed(key)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// values from existing alerts will take precedence over values from ops
|
||||
if (selectedKeyIsFromAlerts || !labelsPluginInstalled) {
|
||||
return mapLabelsToOptions(labelsByKeyFromExisingAlerts[key]);
|
||||
@ -254,6 +262,7 @@ export function useCombinedLabels(
|
||||
getValuesForLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
We will suggest labels from two sources: existing alerts and ops labels.
|
||||
We only will suggest labels from ops if the grafana-labels-app plugin is installed
|
||||
@ -262,6 +271,7 @@ export function useCombinedLabels(
|
||||
export interface LabelsWithSuggestionsProps {
|
||||
dataSourceName: string;
|
||||
}
|
||||
|
||||
export function LabelsWithSuggestions({ dataSourceName }: LabelsWithSuggestionsProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const {
|
||||
|
@ -7,6 +7,7 @@ import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItem
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
|
||||
import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions';
|
||||
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
import { createUrl } from '../../utils/url';
|
||||
@ -22,6 +23,7 @@ interface Props {
|
||||
|
||||
export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: Props) => {
|
||||
const { rule, identifier } = useAlertRule();
|
||||
const rulePluginLinkExtension = useRulePluginLinkExtension(rule);
|
||||
|
||||
// check all abilities and permissions
|
||||
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
|
||||
@ -71,6 +73,20 @@ export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: P
|
||||
childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]}
|
||||
/>
|
||||
)}
|
||||
{rulePluginLinkExtension.length > 0 && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
{rulePluginLinkExtension.map((extension) => (
|
||||
<Menu.Item
|
||||
key={extension.id}
|
||||
label={extension.title}
|
||||
icon={extension.icon}
|
||||
onClick={extension.onClick}
|
||||
url={extension.path}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
@ -1,16 +1,23 @@
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { render, waitFor, screen, userEvent } from 'test/test-utils';
|
||||
import { byText, byRole } from 'testing-library-selector';
|
||||
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { setBackendSrv, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../mocks';
|
||||
import {
|
||||
getCloudRule,
|
||||
getGrafanaRule,
|
||||
grantUserPermissions,
|
||||
mockDataSource,
|
||||
mockPluginLinkExtension,
|
||||
} from '../../mocks';
|
||||
import { setupDataSources } from '../../testSetup/datasources';
|
||||
import { plugins, setupPlugins } from '../../testSetup/plugins';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { DataSourceType } from '../../utils/datasource';
|
||||
import * as ruleId from '../../utils/rule-id';
|
||||
|
||||
import { AlertRuleProvider } from './RuleContext';
|
||||
@ -33,20 +40,58 @@ const ELEMENTS = {
|
||||
button: byRole('button', { name: /More/i }),
|
||||
actions: {
|
||||
silence: byRole('link', { name: /Silence/i }),
|
||||
declareIncident: byRole('menuitem', { name: /Declare incident/i }),
|
||||
duplicate: byRole('menuitem', { name: /Duplicate/i }),
|
||||
copyLink: byRole('menuitem', { name: /Copy link/i }),
|
||||
export: byRole('menuitem', { name: /Export/i }),
|
||||
delete: byRole('menuitem', { name: /Delete/i }),
|
||||
},
|
||||
pluginActions: {
|
||||
sloDashboard: byRole('link', { name: /SLO dashboard/i }),
|
||||
declareIncident: byRole('link', { name: /Declare incident/i }),
|
||||
assertsWorkbench: byRole('link', { name: /Open workbench/i }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { apiHandlers: pluginApiHandlers } = setupPlugins(plugins.slo, plugins.incident, plugins.asserts);
|
||||
|
||||
const server = createMockGrafanaServer(...pluginApiHandlers);
|
||||
|
||||
setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'mimir-1' }));
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: [
|
||||
mockPluginLinkExtension({ pluginId: 'grafana-slo-app', title: 'SLO dashboard', path: '/a/grafana-slo-app' }),
|
||||
mockPluginLinkExtension({
|
||||
pluginId: 'grafana-asserts-app',
|
||||
title: 'Open workbench',
|
||||
path: '/a/grafana-asserts-app',
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleUpdate,
|
||||
AccessControlAction.AlertingRuleDelete,
|
||||
AccessControlAction.AlertingInstanceCreate,
|
||||
]);
|
||||
setBackendSrv(backendSrv);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
describe('Grafana managed alert rule', () => {
|
||||
const server = createMockGrafanaServer();
|
||||
|
||||
const mockRule = getGrafanaRule(
|
||||
{
|
||||
name: 'Test alert',
|
||||
@ -71,29 +116,6 @@ describe('RuleViewer', () => {
|
||||
);
|
||||
const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule);
|
||||
|
||||
beforeAll(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
AccessControlAction.AlertingRuleRead,
|
||||
AccessControlAction.AlertingRuleUpdate,
|
||||
AccessControlAction.AlertingRuleDelete,
|
||||
AccessControlAction.AlertingInstanceCreate,
|
||||
]);
|
||||
setBackendSrv(backendSrv);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should render a Grafana managed alert rule', async () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier);
|
||||
|
||||
@ -131,8 +153,12 @@ describe('RuleViewer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('Data source managed alert rule', () => {
|
||||
const mockRule = getCloudRule({ name: 'cloud test alert' });
|
||||
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);
|
||||
|
||||
beforeAll(() => {
|
||||
@ -146,14 +172,53 @@ describe('RuleViewer', () => {
|
||||
renderRuleViewer(mockRule, mockRuleIdentifier);
|
||||
|
||||
// assert on basic info to be vissible
|
||||
expect(screen.getByText('Test alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('cloud test alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firing')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(mockRule.annotations[Annotation.summary])).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'View panel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: mockRule.annotations[Annotation.runbookURL] })).toBeInTheDocument();
|
||||
expect(screen.getByText(`Every ${mockRule.group.interval}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 user = userEvent.setup();
|
||||
|
||||
renderRuleViewer(sloRule, sloRuleIdentifier);
|
||||
|
||||
expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument();
|
||||
|
||||
await user.click(ELEMENTS.actions.more.button.get());
|
||||
|
||||
expect(ELEMENTS.actions.more.pluginActions.sloDashboard.get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.actions.more.pluginActions.assertsWorkbench.query()).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled());
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
renderRuleViewer(assertsRule, assertsRuleIdentifier);
|
||||
|
||||
expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(ELEMENTS.actions.more.button.get());
|
||||
|
||||
expect(ELEMENTS.actions.more.pluginActions.assertsWorkbench.get()).toBeInTheDocument();
|
||||
expect(ELEMENTS.actions.more.pluginActions.sloDashboard.query()).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(ELEMENTS.actions.more.pluginActions.declareIncident.get()).toBeEnabled());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -161,8 +226,7 @@ const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier)
|
||||
render(
|
||||
<AlertRuleProvider identifier={identifier} rule={rule}>
|
||||
<RuleViewer />
|
||||
</AlertRuleProvider>,
|
||||
{ wrapper: TestProvider }
|
||||
</AlertRuleProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument());
|
||||
|
@ -11,9 +11,12 @@ import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-aler
|
||||
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { defaultPageNav } from '../../RuleViewer';
|
||||
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { makeDashboardLink, makePanelLink } from '../../utils/misc';
|
||||
import {
|
||||
RulePluginOrigin,
|
||||
getRulePluginOrigin,
|
||||
isAlertingRule,
|
||||
isFederatedRuleGroup,
|
||||
isGrafanaRulerRule,
|
||||
@ -73,6 +76,7 @@ const RuleViewer = () => {
|
||||
const isPaused = isGrafanaRulerRule(rule.rulerRule) && isGrafanaRulerRulePaused(rule.rulerRule);
|
||||
|
||||
const showError = hasError && !isPaused;
|
||||
const ruleOrigin = getRulePluginOrigin(rule);
|
||||
|
||||
const summary = annotations[Annotation.summary];
|
||||
|
||||
@ -88,6 +92,7 @@ const RuleViewer = () => {
|
||||
state={isAlertType ? promRule.state : undefined}
|
||||
health={rule.promRule?.health}
|
||||
ruleType={rule.promRule?.type}
|
||||
ruleOrigin={ruleOrigin}
|
||||
/>
|
||||
)}
|
||||
actions={actions}
|
||||
@ -223,15 +228,17 @@ interface TitleProps {
|
||||
state?: PromAlertingRuleState;
|
||||
health?: RuleHealth;
|
||||
ruleType?: PromRuleType;
|
||||
ruleOrigin?: RulePluginOrigin;
|
||||
}
|
||||
|
||||
export const Title = ({ name, paused = false, state, health, ruleType }: TitleProps) => {
|
||||
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const isRecordingRule = ruleType === PromRuleType.Recording;
|
||||
|
||||
return (
|
||||
<div className={styles.title}>
|
||||
<LinkButton variant="secondary" icon="angle-left" href="/alerting/list" />
|
||||
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} />}
|
||||
<Text variant="h1" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { SetupServer, setupServer } from 'msw/node';
|
||||
import { http, HttpResponse, RequestHandler } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi';
|
||||
import { mockAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { alertmanagerChoiceHandler } from '../../../mocks/alertmanagerApi';
|
||||
|
||||
const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = {
|
||||
alertmanagersChoice: AlertmanagerChoice.Internal,
|
||||
numExternalAlertmanagers: 0,
|
||||
@ -18,39 +19,24 @@ const folderAccess = {
|
||||
[AccessControlAction.AlertingRuleDelete]: true,
|
||||
};
|
||||
|
||||
export function createMockGrafanaServer() {
|
||||
const server = setupServer();
|
||||
export function createMockGrafanaServer(...handlers: RequestHandler[]) {
|
||||
const folderHandler = mockFolderAccess(folderAccess);
|
||||
const amChoiceHandler = alertmanagerChoiceHandler(alertmanagerChoiceMockedResponse);
|
||||
|
||||
mockFolderAccess(server, folderAccess);
|
||||
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
|
||||
mockGrafanaIncidentPluginSettings(server);
|
||||
|
||||
return server;
|
||||
return setupServer(folderHandler, amChoiceHandler, ...handlers);
|
||||
}
|
||||
|
||||
// this endpoint is used to determine of we have edit / delete permissions for the Grafana managed alert rule
|
||||
// a user must alsso have permissions for the folder (namespace) in which the alert rule is stored
|
||||
function mockFolderAccess(server: SetupServer, accessControl: Partial<Record<AccessControlAction, boolean>>) {
|
||||
server.use(
|
||||
http.get('/api/folders/:uid', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const uid = url.searchParams.get('uid');
|
||||
function mockFolderAccess(accessControl: Partial<Record<AccessControlAction, boolean>>) {
|
||||
return http.get('/api/folders/:uid', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const uid = url.searchParams.get('uid');
|
||||
|
||||
return HttpResponse.json({
|
||||
title: 'My Folder',
|
||||
uid,
|
||||
accessControl,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function mockGrafanaIncidentPluginSettings(server: SetupServer) {
|
||||
server.use(
|
||||
http.get('/api/plugins/grafana-incident-app/settings', () => {
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
return HttpResponse.json({
|
||||
title: 'My Folder',
|
||||
uid,
|
||||
accessControl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ import { useDispatch } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
|
||||
import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions';
|
||||
import { deleteRuleAction, fetchAllPromAndRulerRulesAction } from '../../state/actions';
|
||||
import { getRulesSourceName } from '../../utils/datasource';
|
||||
import { createShareLink, createViewLink } from '../../utils/misc';
|
||||
@ -59,6 +60,8 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
|
||||
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
|
||||
const ruleExtensionLinks = useRulePluginLinkExtension(rule);
|
||||
|
||||
const [editRuleSupported, editRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
|
||||
const [deleteRuleSupported, deleteRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
|
||||
const [duplicateRuleSupported, duplicateRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
|
||||
@ -178,10 +181,22 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (canDeleteRule) {
|
||||
moreActions.push(<Menu.Item label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />);
|
||||
}
|
||||
if (ruleExtensionLinks.length > 0) {
|
||||
moreActions.push(
|
||||
<Menu.Divider />,
|
||||
...ruleExtensionLinks.map((extension) => (
|
||||
<Menu.Item key={extension.id} label={extension.title} icon={extension.icon} onClick={extension.onClick} />
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (rulerRule && canDeleteRule) {
|
||||
moreActions.push(
|
||||
<Menu.Divider />,
|
||||
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => setRuleToDelete(rule)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (buttons.length || moreActions.length) {
|
||||
|
@ -4,7 +4,7 @@ import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { locationService, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@ -23,6 +23,11 @@ const ui = {
|
||||
cloudRulesHeading: byRole('heading', { name: 'Mimir / Cortex / Loki' }),
|
||||
};
|
||||
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
describe('RuleListGroupView', () => {
|
||||
describe('RBAC', () => {
|
||||
it('Should display Grafana rules when the user has the alert rule read permission', async () => {
|
||||
|
@ -5,6 +5,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
@ -19,6 +20,11 @@ const mocks = {
|
||||
useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
|
||||
};
|
||||
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
const ui = {
|
||||
actionButtons: {
|
||||
edit: byRole('link', { name: 'Edit' }),
|
||||
|
@ -16,8 +16,9 @@ import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { PluginOriginBadge } from '../../plugins/PluginOriginBadge';
|
||||
import { Annotation } from '../../utils/constants';
|
||||
import { isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules';
|
||||
import { getRulePluginOrigin, isGrafanaRulerRule, isGrafanaRulerRulePaused } from '../../utils/rules';
|
||||
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||
import { DynamicTableWithGuidelines } from '../DynamicTableWithGuidelines';
|
||||
import { ProvisioningBadge } from '../Provisioning';
|
||||
@ -175,13 +176,18 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe
|
||||
size: showNextEvaluationColumn ? 4 : 5,
|
||||
},
|
||||
{
|
||||
id: 'provisioned',
|
||||
id: 'metadata',
|
||||
label: '',
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderCell: ({ data: rule }) => {
|
||||
const rulerRule = rule.rulerRule;
|
||||
const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule);
|
||||
|
||||
const originMeta = getRulePluginOrigin(rule);
|
||||
if (originMeta) {
|
||||
return <PluginOriginBadge pluginId={originMeta.pluginId} />;
|
||||
}
|
||||
|
||||
const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule);
|
||||
if (!isGrafanaManagedRule) {
|
||||
return null;
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import { alertmanagerApi } from '../api/alertmanagerApi';
|
||||
import { useAlertmanager } from '../state/AlertmanagerContext';
|
||||
import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../utils/rules';
|
||||
import { isFederatedRuleGroup, isGrafanaRulerRule, isPluginProvidedRule } from '../utils/rules';
|
||||
|
||||
import { useIsRuleEditable } from './useIsRuleEditable';
|
||||
|
||||
@ -147,9 +147,10 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
|
||||
const isFederated = isFederatedRuleGroup(rule.group);
|
||||
const isGrafanaManagedAlertRule = isGrafanaRulerRule(rule.rulerRule);
|
||||
const isPluginProvided = isPluginProvidedRule(rule);
|
||||
|
||||
// if a rule is either provisioned or a federated rule, we don't allow it to be removed or edited
|
||||
const immutableRule = isProvisioned || isFederated;
|
||||
// if a rule is either provisioned, federated or provided by a plugin rule, we don't allow it to be removed or edited
|
||||
const immutableRule = isProvisioned || isFederated || isPluginProvided;
|
||||
|
||||
const {
|
||||
isEditable,
|
||||
@ -163,11 +164,14 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
|
||||
const MaybeSupported = loading ? NotSupported : isRulerAvailable;
|
||||
const MaybeSupportedUnlessImmutable = immutableRule ? NotSupported : MaybeSupported;
|
||||
|
||||
// Creating duplicates of plugin-provided rules does not seem to make a lot of sense
|
||||
const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported;
|
||||
|
||||
const rulesPermissions = getRulesPermissions(rulesSourceName);
|
||||
const canSilence = useCanSilence(rulesSource);
|
||||
|
||||
const abilities: Abilities<AlertRuleAction> = {
|
||||
[AlertRuleAction.Duplicate]: toAbility(MaybeSupported, rulesPermissions.create),
|
||||
[AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create),
|
||||
[AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read),
|
||||
[AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false],
|
||||
[AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false],
|
||||
|
@ -10,6 +10,8 @@ import {
|
||||
DataSourceJsonData,
|
||||
DataSourcePluginMeta,
|
||||
DataSourceRef,
|
||||
PluginExtensionLink,
|
||||
PluginExtensionTypes,
|
||||
PluginMeta,
|
||||
PluginType,
|
||||
ScopedVars,
|
||||
@ -707,6 +709,18 @@ export function getCloudRule(override?: Partial<CombinedRule>) {
|
||||
});
|
||||
}
|
||||
|
||||
export function mockPluginLinkExtension(extension: Partial<PluginExtensionLink>): PluginExtensionLink {
|
||||
return {
|
||||
type: PluginExtensionTypes.link,
|
||||
id: 'plugin-id',
|
||||
pluginId: 'grafana-test-app',
|
||||
title: 'Test plugin link',
|
||||
description: 'Test plugin link',
|
||||
path: '/test',
|
||||
...extension,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockAlertWithState(state: GrafanaAlertState, labels?: {}): Alert {
|
||||
return { activeAt: '', annotations: {}, labels: labels || {}, state: state, value: '' };
|
||||
}
|
||||
|
@ -1,17 +1,10 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { SetupServer } from 'msw/lib/node';
|
||||
|
||||
import { PluginMeta } from '@grafana/data';
|
||||
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
export function mockPluginSettings(server: SetupServer, plugin: SupportedPlugin, response?: PluginMeta) {
|
||||
server.use(
|
||||
http.get(`/api/plugins/${plugin}/settings`, () => {
|
||||
if (response) {
|
||||
return HttpResponse.json(response);
|
||||
}
|
||||
return HttpResponse.json({}, { status: 404 });
|
||||
})
|
||||
export const pluginsHandler = (pluginsRegistry: Map<string, PluginMeta>) =>
|
||||
http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) =>
|
||||
pluginsRegistry.has(pluginId)
|
||||
? HttpResponse.json<PluginMeta>(pluginsRegistry.get(pluginId)!)
|
||||
: HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 })
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { Badge, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { getPluginSettings } from '../../../plugins/pluginSettings';
|
||||
|
||||
interface PluginOriginBadgeProps {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export function PluginOriginBadge({ pluginId }: PluginOriginBadgeProps) {
|
||||
const { value: pluginMeta } = useAsync(() => getPluginSettings(pluginId));
|
||||
|
||||
const logo = pluginMeta?.info.logos?.small;
|
||||
|
||||
const badgeIcon = logo ? (
|
||||
<img src={logo} alt={pluginMeta?.name} style={{ width: '20px', height: '20px' }} />
|
||||
) : (
|
||||
<Badge text={pluginId} color="orange" />
|
||||
);
|
||||
|
||||
const tooltipContent = pluginMeta
|
||||
? `This rule is managed by the ${pluginMeta?.name} plugin`
|
||||
: `This rule is managed by a plugin`;
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div>{badgeIcon}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PluginExtensionPoints } from '@grafana/data';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
import { CombinedRule } from 'app/types/unified-alerting';
|
||||
import { PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { getRulePluginOrigin } from '../utils/rules';
|
||||
|
||||
interface BaseRuleExtensionContext {
|
||||
name: string;
|
||||
namespace: string;
|
||||
group: string;
|
||||
expression: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext {
|
||||
annotations: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RecordingRuleExtensionContext extends BaseRuleExtensionContext {}
|
||||
|
||||
export function useRulePluginLinkExtension(rule: CombinedRule) {
|
||||
const ruleExtensionPoint = useRuleExtensionPoint(rule);
|
||||
const { extensions } = usePluginLinkExtensions(ruleExtensionPoint);
|
||||
|
||||
const ruleOrigin = getRulePluginOrigin(rule);
|
||||
const ruleType = rule.promRule?.type;
|
||||
if (!ruleOrigin || !ruleType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { pluginId } = ruleOrigin;
|
||||
|
||||
return extensions.filter((extension) => extension.pluginId === pluginId);
|
||||
}
|
||||
|
||||
export interface PluginRuleExtensionParam {
|
||||
pluginId: string;
|
||||
rule: CombinedRule;
|
||||
}
|
||||
|
||||
interface AlertingRuleExtensionPoint {
|
||||
extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction;
|
||||
context: AlertingRuleExtensionContext;
|
||||
}
|
||||
|
||||
interface RecordingRuleExtensionPoint {
|
||||
extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction;
|
||||
context: RecordingRuleExtensionContext;
|
||||
}
|
||||
|
||||
interface EmptyExtensionPoint {
|
||||
extensionPointId: '';
|
||||
}
|
||||
|
||||
type RuleExtensionPoint = AlertingRuleExtensionPoint | RecordingRuleExtensionPoint | EmptyExtensionPoint;
|
||||
|
||||
function useRuleExtensionPoint(rule: CombinedRule): RuleExtensionPoint {
|
||||
return useMemo(() => {
|
||||
const ruleType = rule.promRule?.type;
|
||||
|
||||
switch (ruleType) {
|
||||
case PromRuleType.Alerting:
|
||||
return {
|
||||
extensionPointId: PluginExtensionPoints.AlertingAlertingRuleAction,
|
||||
context: {
|
||||
name: rule.name,
|
||||
namespace: rule.namespace.name,
|
||||
group: rule.group.name,
|
||||
expression: rule.query,
|
||||
labels: rule.labels,
|
||||
annotations: rule.annotations,
|
||||
},
|
||||
};
|
||||
case PromRuleType.Recording:
|
||||
return {
|
||||
extensionPointId: PluginExtensionPoints.AlertingRecordingRuleAction,
|
||||
context: {
|
||||
name: rule.name,
|
||||
namespace: rule.namespace.name,
|
||||
group: rule.group.name,
|
||||
expression: rule.query,
|
||||
labels: rule.labels,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return { extensionPointId: '' };
|
||||
}
|
||||
}, [rule]);
|
||||
}
|
97
public/app/features/alerting/unified/testSetup/plugins.ts
Normal file
97
public/app/features/alerting/unified/testSetup/plugins.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { RequestHandler } from 'msw';
|
||||
|
||||
import { PluginMeta, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { pluginsHandler } from '../mocks/plugins';
|
||||
|
||||
export function setupPlugins(...plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } {
|
||||
const pluginsRegistry = new Map<string, PluginMeta>();
|
||||
plugins.forEach((plugin) => pluginsRegistry.set(plugin.id, plugin));
|
||||
|
||||
pluginsRegistry.forEach((plugin) => {
|
||||
config.apps[plugin.id] = {
|
||||
id: plugin.id,
|
||||
path: plugin.baseUrl,
|
||||
preload: true,
|
||||
version: plugin.info.version,
|
||||
angular: plugin.angular ?? { detected: false, hideDeprecation: false },
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
apiHandlers: [pluginsHandler(pluginsRegistry)],
|
||||
};
|
||||
}
|
||||
|
||||
export const plugins: Record<string, PluginMeta> = {
|
||||
slo: {
|
||||
id: 'grafana-slo-app',
|
||||
name: 'SLO dashboard',
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
info: {
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: '',
|
||||
},
|
||||
description: 'Create and manage Service Level Objectives',
|
||||
links: [],
|
||||
logos: {
|
||||
small: 'public/plugins/grafana-slo-app/img/logo.svg',
|
||||
large: 'public/plugins/grafana-slo-app/img/logo.svg',
|
||||
},
|
||||
screenshots: [],
|
||||
version: 'local-dev',
|
||||
updated: '2024-04-09',
|
||||
},
|
||||
module: 'public/plugins/grafana-slo-app/module.js',
|
||||
baseUrl: 'public/plugins/grafana-slo-app',
|
||||
},
|
||||
incident: {
|
||||
id: 'grafana-incident-app',
|
||||
name: 'Incident management',
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
info: {
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: '',
|
||||
},
|
||||
description: 'Incident management',
|
||||
links: [],
|
||||
logos: {
|
||||
small: 'public/plugins/grafana-incident-app/img/logo.svg',
|
||||
large: 'public/plugins/grafana-incident-app/img/logo.svg',
|
||||
},
|
||||
screenshots: [],
|
||||
version: 'local-dev',
|
||||
updated: '2024-04-09',
|
||||
},
|
||||
module: 'public/plugins/grafana-incident-app/module.js',
|
||||
baseUrl: 'public/plugins/grafana-incident-app',
|
||||
},
|
||||
asserts: {
|
||||
id: 'grafana-asserts-app',
|
||||
name: 'Asserts',
|
||||
type: PluginType.app,
|
||||
enabled: true,
|
||||
info: {
|
||||
author: {
|
||||
name: 'Grafana Labs',
|
||||
url: '',
|
||||
},
|
||||
description: 'Asserts',
|
||||
links: [],
|
||||
logos: {
|
||||
small: 'public/plugins/grafana-asserts-app/img/logo.svg',
|
||||
large: 'public/plugins/grafana-asserts-app/img/logo.svg',
|
||||
},
|
||||
screenshots: [],
|
||||
version: 'local-dev',
|
||||
updated: '2024-04-09',
|
||||
},
|
||||
module: 'public/plugins/grafana-asserts-app/module.js',
|
||||
baseUrl: 'public/plugins/grafana-asserts-app',
|
||||
},
|
||||
};
|
@ -32,3 +32,11 @@ export function arrayKeyValuesToObject(
|
||||
|
||||
return labelsObject;
|
||||
}
|
||||
|
||||
export const GRAFANA_ORIGIN_LABEL = '__grafana_origin';
|
||||
|
||||
export function isPrivateLabelKey(labelKey: string) {
|
||||
return (labelKey.startsWith('__') && labelKey.endsWith('__')) || labelKey === GRAFANA_ORIGIN_LABEL;
|
||||
}
|
||||
|
||||
export const isPrivateLabel = ([key, _]: [string, string]) => isPrivateLabelKey(key);
|
||||
|
@ -11,6 +11,8 @@ import { Matcher, MatcherOperator, ObjectMatcher, Route } from 'app/plugins/data
|
||||
|
||||
import { Labels } from '../../../../types/unified-alerting-dto';
|
||||
|
||||
import { isPrivateLabelKey } from './labels';
|
||||
|
||||
const matcherOperators = [
|
||||
MatcherOperator.regex,
|
||||
MatcherOperator.notRegex,
|
||||
@ -91,9 +93,7 @@ export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] {
|
||||
}
|
||||
|
||||
export const getMatcherQueryParams = (labels: Labels) => {
|
||||
const validMatcherLabels = Object.entries(labels).filter(
|
||||
([labelKey]) => !(labelKey.startsWith('__') && labelKey.endsWith('__'))
|
||||
);
|
||||
const validMatcherLabels = Object.entries(labels).filter(([labelKey]) => !isPrivateLabelKey(labelKey));
|
||||
|
||||
const matcherUrlParams = new URLSearchParams();
|
||||
validMatcherLabels.forEach(([labelKey, labelValue]) =>
|
||||
|
45
public/app/features/alerting/unified/utils/rules.test.ts
Normal file
45
public/app/features/alerting/unified/utils/rules.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { mockCombinedRule } from '../mocks';
|
||||
|
||||
import { GRAFANA_ORIGIN_LABEL } from './labels';
|
||||
import { getRulePluginOrigin } from './rules';
|
||||
|
||||
describe('getRuleOrigin', () => {
|
||||
it('returns undefined when no origin label is present', () => {
|
||||
const rule = mockCombinedRule({
|
||||
labels: {},
|
||||
});
|
||||
expect(getRulePluginOrigin(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when origin label does not match expected format', () => {
|
||||
const rule = mockCombinedRule({
|
||||
labels: { [GRAFANA_ORIGIN_LABEL]: 'invalid_format' },
|
||||
});
|
||||
expect(getRulePluginOrigin(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when plugin is not installed', () => {
|
||||
const rule = mockCombinedRule({
|
||||
labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/uninstalled_plugin' },
|
||||
});
|
||||
expect(getRulePluginOrigin(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns pluginId when origin label matches expected format and plugin is installed', () => {
|
||||
config.apps = {
|
||||
installed_plugin: {
|
||||
id: 'installed_plugin',
|
||||
version: '',
|
||||
path: '',
|
||||
preload: true,
|
||||
angular: { detected: false, hideDeprecation: false },
|
||||
},
|
||||
};
|
||||
const rule = mockCombinedRule({
|
||||
labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/installed_plugin' },
|
||||
});
|
||||
expect(getRulePluginOrigin(rule)).toEqual({ pluginId: 'installed_plugin' });
|
||||
});
|
||||
});
|
@ -1,10 +1,12 @@
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import { AlertState } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
Alert,
|
||||
AlertingRule,
|
||||
CloudRuleIdentifier,
|
||||
CombinedRule,
|
||||
CombinedRuleGroup,
|
||||
CombinedRuleWithLocation,
|
||||
GrafanaRuleIdentifier,
|
||||
@ -33,6 +35,7 @@ import { RuleHealth } from '../search/rulesSearchParser';
|
||||
|
||||
import { RULER_NOT_SUPPORTED_MSG } from './constants';
|
||||
import { getRulesSourceName } from './datasource';
|
||||
import { GRAFANA_ORIGIN_LABEL } from './labels';
|
||||
import { AsyncRequestState } from './redux';
|
||||
import { safeParsePrometheusDuration } from './time';
|
||||
|
||||
@ -100,6 +103,41 @@ export function getRuleHealth(health: string): RuleHealth | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RulePluginOrigin {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export function getRulePluginOrigin(rule: CombinedRule): RulePluginOrigin | undefined {
|
||||
// com.grafana.origin=plugin/<plugin-identifier>
|
||||
// Prom and Mimir do not support dots in label names 😔
|
||||
const origin = rule.labels[GRAFANA_ORIGIN_LABEL];
|
||||
if (!origin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = origin.match(/^plugin\/(?<pluginId>.+)$/);
|
||||
if (!match?.groups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pluginId = match.groups['pluginId'];
|
||||
const pluginInstalled = isPluginInstalled(pluginId);
|
||||
|
||||
if (!pluginInstalled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { pluginId };
|
||||
}
|
||||
|
||||
function isPluginInstalled(pluginId: string) {
|
||||
return Boolean(config.apps[pluginId]);
|
||||
}
|
||||
|
||||
export function isPluginProvidedRule(rule: CombinedRule): boolean {
|
||||
return Boolean(getRulePluginOrigin(rule));
|
||||
}
|
||||
|
||||
export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): string {
|
||||
if (state === PromAlertingRuleState.Inactive) {
|
||||
return 'Normal';
|
||||
|
@ -6,7 +6,7 @@ import { byTestId } from 'testing-library-selector';
|
||||
|
||||
import { DataSourceApi } from '@grafana/data';
|
||||
import { PromOptions, PrometheusDatasource } from '@grafana/prometheus';
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { locationService, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { fetchRules } from 'app/features/alerting/unified/api/prometheus';
|
||||
import { fetchRulerRules } from 'app/features/alerting/unified/api/ruler';
|
||||
@ -48,6 +48,11 @@ jest.mock('app/features/alerting/unified/api/ruler');
|
||||
jest.spyOn(config, 'getAllDataSources');
|
||||
jest.spyOn(ruleActionButtons, 'matchesWidth').mockReturnValue(false);
|
||||
|
||||
setPluginExtensionsHook(() => ({
|
||||
extensions: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
const dataSources = {
|
||||
prometheus: mockDataSource<PromOptions>({
|
||||
name: 'Prometheus',
|
||||
|
@ -14,8 +14,7 @@ import { AlertingRule } from 'app/types/unified-alerting';
|
||||
import { PromRuleType } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { fetchPromRulesAction } from '../../../features/alerting/unified/state/actions';
|
||||
|
||||
import { isPrivateLabel } from './util';
|
||||
import { isPrivateLabelKey } from '../../../features/alerting/unified/utils/labels';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@ -56,7 +55,7 @@ export const GroupBy = (props: Props) => {
|
||||
.flatMap((group) => group.rules.filter((rule): rule is AlertingRule => rule.type === PromRuleType.Alerting))
|
||||
.flatMap((rule) => rule.alerts ?? [])
|
||||
.map((alert) => Object.keys(alert.labels ?? {}))
|
||||
.flatMap((labels) => labels.filter(isPrivateLabel));
|
||||
.flatMap((labels) => labels.filter((label) => !isPrivateLabelKey(label)));
|
||||
|
||||
return uniq(allLabels);
|
||||
}, [allRequestsReady, promRulesByDatasource]);
|
||||
|
@ -36,7 +36,3 @@ export function filterAlerts(
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isPrivateLabel(label: string) {
|
||||
return !(label.startsWith('__') && label.endsWith('__'));
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user