From 9e29c215c38290d2b7120343b76d9e8c1a0c81b0 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 16 May 2024 09:34:07 +0100 Subject: [PATCH] Alerting: Update silences creation to support `__alert_rule_uid__` and move into drawer (#87320) --- .../alerting/unified/Silences.test.tsx | 2 +- .../features/alerting/unified/Silences.tsx | 16 +- .../alerting/unified/api/alertRuleApi.ts | 7 + .../alerting/unified/api/alertSilencesApi.ts | 18 +- .../unified/components/AlertManagerPicker.tsx | 26 +- .../components/rule-viewer/AlertRuleMenu.tsx | 12 +- .../rule-viewer/RuleViewer.test.tsx | 74 +++-- .../components/rules/RuleActionsButtons.tsx | 10 + .../RuleActionsButtons.test.tsx.snap | 2 +- .../components/silences/MatchersField.tsx | 54 +++- .../silences/SilenceGrafanaRuleDrawer.tsx | 52 ++++ .../silences/SilencedInstancesPreview.tsx | 60 +++-- .../components/silences/SilencesEditor.tsx | 254 +++++++----------- .../unified/components/silences/utils.ts | 77 ++++++ .../unified/hooks/useSilenceNavData.test.tsx | 10 +- .../unified/hooks/useSilenceNavData.ts | 6 +- .../app/features/alerting/unified/mockApi.ts | 4 +- .../unified/mocks/server/all-handlers.ts | 2 + .../unified/mocks/server/configure.ts | 10 + .../mocks/server/handlers/alertRules.ts | 16 ++ .../unified/mocks/server/handlers/folders.ts | 12 +- .../unified/mocks/server/handlers/plugins.ts | 20 +- .../alerting/unified/testSetup/plugins.ts | 21 +- .../alerting/unified/utils/constants.ts | 3 + .../features/alerting/unified/utils/misc.ts | 10 - 25 files changed, 512 insertions(+), 266 deletions(-) create mode 100644 public/app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer.tsx create mode 100644 public/app/features/alerting/unified/components/silences/utils.ts create mode 100644 public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 909cf9db9f6..739d4bb47fe 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -68,7 +68,7 @@ const ui = { matchersField: byTestId('matcher'), matcherName: byPlaceholderText('label'), matcherValue: byPlaceholderText('value'), - comment: byPlaceholderText('Details about the silence'), + comment: byLabelText(/Comment/i), matcherOperatorSelect: byLabelText('operator'), matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }), addMatcherButton: byRole('button', { name: 'Add matcher' }), diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index 0663017b29e..6fa311a6d01 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -2,10 +2,14 @@ import React from 'react'; import { Route, RouteChildrenProps, Switch } from 'react-router-dom'; import { withErrorBoundary } from '@grafana/ui'; +import { + defaultsFromQuery, + getDefaultSilenceFormValues, +} from 'app/features/alerting/unified/components/silences/utils'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from './components/GrafanaAlertmanagerDeliveryWarning'; -import SilencesEditor from './components/silences/SilencesEditor'; +import ExistingSilenceEditor, { SilencesEditor } from './components/silences/SilencesEditor'; import SilencesTable from './components/silences/SilencesTable'; import { useSilenceNavData } from './hooks/useSilenceNavData'; import { useAlertmanager } from './state/AlertmanagerContext'; @@ -20,19 +24,23 @@ const Silences = () => { return ( <> - - + {({ location }) => { + const queryParams = new URLSearchParams(location.search); + const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams)); + + return ; + }} {({ match }: RouteChildrenProps<{ id: string }>) => { return ( match?.params.id && ( - + ) ); }} diff --git a/public/app/features/alerting/unified/api/alertRuleApi.ts b/public/app/features/alerting/unified/api/alertRuleApi.ts index 3bd3a1f91f7..b849de1e9a6 100644 --- a/public/app/features/alerting/unified/api/alertRuleApi.ts +++ b/public/app/features/alerting/unified/api/alertRuleApi.ts @@ -9,6 +9,7 @@ import { PostableRuleGrafanaRuleDTO, PromRulesResponse, RulerAlertingRuleDTO, + RulerGrafanaRuleDTO, RulerRecordingRuleDTO, RulerRuleGroupDTO, RulerRulesConfigDTO, @@ -214,6 +215,12 @@ export const alertRuleApi = alertingApi.injectEndpoints({ providesTags: ['CombinedAlertRule'], }), + getAlertRule: build.query({ + // TODO: In future, if supported in other rulers, parametrize ruler source name + // For now, to make the consumption of this hook clearer, only support Grafana ruler + query: ({ uid }) => ({ url: `/api/ruler/${GRAFANA_RULES_SOURCE_NAME}/api/v1/rule/${uid}` }), + }), + exportRules: build.query({ query: ({ format, folderUid, group, ruleUid }) => ({ url: `/api/ruler/grafana/api/v1/export/rules`, diff --git a/public/app/features/alerting/unified/api/alertSilencesApi.ts b/public/app/features/alerting/unified/api/alertSilencesApi.ts index ad59bf29176..f3645ee6a45 100644 --- a/public/app/features/alerting/unified/api/alertSilencesApi.ts +++ b/public/app/features/alerting/unified/api/alertSilencesApi.ts @@ -1,7 +1,13 @@ +import { AppEvents } from '@grafana/data'; +import appEvents from 'app/core/app_events'; import { Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; import { alertingApi } from './alertingApi'; +export type SilenceCreatedResponse = { + silenceId: string; +}; + export const alertSilencesApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ getSilences: build.query< @@ -33,9 +39,7 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ }), createSilence: build.mutation< - { - silenceId: string; - }, + SilenceCreatedResponse, { datasourceUid: string; payload: SilenceCreatePayload; @@ -47,6 +51,14 @@ export const alertSilencesApi = alertingApi.injectEndpoints({ data: payload, }), invalidatesTags: ['AlertmanagerSilences', 'AlertmanagerAlerts'], + onQueryStarted: async (arg, { queryFulfilled }) => { + try { + await queryFulfilled; + appEvents.emit(AppEvents.alertSuccess, ['Silence created']); + } catch (error) { + appEvents.emit(AppEvents.alertError, ['Could not create silence']); + } + }, }), expireSilence: build.mutation< diff --git a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx index f4e5a04d034..a7fbb7507bb 100644 --- a/public/app/features/alerting/unified/components/AlertManagerPicker.tsx +++ b/public/app/features/alerting/unified/components/AlertManagerPicker.tsx @@ -9,25 +9,33 @@ import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/data interface Props { disabled?: boolean; + /** + * If true, only show alertmanagers that are receiving alerts from Grafana + */ + showOnlyReceivingGrafanaAlerts?: boolean; } function getAlertManagerLabel(alertManager: AlertManagerDataSource) { return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name.slice(0, 37); } -export const AlertManagerPicker = ({ disabled = false }: Props) => { +export const AlertManagerPicker = ({ disabled = false, showOnlyReceivingGrafanaAlerts }: Props) => { const styles = useStyles2(getStyles); - const { selectedAlertmanager, availableAlertManagers, setSelectedAlertmanager } = useAlertmanager(); const options: Array> = useMemo(() => { - return availableAlertManagers.map((ds) => ({ - label: getAlertManagerLabel(ds), - value: ds.name, - imgUrl: ds.imgUrl, - meta: ds.meta, - })); - }, [availableAlertManagers]); + return availableAlertManagers + .filter(({ name, handleGrafanaManagedAlerts }) => { + const isReceivingGrafanaAlerts = name === GRAFANA_RULES_SOURCE_NAME || handleGrafanaManagedAlerts; + return showOnlyReceivingGrafanaAlerts ? isReceivingGrafanaAlerts : true; + }) + .map((ds) => ({ + label: getAlertManagerLabel(ds), + value: ds.name, + imgUrl: ds.imgUrl, + meta: ds.meta, + })); + }, [availableAlertManagers, showOnlyReceivingGrafanaAlerts]); return ( void; handleDelete: (rule: CombinedRule) => void; handleDuplicateRule: (identifier: RuleIdentifier) => void; onPauseChange?: () => void; @@ -35,6 +36,7 @@ const AlertRuleMenu = ({ rule, identifier, showCopyLinkButton, + handleSilence, handleDelete, handleDuplicateRule, onPauseChange, @@ -77,13 +79,7 @@ const AlertRuleMenu = ({ const menuItems = ( <> {canPause && } - {canSilence && ( - - )} + {canSilence && } {shouldShowDeclareIncidentButton && } {canDuplicate && handleDuplicateRule(identifier)} />} {showDivider && } 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 3f660cc17a2..dd700747239 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 @@ -2,12 +2,17 @@ import React from 'react'; import { render, waitFor, screen, userEvent } from 'test/test-utils'; import { byText, byRole } from 'testing-library-selector'; -import { setBackendSrv, setPluginExtensionsHook } from '@grafana/runtime'; +import { setBackendSrv, setDataSourceSrv, setPluginExtensionsHook } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; +import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; +import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules'; +import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { + MockDataSourceSrv, getCloudRule, getGrafanaRule, grantUserPermissions, @@ -15,14 +20,12 @@ import { 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 { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import * as ruleId from '../../utils/rule-id'; import { AlertRuleProvider } from './RuleContext'; import RuleViewer from './RuleViewer'; -import { createMockGrafanaServer } from './__mocks__/server'; // metadata and interactive elements const ELEMENTS = { @@ -39,7 +42,7 @@ const ELEMENTS = { more: { button: byRole('button', { name: /More/i }), actions: { - silence: byRole('link', { name: /Silence/i }), + silence: byRole('menuitem', { name: /Silence/i }), duplicate: byRole('menuitem', { name: /Duplicate/i }), copyLink: byRole('menuitem', { name: /Copy link/i }), export: byRole('menuitem', { name: /Export/i }), @@ -54,10 +57,7 @@ const ELEMENTS = { }, }; -const { apiHandlers: pluginApiHandlers } = setupPlugins(plugins); - -const server = createMockGrafanaServer(...pluginApiHandlers); - +setupMswServer(); setupDataSources(mockDataSource({ type: DataSourceType.Prometheus, name: 'mimir-1' })); setPluginExtensionsHook(() => ({ extensions: [ @@ -82,14 +82,6 @@ beforeAll(() => { setBackendSrv(backendSrv); }); -beforeEach(() => { - server.listen(); -}); - -afterAll(() => { - server.close(); -}); - describe('RuleViewer', () => { describe('Grafana managed alert rule', () => { const mockRule = getGrafanaRule( @@ -116,6 +108,25 @@ describe('RuleViewer', () => { ); const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingInstanceCreate, + ]); + setBackendSrv(backendSrv); + + setFolderAccessControl({ + [AccessControlAction.AlertingRuleCreate]: true, + [AccessControlAction.AlertingRuleRead]: true, + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + [AccessControlAction.AlertingInstanceCreate]: true, + }); + }); + it('should render a Grafana managed alert rule', async () => { await renderRuleViewer(mockRule, mockRuleIdentifier); @@ -151,6 +162,35 @@ describe('RuleViewer', () => { expect(menuItem.get()).toBeInTheDocument(); } }); + + it('renders silencing form correctly and shows alert rule name', async () => { + const dataSources = { + grafana: mockDataSource({ + name: GRAFANA_RULES_SOURCE_NAME, + type: DataSourceType.Alertmanager, + jsonData: { + handleGrafanaManagedAlerts: true, + }, + }), + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + jsonData: { + handleGrafanaManagedAlerts: true, + }, + }), + }; + setupDataSources(dataSources.grafana, dataSources.am); + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + + await renderRuleViewer(mockRule, mockRuleIdentifier); + + const user = userEvent.setup(); + await user.click(ELEMENTS.actions.more.button.get()); + await user.click(ELEMENTS.actions.more.actions.silence.get()); + + expect(await screen.findByLabelText(/^alert rule/i)).toHaveValue(MOCK_GRAFANA_ALERT_RULE_TITLE); + }); }); describe('Data source managed alert rule', () => { diff --git a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx index b16add8fd93..d907fe019e8 100644 --- a/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx @@ -7,7 +7,9 @@ import { LinkButton, useStyles2, Stack } from '@grafana/ui'; import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal'; import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails'; +import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer'; import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules'; +import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { useDispatch } from 'app/types'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; @@ -44,6 +46,8 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton const style = useStyles2(getStyles); const [deleteModal, showDeleteModal] = useDeleteModal(); + const [showSilenceDrawer, setShowSilenceDrawer] = useState(false); + const [redirectToClone, setRedirectToClone] = useState< { identifier: RuleIdentifier; isProvisioned: boolean } | undefined >(undefined); @@ -119,6 +123,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton identifier={identifier} showCopyLinkButton={showCopyLinkButton} handleDelete={() => showDeleteModal(rule)} + handleSilence={() => setShowSilenceDrawer(true)} handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })} onPauseChange={() => { // Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect @@ -131,6 +136,11 @@ export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton }} /> {deleteModal} + {isGrafanaRulerRule(rule.rulerRule) && showSilenceDrawer && ( + + setShowSilenceDrawer(false)} /> + + )} {redirectToClone?.identifier && ( { +const MatchersField = ({ className, required, ruleUid }: Props) => { const styles = useStyles2(getStyles); const formApi = useFormContext(); const { @@ -24,11 +27,27 @@ const MatchersField = ({ className }: Props) => { const { fields: matchers = [], append, remove } = useFieldArray({ name: 'matchers' }); + const [getAlertRule, { data: alertRule }] = alertRuleApi.endpoints.getAlertRule.useLazyQuery(); + useEffect(() => { + // If we have a UID, fetch the alert rule details so we can display the rule name + if (ruleUid) { + getAlertRule({ uid: ruleUid }); + } + }, [getAlertRule, ruleUid]); + return ( -
- +
+
-
+
+ {alertRule && ( +
+ + + + +
+ )} {matchers.map((matcher, index) => { return (
@@ -39,13 +58,14 @@ const MatchersField = ({ className }: Props) => { > - + ( @@ -55,11 +75,12 @@ const MatchersField = ({ className }: Props) => { className={styles.matcherOptions} options={matcherFieldOptions} aria-label="operator" + id={`matcher-${index}-operator`} /> )} defaultValue={matcher.operator || matcherFieldOptions[0].value} - name={`matchers.${index}.operator` as const} - rules={{ required: { value: true, message: 'Required.' } }} + name={`matchers.${index}.operator`} + rules={{ required: { value: required, message: 'Required.' } }} /> { > - {matchers.length > 1 && ( + {(matchers.length > 1 || !required) && ( remove(index)} > @@ -90,6 +112,8 @@ const MatchersField = ({ className }: Props) => { })}
)} {!isLoading && } - + Cancel -
+ ); }; const getStyles = (theme: GrafanaTheme2) => ({ - field: css({ - margin: theme.spacing(1, 0), + formContainer: css({ + maxWidth: theme.breakpoints.values.md, }), - textArea: css({ - maxWidth: `${theme.breakpoints.values.sm}px`, + alertRule: css({ + paddingBottom: theme.spacing(2), }), - createdBy: css({ - width: '200px', - }), - flexRow: css({ + silencePeriod: css({ display: 'flex', flexDirection: 'row', justifyContent: 'flex-start', - - '& > *': { - marginRight: theme.spacing(1), - }, - }), - silencePeriod: css({ - maxWidth: `${theme.breakpoints.values.sm}px`, + gap: theme.spacing(1), + maxWidth: theme.breakpoints.values.sm, }), }); -export default SilencesEditor; +export default ExistingSilenceEditor; diff --git a/public/app/features/alerting/unified/components/silences/utils.ts b/public/app/features/alerting/unified/components/silences/utils.ts new file mode 100644 index 00000000000..fd111bbbec5 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/utils.ts @@ -0,0 +1,77 @@ +import { DefaultTimeZone, addDurationToDate, dateTime, intervalToAbbreviatedDurationString } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { SilenceFormFields } from 'app/features/alerting/unified/types/silence-form'; +import { matcherToMatcherField } from 'app/features/alerting/unified/utils/alertmanager'; +import { parseQueryParamMatchers } from 'app/features/alerting/unified/utils/matchers'; +import { MatcherOperator, Silence } from 'app/plugins/datasource/alertmanager/types'; + +/** + * Parse query params and return default silence form values + */ +export const defaultsFromQuery = (searchParams: URLSearchParams): Partial => { + const defaults: Partial = {}; + + const comment = searchParams.get('comment'); + const matchers = searchParams.getAll('matcher'); + + const formMatchers = parseQueryParamMatchers(matchers); + if (formMatchers.length) { + defaults.matchers = formMatchers.map(matcherToMatcherField); + } + + if (comment) { + defaults.comment = comment; + } + + return defaults; +}; + +/** + * + */ +export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields => { + const now = new Date(); + const isExpired = Date.parse(silence.endsAt) < Date.now(); + const interval = isExpired + ? { + start: now, + end: addDurationToDate(now, { hours: 2 }), + } + : { start: new Date(silence.startsAt), end: new Date(silence.endsAt) }; + return { + id: silence.id, + startsAt: interval.start.toISOString(), + endsAt: interval.end.toISOString(), + comment: silence.comment, + createdBy: silence.createdBy, + duration: intervalToAbbreviatedDurationString(interval), + isRegex: false, + matchers: silence.matchers?.map(matcherToMatcherField) || [], + matcherName: '', + matcherValue: '', + timeZone: DefaultTimeZone, + }; +}; + +/** + * Generate default silence form values + */ +export const getDefaultSilenceFormValues = (partial?: Partial): SilenceFormFields => { + const now = new Date(); + + const endsAt = addDurationToDate(now, { hours: 2 }); // Default time period is now + 2h + return { + id: '', + startsAt: now.toISOString(), + endsAt: endsAt.toISOString(), + comment: `created ${dateTime().format('YYYY-MM-DD HH:mm')}`, + createdBy: config.bootData.user.name, + duration: '2h', + isRegex: false, + matcherName: '', + matcherValue: '', + timeZone: DefaultTimeZone, + matchers: [{ name: '', value: '', operator: MatcherOperator.equal }], + ...partial, + }; +}; diff --git a/public/app/features/alerting/unified/hooks/useSilenceNavData.test.tsx b/public/app/features/alerting/unified/hooks/useSilenceNavData.test.tsx index f604ff0da09..090f4f5fb22 100644 --- a/public/app/features/alerting/unified/hooks/useSilenceNavData.test.tsx +++ b/public/app/features/alerting/unified/hooks/useSilenceNavData.test.tsx @@ -25,10 +25,8 @@ describe('useSilenceNavData', () => { (useRouteMatch as jest.Mock).mockReturnValue({ isExact: true, path: '/alerting/silence/new' }); const { result } = setup(); - expect(result).toEqual({ - icon: 'bell-slash', - id: 'silence-new', - text: 'Add silence', + expect(result).toMatchObject({ + text: 'Silence alert rule', }); }); @@ -36,9 +34,7 @@ describe('useSilenceNavData', () => { (useRouteMatch as jest.Mock).mockReturnValue({ isExact: true, path: '/alerting/silence/:id/edit' }); const { result } = setup(); - expect(result).toEqual({ - icon: 'bell-slash', - id: 'silence-edit', + expect(result).toMatchObject({ text: 'Edit silence', }); }); diff --git a/public/app/features/alerting/unified/hooks/useSilenceNavData.ts b/public/app/features/alerting/unified/hooks/useSilenceNavData.ts index a8fedc67dbf..241e54a4c98 100644 --- a/public/app/features/alerting/unified/hooks/useSilenceNavData.ts +++ b/public/app/features/alerting/unified/hooks/useSilenceNavData.ts @@ -9,20 +9,22 @@ const defaultPageNav: Partial = { export function useSilenceNavData() { const { isExact, path } = useRouteMatch(); - const [pageNav, setPageNav] = useState | undefined>(); + const [pageNav, setPageNav] = useState(); useEffect(() => { if (path === '/alerting/silence/new') { setPageNav({ ...defaultPageNav, id: 'silence-new', - text: 'Add silence', + text: 'Silence alert rule', + subTitle: 'Configure silences to stop notifications from a particular alert rule', }); } else if (path === '/alerting/silence/:id/edit') { setPageNav({ ...defaultPageNav, id: 'silence-edit', text: 'Edit silence', + subTitle: 'Recreate existing silence to stop notifications from a particular alert rule', }); } }, [path, isExact]); diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index b34222c09a2..f32e2b5aaa5 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -419,7 +419,9 @@ export function mockDashboardApi(server: SetupServer) { const server = setupServer(...allHandlers); -// Creates a MSW server and sets up beforeAll, afterAll and beforeEach handlers for it +/** + * Sets up beforeAll, afterAll and beforeEach handlers for mock server + */ export function setupMswServer() { beforeAll(() => { setBackendSrv(backendSrv); diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index 0ee78ae8e2e..908f4d77acc 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -2,6 +2,7 @@ * Contains all handlers that are required for test rendering of components within Alerting */ +import alertRuleHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertRules'; import alertmanagerHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; import datasourcesHandlers from 'app/features/alerting/unified/mocks/server/handlers/datasources'; import evalHandlers from 'app/features/alerting/unified/mocks/server/handlers/eval'; @@ -13,6 +14,7 @@ import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers * Array of all mock handlers that are required across Alerting tests */ const allHandlers = [ + ...alertRuleHandlers, ...alertmanagerHandlers, ...datasourcesHandlers, ...evalHandlers, diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts index f02bb547d47..2566a218e1e 100644 --- a/public/app/features/alerting/unified/mocks/server/configure.ts +++ b/public/app/features/alerting/unified/mocks/server/configure.ts @@ -1,6 +1,9 @@ import server from 'app/features/alerting/unified/mockApi'; +import { mockFolder } from 'app/features/alerting/unified/mocks'; import { grafanaAlertingConfigurationStatusHandler } from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; +import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; +import { FolderDTO } from 'app/types'; /** * Makes the mock server respond in a way that matches the different behaviour associated with @@ -13,3 +16,10 @@ export const setAlertmanagerChoices = (alertmanagersChoice: AlertmanagerChoice, }; server.use(grafanaAlertingConfigurationStatusHandler(response)); }; + +/** + * Makes the mock server respond with different folder access control settings + */ +export const setFolderAccessControl = (accessControl: FolderDTO['accessControl']) => { + server.use(getFolderHandler(mockFolder({ hasAcl: true, accessControl }))); +}; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts new file mode 100644 index 00000000000..970e0ff184f --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/alertRules.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from 'msw'; + +export const MOCK_GRAFANA_ALERT_RULE_TITLE = 'Test alert'; + +const alertRuleDetailsHandler = () => + http.get<{ folderUid: string }>(`/api/ruler/:ruler/api/v1/rule/:uid`, () => { + // TODO: Scaffold out alert rule response logic as this endpoint is used more in tests + return HttpResponse.json({ + grafana_alert: { + title: MOCK_GRAFANA_ALERT_RULE_TITLE, + }, + }); + }); + +const handlers = [alertRuleDetailsHandler()]; +export default handlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/folders.ts b/public/app/features/alerting/unified/mocks/server/handlers/folders.ts index f3f51f64e70..85654a1802d 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/folders.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/folders.ts @@ -3,7 +3,17 @@ import { HttpResponse, http } from 'msw'; import { mockFolder } from 'app/features/alerting/unified/mocks'; export const getFolderHandler = (response = mockFolder()) => - http.get(`/api/folders/:folderUid`, () => HttpResponse.json(response)); + http.get<{ folderUid: string }>(`/api/folders/:folderUid`, ({ request, params }) => { + const { accessControl, ...withoutAccessControl } = response; + + // Server only responds with ACL if query param is sent + const accessControlQueryParam = new URL(request.url).searchParams.get('accesscontrol'); + if (!accessControlQueryParam) { + return HttpResponse.json(withoutAccessControl); + } + + return HttpResponse.json(response); + }); const handlers = [getFolderHandler()]; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index b5a70ea71aa..f07cac89b0d 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -1,15 +1,31 @@ import { http, HttpResponse } from 'msw'; import { PluginMeta } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { plugins } from 'app/features/alerting/unified/testSetup/plugins'; -export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => - http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) => { +/** + * Returns a handler that maps from plugin ID to PluginMeta, and additionally sets up necessary + * config side effects that are expected to come along with this API behaviour + */ +export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { + plugins.forEach(({ id, baseUrl, info, angular }) => { + config.apps[id] = { + id, + path: baseUrl, + preload: true, + version: info.version, + angular: angular ?? { detected: false, hideDeprecation: false }, + }; + }); + + return http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) => { const matchingPlugin = pluginsArray.find((plugin) => plugin.id === pluginId); return matchingPlugin ? HttpResponse.json(matchingPlugin) : HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 }); }); +}; const handlers = [getPluginsHandler()]; export default handlers; diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index f56ecb62875..9ebd3133a0f 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -1,26 +1,7 @@ -import { RequestHandler } from 'msw'; - import { PluginMeta, PluginType } from '@grafana/data'; -import { config, setPluginExtensionsHook } from '@grafana/runtime'; +import { setPluginExtensionsHook } from '@grafana/runtime'; import { mockPluginLinkExtension } from '../mocks'; -import { getPluginsHandler } from '../mocks/server/handlers/plugins'; - -export function setupPlugins(plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } { - plugins.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: [getPluginsHandler(plugins)], - }; -} export function setupPluginsExtensionsHook() { setPluginExtensionsHook(() => ({ diff --git a/public/app/features/alerting/unified/utils/constants.ts b/public/app/features/alerting/unified/utils/constants.ts index b78593480d4..1e4fb11524c 100644 --- a/public/app/features/alerting/unified/utils/constants.ts +++ b/public/app/features/alerting/unified/utils/constants.ts @@ -44,3 +44,6 @@ export const defaultAnnotations = [ { key: Annotation.description, value: '' }, { key: Annotation.runbookURL, value: '' }, ]; + +/** Special matcher name used to identify alert rules by UID */ +export const MATCHER_ALERT_RULE_UID = '__alert_rule_uid__'; diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index d0ba8e153c3..350c39abf0c 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -118,16 +118,6 @@ export function wrapWithQuotes(input: string) { return alreadyWrapped ? escapeQuotes(input) : `"${escapeQuotes(input)}"`; } -export function makeRuleBasedSilenceLink(alertManagerSourceName: string, rule: CombinedRule) { - // we wrap the name of the alert with quotes since it might contain starting and trailing spaces - const labels: Labels = { - alertname: rule.name, - ...rule.labels, - }; - - return makeLabelBasedSilenceLink(alertManagerSourceName, labels); -} - export function makeLabelBasedSilenceLink(alertManagerSourceName: string, labels: Labels) { const silenceUrlParams = new URLSearchParams(); silenceUrlParams.append('alertmanager', alertManagerSourceName);