Compare commits

..

2 Commits

Author SHA1 Message Date
Jatinderjit Singh
58c2e8366e Add mute endpoint for alert rules
Expose POST /api/v2/rules/{id}/mute as a shortcut to create a
fixed-window planned maintenance scoped to a single rule. The created
PlannedMaintenance is returned so clients can unmute by deleting it via
the existing /api/v1/downtime_schedules/{id} endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:44:33 +05:30
Jatinderjit Singh
17f0c33fe0 feat(planned-downtime): explicit toggle for all vs specific alert rules
Replace the implicit "empty alert list silences everything" behavior
with a Radio toggle ("All alert rules" / "Specific alert rules") so
users can't accidentally silence every alert by forgetting to select
rules. The list view now displays an explicit "All alert rules" tag
instead of a dash for schedules that silence everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:44:33 +05:30
7 changed files with 216 additions and 49 deletions

View File

@@ -81,6 +81,20 @@
}
}
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
}
}
.alert-rule-all-warning {
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.formItemWithBullet {
margin-bottom: 0;
}

View File

@@ -8,6 +8,7 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
@@ -67,11 +68,14 @@ const TIME_FORMAT = DATE_TIME_FORMATS.TIME;
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATE;
const ORDINAL_FORMAT = DATE_TIME_FORMATS.ORDINAL_ONLY;
type AlertRuleScope = 'all' | 'specific';
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | string;
endTime: dayjs.Dayjs | string;
recurrence?: RuletypesRecurrenceDTO | null;
alertRuleScope: AlertRuleScope;
alertRules: DefaultOptionType[];
timezone?: string;
labelExpression?: string;
@@ -129,6 +133,12 @@ export function PlannedDowntimeForm(
recurrenceOptions.doesNotRepeat.value,
);
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
initialValues.id && (initialValues.alertIds || []).length === 0
? 'all'
: 'specific',
);
const timezoneInitialValue = !isEmpty(initialValues.schedule?.timezone)
? (initialValues.schedule?.timezone as string)
: undefined;
@@ -144,9 +154,12 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
alertIds:
values.alertRuleScope === 'all'
? []
: (values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[]),
name: values.name,
labelExpression: values.labelExpression,
schedule: {
@@ -262,12 +275,13 @@ export function PlannedDowntimeForm(
};
const formatedInitialValues = useMemo(() => {
const initialAlertIds = initialValues.alertIds || [];
const isExistingSchedule = Boolean(initialValues.id);
const formData: PlannedDowntimeFormData = {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
alertRuleScope:
isExistingSchedule && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
startTime: initialValues.schedule?.startTime
? dayjs(initialValues.schedule?.startTime)
@@ -291,6 +305,7 @@ export function PlannedDowntimeForm(
useEffect(() => {
setSelectedTags(formatedInitialValues.alertRules);
setAlertRuleScope(formatedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formatedInitialValues });
}, [form, formatedInitialValues, initialValues]);
@@ -410,6 +425,9 @@ export function PlannedDowntimeForm(
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setAlertRuleScope(
form.getFieldValue('alertRuleScope') as AlertRuleScope,
);
setFormData(form.getFieldsValue());
}}
autoComplete="off"
@@ -521,49 +539,82 @@ export function PlannedDowntimeForm(
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div>
<div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
(Leave empty to silence all alerts)
</Typography>
</div>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Form.Item
name="alertRuleScope"
initialValue="specific"
className="alert-rule-scope"
>
<Radio.Group>
<Radio value="all">All alert rules</Radio>
<Radio value="specific">Specific alert rules</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name={alertRuleFormName}>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
{alertRuleScope === 'specific' && (
<>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item
name={alertRuleFormName}
rules={[
{
validator: async (
_rule,
value: DefaultOptionType[] | undefined,
): Promise<void> => {
if (!value || value.length === 0) {
throw new Error(
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
);
}
},
},
]}
>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
{alertRuleScope === 'all' && (
<Typography
className="alert-rule-info alert-rule-all-warning"
style={{ marginBottom: 16 }}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
All alerts will be silenced for the duration of this maintenance window.
</Typography>
)}
</div>
<Form.Item
label={

View File

@@ -207,7 +207,7 @@ export function CollapseListContent({
selectedTags={alertOptions}
/>
) : (
'-'
<Tag className="all-alerts-tag">All alert rules</Tag>
),
)}
</Flex>

View File

@@ -116,6 +116,22 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/mute", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.MuteRule), handler.OpenAPIDef{
ID: "MuteRule",
Tags: []string{"rules"},
Summary: "Mute alert rule",
Description: "This endpoint mutes an alert rule until the provided endTime by creating a fixed-window planned maintenance scoped to that rule. Unmute by deleting the returned downtime schedule.",
Request: new(alertmanagertypes.PostableMuteRule),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},

View File

@@ -10,6 +10,7 @@ type Handler interface {
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)
TestRule(http.ResponseWriter, *http.Request)
MuteRule(http.ResponseWriter, *http.Request)
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)

View File

@@ -185,6 +185,53 @@ func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, ruletypes.GettableTestRule{AlertCount: alertCount, Message: "notification sent"})
}
// MuteRule mutes an alert rule until the provided endTime by creating a
// fixed-window planned maintenance scoped to that rule. The created
// PlannedMaintenance is returned so the client can later unmute by deleting
// it via DELETE /api/v1/downtime_schedules/{id}.
func (handler *handler) MuteRule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.GetRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
payload := new(ruletypes.PostableMuteRule)
if err := binding.JSON.BindBody(req.Body, payload); err != nil {
render.Error(rw, err)
return
}
now := time.Now()
if err := payload.Validate(now); err != nil {
render.Error(rw, err)
return
}
schedule := payload.ToPostablePlannedMaintenance(id.StringValue(), rule.AlertName, now)
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
created, err := handler.ruler.MaintenanceStore().CreatePlannedMaintenance(ctx, schedule)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, created)
}
func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -15,6 +15,44 @@ import (
)
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
var ErrCodeInvalidMutePayload = errors.MustNewCode("invalid_mute_payload")
// PostableMuteRule is the input payload for muting an alert rule. A mute is a
// shortcut for creating a fixed (non-recurring) planned maintenance scoped to a
// single rule, covering [now, EndTime].
type PostableMuteRule struct {
EndTime time.Time `json:"endTime" required:"true"`
Description string `json:"description"`
}
func (p *PostableMuteRule) Validate(now time.Time) error {
if p.EndTime.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidMutePayload, "missing endTime in the payload")
}
if !p.EndTime.After(now) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidMutePayload, "endTime must be in the future")
}
return nil
}
// ToPostablePlannedMaintenance builds the underlying planned maintenance
// payload for muting the given rule until p.EndTime.
func (p *PostableMuteRule) ToPostablePlannedMaintenance(ruleID string, ruleName string, now time.Time) *PostablePlannedMaintenance {
name := "Mute: " + ruleName
if ruleName == "" {
name = "Mute for rule " + ruleID
}
return &PostablePlannedMaintenance{
Name: name,
Description: p.Description,
Schedule: &Schedule{
Timezone: "UTC",
StartTime: now.UTC(),
EndTime: p.EndTime.UTC(),
},
AlertIds: []string{ruleID},
}
}
type MaintenanceStatus struct {
valuer.String