Alerting: Update silences creation to support __alert_rule_uid__ and move into drawer (#87320)

This commit is contained in:
Tom Ratcliffe 2024-05-16 09:34:07 +01:00 committed by GitHub
parent e7d5622969
commit 9e29c215c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 512 additions and 266 deletions

View File

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

View File

@ -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 (
<>
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager} />
<Switch>
<Route exact path="/alerting/silences">
<SilencesTable alertManagerSourceName={selectedAlertmanager} />
</Route>
<Route exact path="/alerting/silence/new">
<SilencesEditor alertManagerSourceName={selectedAlertmanager} />
{({ location }) => {
const queryParams = new URLSearchParams(location.search);
const formValues = getDefaultSilenceFormValues(defaultsFromQuery(queryParams));
return <SilencesEditor formValues={formValues} alertManagerSourceName={selectedAlertmanager} />;
}}
</Route>
<Route exact path="/alerting/silence/:id/edit">
{({ match }: RouteChildrenProps<{ id: string }>) => {
return (
match?.params.id && (
<SilencesEditor silenceId={match.params.id} alertManagerSourceName={selectedAlertmanager} />
<ExistingSilenceEditor silenceId={match.params.id} alertManagerSourceName={selectedAlertmanager} />
)
);
}}

View File

@ -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<RulerGrafanaRuleDTO, { uid: string }>({
// 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<string, ExportRulesParams>({
query: ({ format, folderUid, group, ruleUid }) => ({
url: `/api/ruler/grafana/api/v1/export/rules`,

View File

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

View File

@ -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<SelectableValue<string>> = 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 (
<InlineField

View File

@ -11,7 +11,7 @@ import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { createUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
@ -20,6 +20,7 @@ interface Props {
rule: CombinedRule;
identifier: RuleIdentifier;
showCopyLinkButton?: boolean;
handleSilence: () => 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 && <MenuItemPauseRule rule={rule} onPauseChange={onPauseChange} />}
{canSilence && (
<Menu.Item
label="Silence notifications"
icon="bell-slash"
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
/>
)}
{canSilence && <Menu.Item label="Silence notifications" icon="bell-slash" onClick={handleSilence} />}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
{showDivider && <Menu.Divider />}

View File

@ -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<AlertManagerDataSourceJsonData>({
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
jsonData: {
handleGrafanaManagedAlerts: true,
},
}),
am: mockDataSource<AlertManagerDataSourceJsonData>({
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', () => {

View File

@ -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<boolean>(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 && (
<AlertmanagerProvider accessType="instance">
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
</AlertmanagerProvider>
)}
{redirectToClone?.identifier && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}

View File

@ -12,11 +12,11 @@ exports[`RuleActionsButtons renders correct options for Cloud rule 1`] = `
exports[`RuleActionsButtons renders correct options for grafana managed rule 1`] = `
[
"Pause evaluation",
"Silence notifications",
"Duplicate",
"Copy link",
"Export",
"Delete",
"Silence notifications",
"Declare incident",
]
`;

View File

@ -1,9 +1,10 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import React, { useEffect } from 'react';
import { useFormContext, useFieldArray, Controller } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Field, Input, IconButton, useStyles2, Select } from '@grafana/ui';
import { Button, Field, Input, IconButton, useStyles2, Select, Divider } from '@grafana/ui';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { SilenceFormFields } from '../../types/silence-form';
@ -11,9 +12,11 @@ import { matcherFieldOptions } from '../../utils/alertmanager';
interface Props {
className?: string;
required: boolean;
ruleUid?: string;
}
const MatchersField = ({ className }: Props) => {
const MatchersField = ({ className, required, ruleUid }: Props) => {
const styles = useStyles2(getStyles);
const formApi = useFormContext<SilenceFormFields>();
const {
@ -24,11 +27,27 @@ const MatchersField = ({ className }: Props) => {
const { fields: matchers = [], append, remove } = useFieldArray<SilenceFormFields>({ 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 (
<div className={cx(className, styles.wrapper)}>
<Field label="Matching labels" required>
<div className={className}>
<Field label="Refine affected alerts" required={required}>
<div>
<div className={styles.matchers}>
<div className={cx(styles.matchers, styles.indent)}>
{alertRule && (
<div>
<Field label="Alert rule" disabled>
<Input id="alert-rule-name" defaultValue={alertRule.grafana_alert.title} disabled />
</Field>
<Divider />
</div>
)}
{matchers.map((matcher, index) => {
return (
<div className={styles.row} key={`${matcher.id}`} data-testid="matcher">
@ -39,13 +58,14 @@ const MatchersField = ({ className }: Props) => {
>
<Input
{...register(`matchers.${index}.name` as const, {
required: { value: true, message: 'Required.' },
required: { value: required, message: 'Required.' },
})}
defaultValue={matcher.name}
placeholder="label"
id={`matcher-${index}-label`}
/>
</Field>
<Field label={'Operator'}>
<Field label="Operator">
<Controller
control={control}
render={({ field: { onChange, ref, ...field } }) => (
@ -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.' } }}
/>
</Field>
<Field
@ -69,16 +90,17 @@ const MatchersField = ({ className }: Props) => {
>
<Input
{...register(`matchers.${index}.value` as const, {
required: { value: true, message: 'Required.' },
required: { value: required, message: 'Required.' },
})}
defaultValue={matcher.value}
placeholder="value"
id={`matcher-${index}-value`}
/>
</Field>
{matchers.length > 1 && (
{(matchers.length > 1 || !required) && (
<IconButton
aria-label="Remove matcher"
className={styles.removeButton}
tooltip="Remove matcher"
name="trash-alt"
onClick={() => remove(index)}
>
@ -90,6 +112,8 @@ const MatchersField = ({ className }: Props) => {
})}
</div>
<Button
className={styles.indent}
tooltip="Refine which alert instances are silenced by selecting label matchers"
type="button"
icon="plus"
variant="secondary"
@ -112,6 +136,7 @@ const getStyles = (theme: GrafanaTheme2) => {
marginTop: theme.spacing(2),
}),
row: css({
marginTop: theme.spacing(1),
display: 'flex',
alignItems: 'flex-start',
flexDirection: 'row',
@ -133,6 +158,9 @@ const getStyles = (theme: GrafanaTheme2) => {
margin: `${theme.spacing(1)} 0`,
paddingTop: theme.spacing(0.5),
}),
indent: css({
marginLeft: theme.spacing(2),
}),
};
};

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Divider, Drawer, Stack } from '@grafana/ui';
import { AlertManagerPicker } from 'app/features/alerting/unified/components/AlertManagerPicker';
import { GrafanaAlertmanagerDeliveryWarning } from 'app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning';
import { SilencesEditor } from 'app/features/alerting/unified/components/silences/SilencesEditor';
import { getDefaultSilenceFormValues } from 'app/features/alerting/unified/components/silences/utils';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
type Props = {
rulerRule: RulerGrafanaRuleDTO;
onClose: () => void;
};
/**
* For a given Grafana managed rule, renders a drawer containing silences editor and Alertmanager selection
*/
const SilenceGrafanaRuleDrawer = ({ rulerRule, onClose }: Props) => {
const { uid } = rulerRule.grafana_alert;
const formValues = getDefaultSilenceFormValues();
const { selectedAlertmanager } = useAlertmanager();
return (
<Drawer
title="Silence alert rule"
subtitle="Configure silences to stop notifications from a particular alert rule."
onClose={onClose}
size="md"
>
<Stack direction={'column'}>
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager!} />
<div>
<AlertManagerPicker showOnlyReceivingGrafanaAlerts />
<Divider />
</div>
<SilencesEditor
ruleUid={uid}
formValues={formValues}
alertManagerSourceName={selectedAlertmanager!}
onSilenceCreated={onClose}
onCancel={onClose}
/>
</Stack>
</Drawer>
);
};
export default SilenceGrafanaRuleDrawer;

View File

@ -1,9 +1,13 @@
import { css } from '@emotion/css';
import React from 'react';
import React, { useState } from 'react';
import { useDebounce, useDeepCompareEffect } from 'react-use';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { Alert, Badge, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types';
import { MatcherFieldValue } from 'app/features/alerting/unified/types/silence-form';
import { matcherFieldToMatcher } from 'app/features/alerting/unified/utils/alertmanager';
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
import { AlertmanagerAlert, Matcher, MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { isNullDate } from '../../utils/time';
@ -14,25 +18,49 @@ import { AmAlertStateTag } from './AmAlertStateTag';
interface Props {
amSourceName: string;
matchers: Matcher[];
matchers: MatcherFieldValue[];
ruleUid?: string;
}
export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => {
const { useGetAlertmanagerAlertsQuery } = alertmanagerApi;
/**
* Performs a deep equality check on the dependencies, and debounces the callback
*/
const useDebouncedDeepCompare = (cb: () => void, debounceMs: number, dependencies: unknown[]) => {
const [state, setState] = useState<unknown[]>();
useDebounce(cb, debounceMs, [state]);
useDeepCompareEffect(() => {
setState(dependencies);
}, [dependencies]);
};
export const SilencedInstancesPreview = ({ amSourceName, matchers: inputMatchers, ruleUid }: Props) => {
const matchers: Matcher[] = [
...(ruleUid ? [{ name: MATCHER_ALERT_RULE_UID, value: ruleUid, operator: MatcherOperator.equal }] : []),
...inputMatchers,
].map(matcherFieldToMatcher);
const useLazyQuery = alertmanagerApi.endpoints.getAlertmanagerAlerts.useLazyQuery;
const styles = useStyles2(getStyles);
const columns = useColumns();
// By default the form contains an empty matcher - with empty name and value and = operator
// We don't want to fetch previews for empty matchers as it results in all alerts returned
const hasValidMatchers = matchers.some((matcher) => matcher.value && matcher.name);
const hasValidMatchers = ruleUid || inputMatchers.some((matcher) => matcher.value && matcher.name);
const {
currentData: alerts = [],
isFetching,
isError,
} = useGetAlertmanagerAlertsQuery(
{ amSourceName, filter: { matchers } },
{ skip: !hasValidMatchers, refetchOnMountOrArgChange: true }
const [getAlertmanagerAlerts, { currentData: alerts = [], isFetching, isError }] = useLazyQuery();
// We need to deep compare the matchers, as otherwise the preview API call is triggered on every render
// of the component. This is because between react-hook-form's useFieldArray, and our parsing of the matchers,
// we end up otherwise triggering the call too frequently
useDebouncedDeepCompare(
() => {
if (hasValidMatchers) {
getAlertmanagerAlerts({ amSourceName, filter: { matchers } });
}
},
500,
[amSourceName, matchers]
);
const tableItemAlerts = alerts.map<DynamicTableItemProps<AlertmanagerAlert>>((alert) => ({
@ -43,7 +71,7 @@ export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => {
return (
<div>
<h4 className={styles.title}>
Silenced alert instances
Affected alert rule instances
{tableItemAlerts.length > 0 ? (
<Badge className={styles.badge} color="blue" text={tableItemAlerts.length} />
) : null}
@ -51,10 +79,10 @@ export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => {
{!hasValidMatchers && <span>Add a valid matcher to see affected alerts</span>}
{isError && (
<Alert title="Preview not available" severity="error">
Error occured when generating affected alerts preview. Are you matchers valid?
Error occured when generating preview of affected alerts. Are your matchers valid?
</Alert>
)}
{isFetching && <LoadingPlaceholder text="Loading..." />}
{isFetching && <LoadingPlaceholder text="Loading affected alert rule instances..." />}
{!isFetching && !isError && hasValidMatchers && (
<div className={styles.table}>
{tableItemAlerts.length > 0 ? (

View File

@ -1,13 +1,12 @@
import { css, cx } from '@emotion/css';
import { isEqual, pickBy } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { pickBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useDebounce } from 'react-use';
import {
addDurationToDate,
dateTime,
DefaultTimeZone,
GrafanaTheme2,
intervalToAbbreviatedDurationString,
isValidDate,
@ -22,109 +21,113 @@ import {
Input,
LinkButton,
LoadingPlaceholder,
Stack,
TextArea,
useStyles2,
} from '@grafana/ui';
import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi';
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { SilenceFormFields } from '../../types/silence-form';
import { matcherFieldToMatcher, matcherToMatcherField } from '../../utils/alertmanager';
import { parseQueryParamMatchers } from '../../utils/matchers';
import { matcherFieldToMatcher } from '../../utils/alertmanager';
import { makeAMLink } from '../../utils/misc';
import MatchersField from './MatchersField';
import { SilencePeriod } from './SilencePeriod';
import { SilencedInstancesPreview } from './SilencedInstancesPreview';
import { getDefaultSilenceFormValues, getFormFieldsForSilence } from './utils';
interface Props {
silenceId?: string;
silenceId: string;
alertManagerSourceName: string;
}
const defaultsFromQuery = (searchParams: URLSearchParams): Partial<SilenceFormFields> => {
const defaults: Partial<SilenceFormFields> = {};
/**
* Silences editor for editing an existing silence.
*
* Fetches silence details from API, based on `silenceId`
*/
export const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const {
data: silence,
isLoading: getSilenceIsLoading,
error: errorGettingExistingSilence,
} = alertSilencesApi.endpoints.getSilence.useQuery({
id: silenceId,
datasourceUid: getDatasourceAPIUid(alertManagerSourceName),
});
const comment = searchParams.get('comment');
const matchers = searchParams.getAll('matcher');
const ruleUid = silence?.matchers?.find((m) => m.name === MATCHER_ALERT_RULE_UID)?.value;
const formMatchers = parseQueryParamMatchers(matchers);
if (formMatchers.length) {
defaults.matchers = formMatchers.map(matcherToMatcherField);
const defaultValues = useMemo(() => {
if (!silence) {
return;
}
const filteredMatchers = silence.matchers?.filter((m) => m.name !== MATCHER_ALERT_RULE_UID);
return getFormFieldsForSilence({ ...silence, matchers: filteredMatchers });
}, [silence]);
if (silenceId && getSilenceIsLoading) {
return <LoadingPlaceholder text="Loading existing silence information..." />;
}
if (comment) {
defaults.comment = comment;
const existingSilenceNotFound =
isFetchError(errorGettingExistingSilence) && errorGettingExistingSilence.status === 404;
if (existingSilenceNotFound) {
return <Alert title={`Existing silence "${silenceId}" not found`} severity="warning" />;
}
return defaults;
};
const getDefaultFormValues = (searchParams: URLSearchParams, silence?: Silence): SilenceFormFields => {
const now = new Date();
if (silence) {
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,
};
} else {
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,
matchers: [{ name: '', value: '', operator: MatcherOperator.equal }],
matcherName: '',
matcherValue: '',
timeZone: DefaultTimeZone,
...defaultsFromQuery(searchParams),
};
}
};
export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) => {
// Use a lazy query to fetch the Silence info, as we may not always require this
// (e.g. if creating a new one from scratch, we don't need to fetch anything)
const [getSilence, { data: silence, isLoading: getSilenceIsLoading, error: errorGettingExistingSilence }] =
alertSilencesApi.endpoints.getSilence.useLazyQuery();
const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation();
const [urlSearchParams] = useURLSearchParams();
const defaultValues = useMemo(() => getDefaultFormValues(urlSearchParams, silence), [silence, urlSearchParams]);
const formAPI = useForm({ defaultValues });
const styles = useStyles2(getStyles);
const [matchersForPreview, setMatchersForPreview] = useState<Matcher[]>(
defaultValues.matchers.map(matcherFieldToMatcher)
return (
<SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} />
);
};
const { register, handleSubmit, formState, watch, setValue, clearErrors, reset } = formAPI;
type SilencesEditorProps = {
formValues?: SilenceFormFields;
alertManagerSourceName: string;
onSilenceCreated?: (response: SilenceCreatedResponse) => void;
onCancel?: () => void;
ruleUid?: string;
};
/**
* Base silences editor used for new silences (from both the list view and the drawer),
* and for editing existing silences
*/
export const SilencesEditor = ({
formValues = getDefaultSilenceFormValues(),
alertManagerSourceName,
onSilenceCreated,
onCancel,
ruleUid,
}: SilencesEditorProps) => {
const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation();
const formAPI = useForm({ defaultValues: formValues });
const styles = useStyles2(getStyles);
const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI;
const [duration, startsAt, endsAt, matchers] = watch(['duration', 'startsAt', 'endsAt', 'matchers']);
/** Default action taken after creation or cancellation, if corresponding method is not defined */
const defaultHandler = () => {
locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName));
};
const onSilenceCreatedHandler = onSilenceCreated || defaultHandler;
const onCancelHandler = onCancel || defaultHandler;
const onSubmit = async (data: SilenceFormFields) => {
const { id, startsAt, endsAt, comment, createdBy, matchers: matchersFields } = data;
const matchers = matchersFields.map(matcherFieldToMatcher);
if (ruleUid) {
matchersFields.push({ name: MATCHER_ALERT_RULE_UID, value: ruleUid, operator: MatcherOperator.equal });
}
const matchersToSend = matchersFields.map(matcherFieldToMatcher).filter((field) => field.name && field.value);
const payload = pickBy(
{
id,
@ -132,35 +135,17 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
endsAt,
comment,
createdBy,
matchers,
matchers: matchersToSend,
},
(value) => !!value
) as SilenceCreatePayload;
await createSilence({ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), payload })
.unwrap()
.then(() => {
locationService.push(makeAMLink('/alerting/silences', alertManagerSourceName));
.then((newSilenceResponse) => {
onSilenceCreatedHandler?.(newSilenceResponse);
});
};
const duration = watch('duration');
const startsAt = watch('startsAt');
const endsAt = watch('endsAt');
const matcherFields = watch('matchers');
useEffect(() => {
if (silence) {
// Allows the form to correctly initialise when an existing silence is fetch from the backend
reset(getDefaultFormValues(urlSearchParams, silence));
}
}, [reset, silence, urlSearchParams]);
useEffect(() => {
if (silenceId) {
getSilence({ id: silenceId, datasourceUid: getDatasourceAPIUid(alertManagerSourceName) });
}
}, [alertManagerSourceName, getSilence, silenceId]);
// Keep duration and endsAt in sync
const [prevDuration, setPrevDuration] = useState(duration);
useDebounce(
@ -186,37 +171,13 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
700,
[clearErrors, duration, endsAt, prevDuration, setValue, startsAt]
);
useDebounce(
() => {
// React-hook-form watch does not return referentialy equal values so this trick is needed
const newMatchers = matcherFields.filter((m) => m.name && m.value).map(matcherFieldToMatcher);
if (!isEqual(matchersForPreview, newMatchers)) {
setMatchersForPreview(newMatchers);
}
},
700,
[matcherFields]
);
const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name);
if (getSilenceIsLoading) {
return <LoadingPlaceholder text="Loading existing silence information..." />;
}
const existingSilenceNotFound =
isFetchError(errorGettingExistingSilence) && errorGettingExistingSilence.status === 404;
if (existingSilenceNotFound) {
return <Alert title={`Existing silence "${silenceId}" not found`} severity="warning" />;
}
return (
<FormProvider {...formAPI}>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldSet>
<div className={cx(styles.flexRow, styles.silencePeriod)}>
<FieldSet className={styles.formContainer}>
<div className={styles.silencePeriod}>
<SilencePeriod />
<Field
label="Duration"
@ -227,7 +188,6 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
}
>
<Input
className={styles.createdBy}
{...register('duration', {
validate: (value) =>
Object.keys(parseDuration(value)).length === 0
@ -239,9 +199,9 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
</Field>
</div>
<MatchersField />
<MatchersField required={Boolean(!ruleUid)} ruleUid={ruleUid} />
<Field
className={cx(styles.field, styles.textArea)}
label="Comment"
required
error={formState.errors.comment?.message}
@ -251,11 +211,11 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
{...register('comment', { required: { value: true, message: 'Required.' } })}
rows={5}
placeholder="Details about the silence"
id="comment"
/>
</Field>
{!userLogged && (
<Field
className={cx(styles.field, styles.createdBy)}
label="Created By"
required
error={formState.errors.createdBy?.message}
@ -267,46 +227,38 @@ export const SilencesEditor = ({ silenceId, alertManagerSourceName }: Props) =>
/>
</Field>
)}
<SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchersForPreview} />
<SilencedInstancesPreview amSourceName={alertManagerSourceName} matchers={matchers} ruleUid={ruleUid} />
</FieldSet>
<div className={styles.flexRow}>
<Stack gap={1}>
{isLoading && (
<Button disabled={true} icon="spinner" variant="primary">
Saving...
</Button>
)}
{!isLoading && <Button type="submit">Save silence</Button>}
<LinkButton href={makeAMLink('alerting/silences', alertManagerSourceName)} variant={'secondary'}>
<LinkButton onClick={onCancelHandler} variant={'secondary'}>
Cancel
</LinkButton>
</div>
</Stack>
</form>
</FormProvider>
);
};
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;

View File

@ -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<SilenceFormFields> => {
const defaults: Partial<SilenceFormFields> = {};
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>): 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,
};
};

View File

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

View File

@ -9,20 +9,22 @@ const defaultPageNav: Partial<NavModelItem> = {
export function useSilenceNavData() {
const { isExact, path } = useRouteMatch();
const [pageNav, setPageNav] = useState<Pick<NavModelItem, 'id' | 'text' | 'icon'> | undefined>();
const [pageNav, setPageNav] = useState<NavModelItem | undefined>();
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]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PluginMeta>(matchingPlugin)
: HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 });
});
};
const handlers = [getPluginsHandler()];
export default handlers;

View File

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

View File

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

View File

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