mirror of
https://github.com/grafana/grafana.git
synced 2025-01-09 07:33:42 -06:00
Alerting: Update silences creation to support __alert_rule_uid__
and move into drawer (#87320)
This commit is contained in:
parent
e7d5622969
commit
9e29c215c3
@ -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' }),
|
||||
|
@ -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} />
|
||||
)
|
||||
);
|
||||
}}
|
||||
|
@ -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`,
|
||||
|
@ -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<
|
||||
|
@ -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
|
||||
|
@ -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 />}
|
||||
|
@ -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', () => {
|
||||
|
@ -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}
|
||||
|
@ -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",
|
||||
]
|
||||
`;
|
||||
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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 ? (
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
@ -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]);
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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 })));
|
||||
};
|
||||
|
@ -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;
|
@ -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()];
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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(() => ({
|
||||
|
@ -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__';
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user