From 9369f07e32298ad5868858e92dc288ed101a9863 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 30 Apr 2024 10:34:52 +0200 Subject: [PATCH] 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 --- .../src/types/pluginExtensions.ts | 2 + .../unified/components/AlertLabels.tsx | 6 +- .../components/alert-groups/GroupBy.tsx | 4 +- .../rule-editor/labels/LabelsField.tsx | 14 +- .../components/rule-viewer/Actions.tsx | 16 ++ .../rule-viewer/RuleViewer.test.tsx | 138 +++++++++++++----- .../components/rule-viewer/RuleViewer.tsx | 9 +- .../rule-viewer/__mocks__/server.ts | 50 +++---- .../components/rules/RuleActionsButtons.tsx | 21 ++- .../rules/RuleListGroupView.test.tsx | 7 +- .../components/rules/RulesTable.test.tsx | 6 + .../unified/components/rules/RulesTable.tsx | 12 +- .../alerting/unified/hooks/useAbilities.ts | 12 +- public/app/features/alerting/unified/mocks.ts | 14 ++ .../alerting/unified/mocks/plugins.ts | 17 +-- .../unified/plugins/PluginOriginBadge.tsx | 32 ++++ .../plugins/useRulePluginLinkExtensions.ts | 92 ++++++++++++ .../alerting/unified/testSetup/plugins.ts | 97 ++++++++++++ .../features/alerting/unified/utils/labels.ts | 8 + .../alerting/unified/utils/matchers.ts | 6 +- .../alerting/unified/utils/rules.test.ts | 45 ++++++ .../features/alerting/unified/utils/rules.ts | 38 +++++ .../PanelDataAlertingTab.test.tsx | 7 +- .../panel/alertlist/GroupByWithLoading.tsx | 5 +- public/app/plugins/panel/alertlist/util.ts | 4 - 25 files changed, 552 insertions(+), 110 deletions(-) create mode 100644 public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx create mode 100644 public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts create mode 100644 public/app/features/alerting/unified/testSetup/plugins.ts create mode 100644 public/app/features/alerting/unified/utils/rules.test.ts diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 85a123754f8..c9ed2b0506d 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -117,6 +117,8 @@ export type PluginExtensionEventHelpers = { 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', diff --git a/public/app/features/alerting/unified/components/AlertLabels.tsx b/public/app/features/alerting/unified/components/AlertLabels.tsx index 30bfeb817b7..98a9102466b 100644 --- a/public/app/features/alerting/unified/components/AlertLabels.tsx +++ b/public/app/features/alerting/unified/components/AlertLabels.tsx @@ -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; diff --git a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx index a35dba726d1..5cd7443478e 100644 --- a/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/GroupBy.tsx @@ -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((key) => ({ label: key, value: key, diff --git a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx index 685b1626c67..3573f37ef6c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/labels/LabelsField.tsx @@ -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 ); } + +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 { diff --git a/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx index ee57acb14b2..ff9d296d417 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/Actions.tsx @@ -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={[]} /> )} + {rulePluginLinkExtension.length > 0 && ( + <> + + {rulePluginLinkExtension.map((extension) => ( + + ))} + + )} {canDelete && ( <> diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 1a82e056194..65d5453fee0 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -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( - , - { wrapper: TestProvider } + ); await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx index d6899597755..ed317f4b987 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.tsx @@ -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 (
+ {ruleOrigin && } {name} diff --git a/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts index de4dcdcf261..4bfdda20e06 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts +++ b/public/app/features/alerting/unified/components/rule-viewer/__mocks__/server.ts @@ -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>) { - server.use( - http.get('/api/folders/:uid', ({ request }) => { - const url = new URL(request.url); - const uid = url.searchParams.get('uid'); +function mockFolderAccess(accessControl: Partial>) { + 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, + }); + }); } diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index cf66b4b76b7..484910ad4bb 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -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( setRuleToDelete(rule)} />); - } + if (ruleExtensionLinks.length > 0) { + moreActions.push( + , + ...ruleExtensionLinks.map((extension) => ( + + )) + ); + } + + if (rulerRule && canDeleteRule) { + moreActions.push( + , + setRuleToDelete(rule)} /> + ); } if (buttons.length || moreActions.length) { diff --git a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx index 5e35dbdda97..0c51d4ccbb9 100644 --- a/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleListGroupView.test.tsx @@ -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 () => { diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index 3065719d8c0..7f29dcc73ac 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -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' }), diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.tsx index 56985d845ef..e1ae729a4f8 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.tsx @@ -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 ; + } + + const isGrafanaManagedRule = isGrafanaRulerRule(rulerRule); if (!isGrafanaManagedRule) { return null; } diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 9906553bf7b..14ffa869fc5 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 = { - [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], diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 7ac3c3dfb13..fcbed3a177b 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -10,6 +10,8 @@ import { DataSourceJsonData, DataSourcePluginMeta, DataSourceRef, + PluginExtensionLink, + PluginExtensionTypes, PluginMeta, PluginType, ScopedVars, @@ -707,6 +709,18 @@ export function getCloudRule(override?: Partial) { }); } +export function mockPluginLinkExtension(extension: Partial): 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: '' }; } diff --git a/public/app/features/alerting/unified/mocks/plugins.ts b/public/app/features/alerting/unified/mocks/plugins.ts index 48c98edb33b..1a97fc39f50 100644 --- a/public/app/features/alerting/unified/mocks/plugins.ts +++ b/public/app/features/alerting/unified/mocks/plugins.ts @@ -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) => + http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) => + pluginsRegistry.has(pluginId) + ? HttpResponse.json(pluginsRegistry.get(pluginId)!) + : HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 }) ); -} diff --git a/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx new file mode 100644 index 00000000000..437d56fd192 --- /dev/null +++ b/public/app/features/alerting/unified/plugins/PluginOriginBadge.tsx @@ -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 ? ( + {pluginMeta?.name} + ) : ( + + ); + + const tooltipContent = pluginMeta + ? `This rule is managed by the ${pluginMeta?.name} plugin` + : `This rule is managed by a plugin`; + + return ( + +
{badgeIcon}
+
+ ); +} diff --git a/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts new file mode 100644 index 00000000000..237dbbe8154 --- /dev/null +++ b/public/app/features/alerting/unified/plugins/useRulePluginLinkExtensions.ts @@ -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; +} + +export interface AlertingRuleExtensionContext extends BaseRuleExtensionContext { + annotations: Record; +} + +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]); +} diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts new file mode 100644 index 00000000000..1ef1b4fae87 --- /dev/null +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -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(); + 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 = { + 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', + }, +}; diff --git a/public/app/features/alerting/unified/utils/labels.ts b/public/app/features/alerting/unified/utils/labels.ts index 49da17ab97a..7c89af0ea13 100644 --- a/public/app/features/alerting/unified/utils/labels.ts +++ b/public/app/features/alerting/unified/utils/labels.ts @@ -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); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index 2cac197f8d9..a596a318da0 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -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]) => diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts new file mode 100644 index 00000000000..7ec2349f298 --- /dev/null +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -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' }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 49481acb971..248774818e2 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -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/ + // 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\/(?.+)$/); + 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'; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index 06320963aa8..ea559badb2a 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -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({ name: 'Prometheus', diff --git a/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx b/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx index ed5713720f7..dcf90a7f5b0 100644 --- a/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx +++ b/public/app/plugins/panel/alertlist/GroupByWithLoading.tsx @@ -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]); diff --git a/public/app/plugins/panel/alertlist/util.ts b/public/app/plugins/panel/alertlist/util.ts index 47479c7004e..5acf905d27c 100644 --- a/public/app/plugins/panel/alertlist/util.ts +++ b/public/app/plugins/panel/alertlist/util.ts @@ -36,7 +36,3 @@ export function filterAlerts( ); }); } - -export function isPrivateLabel(label: string) { - return !(label.startsWith('__') && label.endsWith('__')); -}