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:
Konrad Lalik 2024-04-30 10:34:52 +02:00 committed by GitHub
parent 78cda7ff5c
commit 9369f07e32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 552 additions and 110 deletions

View File

@ -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',

View File

@ -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;

View File

@ -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,

View File

@ -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 {

View File

@ -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 />

View File

@ -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());

View File

@ -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>

View File

@ -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,
});
});
}

View File

@ -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) {

View File

@ -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 () => {

View File

@ -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' }),

View File

@ -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;
}

View File

@ -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],

View File

@ -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: '' };
}

View File

@ -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 })
);
}

View File

@ -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>
);
}

View File

@ -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]);
}

View 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',
},
};

View File

@ -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);

View File

@ -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]) =>

View 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' });
});
});

View File

@ -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';

View File

@ -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',

View File

@ -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]);

View File

@ -36,7 +36,3 @@ export function filterAlerts(
);
});
}
export function isPrivateLabel(label: string) {
return !(label.startsWith('__') && label.endsWith('__'));
}