Compare commits

...

33 Commits

Author SHA1 Message Date
Jatinderjit Singh
4da4569953 Merge branch 'main' into mute-rules 2026-05-29 06:53:51 +05:30
Jatinderjit Singh
9458928efc fix(alerts): give mute drawer date picker a solid popup background
The DatePicker popup is portaled to <body>, so without a panel-container
background the time-column cells were transparent and the drawer content
bled through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 06:49:35 +05:30
Jatinderjit Singh
f713254f59 fix: link to planned downtime page 2026-05-29 06:40:06 +05:30
Jatinderjit Singh
eb5f0e946a fix(alerts): format mute times in selected timezone
The backend ignores the offset in the formatted startTime/endTime and
re-attaches the input timezone to the raw wall-clock time. So the
formatted offset must match the timezone field, otherwise the muted
window shifts. Mirror PlannedDowntimeForm by formatting in the selected
timezone: the quick-duration popover formats in the guessed tz, and the
custom-window drawer reinterprets the picked time via .tz(tz, true).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 06:29:05 +05:30
Jatinderjit Singh
fdb48834da fix: return correct upcoming status for recurring maintenances 2026-05-29 06:16:23 +05:30
Jatinderjit Singh
e46d26ff78 refactor: drop status filter, keep status badges 2026-05-29 05:56:41 +05:30
Jatinderjit Singh
503f5c3e7b feat(planned-downtime): add status filter and badge
Planned downtimes were rendered as a flat list with no visual cue for
which were active, upcoming, or expired. Add an "Active & Upcoming /
Expired / All" filter (defaulting to Active & Upcoming so expired noise
is hidden) and a status badge on each row. Sort by status (active →
upcoming → expired) then by most recently updated within each group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 05:42:07 +05:30
Jatinderjit Singh
de2a730557 refactor(alerts): promote Planned Downtime and Routing Policies to top-level tabs
Replace the nested Configuration > {Planned Downtime, Routing Policies}
sub-tab structure with four flat top-level tabs on /alerts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 05:23:48 +05:30
Jatinderjit Singh
79deebe976 fix(planned-downtime): cascade delete associated rules
Deleting a planned maintenance previously failed with a foreign key
error when alert rules were associated with it, forcing users to first
detach every rule. Wrap the delete in a transaction that first removes
rows from planned_maintenance_rule before deleting the maintenance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 05:11:21 +05:30
Jatinderjit Singh
372cdc89e1 refactor(planned-maintenance): simplify IsActive and IsUpcoming 2026-05-29 04:59:59 +05:30
Jatinderjit Singh
bb5e68eef6 feat(planned-maintenance): support indefinite ("forever") fixed downtimes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 04:59:07 +05:30
Jatinderjit Singh
a2f4ce934d Merge branch 'main' into mute-rules 2026-05-27 17:39:00 +05:30
Jatinderjit Singh
c2419a8687 fix: refresh disabled-since time in DisabledBanner every 60s 2026-05-27 17:29:11 +05:30
Jatinderjit Singh
7c214be7ad fix: hide muted banner when mute expires 2026-05-27 17:15:35 +05:30
Jatinderjit Singh
27a2a0cf21 refactor: return all active mutes 2026-05-27 16:33:16 +05:30
Jatinderjit Singh
6ec3c0b8e0 fix: handle badge and banner for indefinite mutes 2026-05-27 11:29:34 +05:30
Jatinderjit Singh
fef84df331 feat(alerts): return activeMute in rule API responses
Backend computes the active mute window per rule (joining with planned
maintenance schedules) in ListRules and GetRuleByID, so the frontend no
longer needs a separate downtime-schedules fetch to determine mute state.

- Add ActiveMuteInfo struct to Rule (id, name, description,
  effectiveStartTime, effectiveEndTime); computed by findActiveMuteForRule
- Handlers for ListRules/GetRuleByID now fetch MaintenanceStore schedules
  and pass them to NewRule
- Regenerate OpenAPI spec and frontend types (RuletypesActiveMuteInfoDTO)
- useActiveMute: drop useListDowntimeSchedules, read activeMute from
  useGetRuleByID (cache-hit on detail page; one request on list page)
- useMuteAlertRule: also invalidate rule queries after muting so activeMute
  refreshes without a separate schedules refetch
- ListAlert: remove useListDowntimeSchedules + findActiveMuteForRule,
  read record.activeMute.effectiveEndTime directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:56:47 +05:30
Jatinderjit Singh
98813660ed fix(alerts): send endTime as null for "Forever" mute
Previously, picking Forever set endTime to now + 10 years so the
schedule had a real (very distant) end. Send null instead so the
backend treats the mute as truly indefinite. The generated schema
narrows endTime to string, but the API accepts null — cast at the
call site with a comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
cd37fbfa71 fix(alerts): drop focus after closing mute popover via Esc / outside click
Pressing Escape promotes the most recent input to 'keyboard', so the
trigger button (Mute) showed a :focus-visible outline once the popover
closed. Blur the active element when closing via keyboard or outside
click so no leftover focus ring lingers. Tab-driven focus still shows
the indicator as expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
fca2da6b15 fix(alerts): close mute popover on outside click and Escape
Since the Popover uses trigger={[]} (controlled-only), antd no longer
attaches its own outside-click / Escape handlers. Add document-level
mousedown and keydown listeners while the popover is open, deferring
attachment by one tick so the click that opened it isn't counted as
an outside click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
4a2907ad35 fix(alerts): show firing state + muted badge separately in rule list
Match the original handoff: Status column keeps showing the actual
rule state (Firing/OK/Pending/Disabled), and a separate inline
'MUTED · <countdown>' badge renders next to the rule name when an
active mute exists. Both signals stay visible.

- Revert Status.tsx to its original simple form (no muteEndTime).
- Extract the muted badge into MutedBadge.tsx and render it inline
  in the Alert Name column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
6b04e17f64 fix(alerts): remove 'Muted/Disabled by <user>' from banners
Drop the createdBy/updatedBy attribution from both the muted and
disabled banners. MutedBanner keeps name + manage link;
DisabledBanner keeps the relative time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
95864ebe58 fix(alerts): disable Mute pill when alert rule is disabled
Muting a disabled rule wouldn't change observable behavior — fires
aren't recorded, so there's nothing to suppress. Disabling the Mute
pill also brings cursor + hover affordances in line with the
non-interactive Active pill while muted: cursor: not-allowed and no
hover background (the SCSS already guards hover with :not(:disabled)).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
a0b9713a4f feat(alerts): add hover background to segmented pills
Surface clickable regions more clearly:
- Inactive pill hover: --bg-ink-200 background (dark) / --bg-vanilla-300
  (light mode).
- Active pill hovers darken slightly to their next shade — robin-600,
  amber-600, slate-200 — for tactile feedback without losing the
  state color.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
bded46ce95 fix(alerts): wrap segmented pill text in span for reliable color
The previous attempt set color on the .pill--active-muted block but
something in the cascade was leaving the bare text node light while
the icon (which had its own explicit rule) went dark. Wrap each pill's
label in <span class="alert-state-segmented__label"> and apply the
muted color to both the icon and label classes — both children now
have explicit color rules that don't depend on inheritance from the
button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
b38d2963b5 fix(alerts): make muted segmented pill text legible
The base .alert-state-segmented__pill rule was winning over
.alert-state-segmented__pill--active-muted in the cascade for some
build configurations, leaving the "Mute" label rendered in
var(--bg-vanilla-400) (light gray) on amber. Bump specificity by
self-chaining the class (&.alert-state-segmented__pill--active-muted →
.pill.pill--active-muted) so the dark text color always wins, and
apply the color to the icon explicitly so it tracks the label.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
406800a2e5 Revert "fix(alerts): move toggle toast to per-call mutate callbacks"
This reverts commit 7dacb99536ca386a9b74d101f2f84fdd39ce6864.
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
b10f45a59a Revert "fix(alerts): update rule cache directly instead of refetching"
This reverts commit adc9e0ff1162d7dcc1f82ccfd54ae3b1c428cc46.
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
f75d166eb6 fix(alerts): update rule cache directly instead of refetching
invalidateGetRuleByID + refetchQueries caused EditRules to render its
<Spinner /> (isRefetching=true), which unmounted the whole form
subtree. The unmount cascade plus follow-up query resolutions (channels,
event, fields/keys) caused the success toast to briefly disappear and
re-enter from its animation again, visible as a flicker.

Patching the rule already returns the updated rule, so write it into
the query cache via setQueryData. The form sees the new state without
remounting, and the toast stays put.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
81f7694991 fix(alerts): move toggle toast to per-call mutate callbacks
Hook-level onSuccess on useMutation can re-fire when the
MutationObserver re-subscribes during context-driven re-renders (each
context update from setAlertRuleState re-renders all consumers of
useAlertRule, including ActionButtons, which re-instantiates the
mutation observer). Per-call callbacks passed to mutate() in
react-query v3 are guaranteed to run exactly once per mutate() call.

This was visible as the success toast appearing once per follow-up API
response after enable/disable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
3858e70a4c fix(alerts): detach mute popover from segmented control anchor
Popover's Trigger wrapper could intercept clicks on the inner buttons,
causing the disable/enable toggle to fire twice (visible as a flickering
success toast). Render the segmented control standalone and anchor the
Popover to a separate invisible span positioned at the bottom-right of
the wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:03 +05:30
Jatinderjit Singh
9b87348839 fix(alerts): mute popover only opens via Mute pill, not Active/Disable
The Popover wrapper used trigger="click", which fired on any click in
the anchor (including Active and Disable pills). Switch to trigger={[]}
so the popover is controlled exclusively by the Mute handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:02 +05:30
Jatinderjit Singh
fda747f81e feat(alerts): add mute action wrapping planned downtime
Adds a Mute action on the alert rule page that creates a Planned
Downtime entry scoped to the rule, with a quick-duration popover and
full scheduler drawer.

- AlertStateSegmented control replaces the Active/Disabled switch with a
  three-state pill (Active / Mute / Disable). Active is disabled while
  muted; un-mute happens via the Planned Downtimes page.
- MutePopover offers quick durations (15m, 1h, 4h, 1d, 1w, Forever) plus
  a Name field.
- MuteSchedulerDrawer exposes the full Planned Downtime form for custom
  windows and recurrence.
- MutedBanner / DisabledBanner render an informative banner under the
  header for the corresponding states.
- Alert rules list shows a muted badge with countdown for each rule that
  has an active downtime.
- Lookup is frontend-only via listDowntimeSchedules + ruleId filter,
  with no backend changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:51:02 +05:30
32 changed files with 2000 additions and 260 deletions

View File

@@ -5945,6 +5945,22 @@ components:
- start
- end
type: object
RuletypesActiveMute:
properties:
description:
type: string
end:
format: date-time
nullable: true
type: string
id:
type: string
name:
type: string
start:
format: date-time
type: string
type: object
RuletypesAlertCompositeQuery:
properties:
panelType:
@@ -6226,6 +6242,11 @@ components:
additionalProperties:
type: string
type: object
mutes:
items:
$ref: '#/components/schemas/RuletypesActiveMute'
nullable: true
type: array
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:

View File

@@ -7051,6 +7051,31 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
state: RuletypesAlertStateDTO;
}
export interface RuletypesActiveMuteDTO {
/**
* @type string
*/
description?: string;
/**
* @type string,null
* @format date-time
*/
end?: string | null;
/**
* @type string
*/
id?: string;
/**
* @type string
*/
name?: string;
/**
* @type string
* @format date-time
*/
start?: string;
}
export enum RuletypesPanelTypeDTO {
value = 'value',
table = 'table',
@@ -7419,6 +7444,10 @@ export interface RuletypesRuleDTO {
* @type object
*/
labels?: RuletypesRuleDTOLabels;
/**
* @type array,null
*/
mutes?: RuletypesActiveMuteDTO[] | null;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array

View File

@@ -14,7 +14,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -175,11 +175,21 @@ function CreateRules(): JSX.Element {
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Configuration
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.CONFIGURATION,
key: AlertListTabs.PLANNED_DOWNTIME,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
children: null,
},
];

View File

@@ -4,5 +4,4 @@ export const THRESHOLD_TAB_TOOLTIP =
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';
export const ROUTING_POLICIES_ROUTE =
'/alerts?tab=Configuration&subTab=routing-policies';
export const ROUTING_POLICIES_ROUTE = '/alerts?tab=RoutingPolicies';

View File

@@ -43,6 +43,7 @@ import { isModifierKeyPressed } from 'utils/app';
import DeleteAlert from './DeleteAlert';
import { ColumnButton, SearchContainer } from './styles';
import MutedBadge from './TableComponents/MutedBadge';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
@@ -276,7 +277,14 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
};
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
const isMuted = Boolean(record.mutes?.length);
return (
<span className="alert-list-name-cell">
<Typography.Link onClick={onClickHandler}>{value}</Typography.Link>
{isMuted && <MutedBadge muteEndTime={record.mutes![0].end} />}
</span>
);
},
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
},

View File

@@ -0,0 +1,20 @@
.alert-list-name-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.alert-list-muted-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--bg-amber-500);
background: rgba(255, 205, 86, 0.12);
border: 1px solid rgba(255, 205, 86, 0.25);
border-radius: 4px;
}

View File

@@ -0,0 +1,43 @@
import { BellOff } from '@signozhq/icons';
import dayjs from 'dayjs';
import './MutedBadge.styles.scss';
const formatRemaining = (endTime: string | undefined | null): string | null => {
if (!endTime) {
return null;
}
const end = dayjs(endTime);
const now = dayjs();
const diffMs = end.diff(now);
if (diffMs <= 0) {
return null;
}
const totalMinutes = Math.floor(diffMs / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
interface MutedBadgeProps {
muteEndTime: string | undefined | null;
}
function MutedBadge({ muteEndTime }: MutedBadgeProps): JSX.Element | null {
const remaining = formatRemaining(muteEndTime);
return (
<span className="alert-list-muted-badge">
<BellOff size={10} />
<span>MUTED{remaining ? ` · ${remaining}` : ''}</span>
</span>
);
}
export default MutedBadge;

View File

@@ -445,12 +445,6 @@ export function PlannedDowntimeForm(
<Form.Item
label="Ends on"
name="endTime"
required={recurrenceType === recurrenceOptions.doesNotRepeat.value}
rules={[
{
required: recurrenceType === recurrenceOptions.doesNotRepeat.value,
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
>
<DatePicker

View File

@@ -1,15 +1,16 @@
import React, { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
import {
AlertmanagertypesMaintenanceStatusDTO,
type ListDowntimeSchedules200,
type RenderErrorResponseDTO,
type AlertmanagertypesPlannedMaintenanceDTO,
type AlertmanagertypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import cx from 'classnames';
@@ -32,6 +33,50 @@ import './PlannedDowntime.styles.scss';
const { Panel } = Collapse;
const STATUS_BADGE_PROPS: Record<
AlertmanagertypesMaintenanceStatusDTO,
{ color: BadgeColor; label: string }
> = {
[AlertmanagertypesMaintenanceStatusDTO.active]: {
color: 'forest',
label: 'Active',
},
[AlertmanagertypesMaintenanceStatusDTO.upcoming]: {
color: 'robin',
label: 'Upcoming',
},
[AlertmanagertypesMaintenanceStatusDTO.expired]: {
color: 'vanilla',
label: 'Expired',
},
};
const STATUS_SORT_ORDER: Record<AlertmanagertypesMaintenanceStatusDTO, number> =
{
[AlertmanagertypesMaintenanceStatusDTO.active]: 0,
[AlertmanagertypesMaintenanceStatusDTO.upcoming]: 1,
[AlertmanagertypesMaintenanceStatusDTO.expired]: 2,
};
function StatusBadge({
status,
}: {
status?: AlertmanagertypesMaintenanceStatusDTO;
}): JSX.Element | null {
if (!status) {
return null;
}
const props = STATUS_BADGE_PROPS[status];
if (!props) {
return null;
}
return (
<Badge color={props.color} variant="outline">
{props.label}
</Badge>
);
}
interface AlertRuleTagsProps {
selectedTags: DefaultOptionType | DefaultOptionType[];
closable: boolean;
@@ -83,11 +128,13 @@ export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
function HeaderComponent({
name,
duration,
status,
handleEdit,
handleDelete,
}: {
name: string;
duration: string;
status?: AlertmanagertypesMaintenanceStatusDTO;
handleEdit: () => void;
handleDelete: () => void;
}): JSX.Element {
@@ -95,9 +142,10 @@ function HeaderComponent({
const isCrudEnabled = user?.role !== USER_ROLES.VIEWER;
return (
<Flex className="header-content" justify="space-between">
<Flex gap={8}>
<Flex gap={8} align="center">
<Typography>{name}</Typography>
<Badge color="vanilla">{duration}</Badge>
<StatusBadge status={status} />
</Flex>
{isCrudEnabled && (
@@ -184,7 +232,9 @@ export function CollapseListContent({
{renderItems(
'Timeframe',
schedule?.startTime ? (
<Typography>{`${startTime}${endTime}`}</Typography>
<Typography>
{schedule?.endTime ? `${startTime}${endTime}` : `${startTime} onwards`}
</Typography>
) : (
'-'
),
@@ -229,6 +279,7 @@ export function CustomCollapseList(
createdAt,
createdBy,
schedule,
status,
updatedAt,
updatedBy,
name,
@@ -257,6 +308,7 @@ export function CustomCollapseList(
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
}
name={defaultTo(name, '')}
status={status}
handleEdit={() => {
setInitialValues({ ...props });
setModalOpen(true);
@@ -330,6 +382,11 @@ export function PlannedDowntimeList({
const tableData = [...(downtimeSchedules.data?.data || [])]
.sort((a, b): number => {
const statusDiff =
(STATUS_SORT_ORDER[a.status] ?? 99) - (STATUS_SORT_ORDER[b.status] ?? 99);
if (statusDiff !== 0) {
return statusDiff;
}
if (a?.updatedAt && b?.updatedAt) {
return dayjs(b.updatedAt).diff(dayjs(a.updatedAt));
}

View File

@@ -13,6 +13,20 @@
align-items: center;
}
}
.alert-state-segmented-wrapper {
position: relative;
display: inline-flex;
}
.alert-state-segmented-anchor {
position: absolute;
right: 0;
bottom: 0;
width: 0;
height: 0;
pointer-events: none;
}
.dropdown-menu {
border-radius: 4px;
box-shadow: none;

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Tooltip } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Divider } from '@signozhq/ui/divider';
import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
@@ -18,6 +17,13 @@ import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
import { AlertHeaderProps } from '../AlertHeader';
import AlertStateSegmented, {
AlertSegmentedState,
} from '../MuteAlert/AlertStateSegmented';
import MutePopover from '../MuteAlert/MutePopover';
import MuteSchedulerDrawer from '../MuteAlert/MuteSchedulerDrawer';
import { useActiveMutes } from '../MuteAlert/useActiveMutes';
import { useMuteAlertRule } from '../MuteAlert/useMuteAlertRule';
import RenameModal from './RenameModal';
import './ActionButtons.styles.scss';
@@ -107,19 +113,77 @@ function AlertActionButtons({
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => (): void => setAlertRuleState(undefined), []);
const toggleAlertRule = useCallback(() => {
setIsAlertRuleDisabled((prev) => !prev);
handleAlertStateToggle();
}, [handleAlertStateToggle]);
const { activeMutes, refetch: refetchActiveMute } = useActiveMutes(ruleId);
const segmentedState: AlertSegmentedState = useMemo(() => {
if (isAlertRuleDisabled) {
return 'disabled';
}
if (activeMutes.length) {
return 'muted';
}
return 'active';
}, [isAlertRuleDisabled, activeMutes]);
const [isMutePopoverOpen, setIsMutePopoverOpen] = useState<boolean>(false);
const [isMuteDrawerOpen, setIsMuteDrawerOpen] = useState<boolean>(false);
const { mute, isLoading: isMuting } = useMuteAlertRule({
ruleId,
onSuccess: () => {
setIsMutePopoverOpen(false);
setIsMuteDrawerOpen(false);
refetchActiveMute();
},
});
const handleActiveClick = useCallback(() => {
// If currently disabled, re-enable. Otherwise (already active) no-op.
// When muted, the segmented control disables this button.
if (isAlertRuleDisabled) {
setIsAlertRuleDisabled(false);
handleAlertStateToggle();
}
}, [isAlertRuleDisabled, handleAlertStateToggle]);
const handleMuteClick = useCallback(() => {
if (segmentedState === 'active') {
setIsMutePopoverOpen(true);
}
}, [segmentedState]);
const handleDisableClick = useCallback(() => {
if (!isAlertRuleDisabled) {
setIsAlertRuleDisabled(true);
handleAlertStateToggle();
}
}, [isAlertRuleDisabled, handleAlertStateToggle]);
const ruleDisplayName = alertRuleName ?? alertDetails.alert;
return (
<>
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
)}
</Tooltip>
{isAlertRuleDisabled !== undefined && (
<div className="alert-state-segmented-wrapper">
<AlertStateSegmented
state={segmentedState}
onActive={handleActiveClick}
onMute={handleMuteClick}
onDisable={handleDisableClick}
/>
<MutePopover
open={isMutePopoverOpen}
onOpenChange={setIsMutePopoverOpen}
ruleName={ruleDisplayName}
isLoading={isMuting}
onSubmit={mute}
onOpenCustomWindow={(): void => setIsMuteDrawerOpen(true)}
anchor={<span className="alert-state-segmented-anchor" />}
/>
</div>
)}
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" className="alert-action-buttons__divider" />
@@ -141,6 +205,14 @@ function AlertActionButtons({
</DropdownMenuSimple>
</div>
<MuteSchedulerDrawer
open={isMuteDrawerOpen}
onClose={(): void => setIsMuteDrawerOpen(false)}
ruleName={ruleDisplayName}
isLoading={isMuting}
onSubmit={mute}
/>
<RenameModal
isOpen={isRenameAlertOpen}
setIsOpen={setIsRenameAlertOpen}

View File

@@ -1,3 +1,13 @@
.alert-info-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.alert-info__banner {
padding: 0 16px;
}
.alert-info {
display: flex;
justify-content: space-between;

View File

@@ -12,6 +12,9 @@ import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
import DisabledBanner from './MuteAlert/DisabledBanner';
import MutedBanner from './MuteAlert/MutedBanner';
import { useActiveMutes } from './MuteAlert/useActiveMutes';
import './AlertHeader.styles.scss';
@@ -43,6 +46,13 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const ruleId = alertDetails?.id || '';
const { activeMutes, refetch } = useActiveMutes(ruleId);
const effectiveState = alertRuleState ?? state ?? '';
const isDisabled = effectiveState === 'disabled';
const showMutedBanner = !isDisabled && Boolean(activeMutes.length);
const showDisabledBanner = isDisabled;
const CreateAlertV1Header = (
<div className="alert-info__info-wrapper">
<div className="top-section">
@@ -67,14 +77,23 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
);
return (
<div className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}
ruleId={alertDetails?.id || ''}
/>
<div className="alert-info-wrapper">
<div className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons alertDetails={alertDetails} ruleId={ruleId} />
</div>
</div>
{showMutedBanner && (
<div className="alert-info__banner">
<MutedBanner activeMute={activeMutes[0]} onExpire={refetch} />
</div>
)}
{showDisabledBanner && (
<div className="alert-info__banner">
<DisabledBanner rule={alertDetails as RuletypesRuleDTO} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,102 @@
.alert-state-segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
border-radius: 999px;
&__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--bg-vanilla-400);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
transition:
background 140ms,
color 140ms;
&:hover:not(:disabled) {
color: var(--bg-vanilla-100);
background: var(--bg-ink-200);
}
&:disabled {
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid var(--bg-robin-500);
outline-offset: 2px;
}
&--active-active {
background: var(--bg-robin-500);
color: var(--bg-vanilla-100);
&:hover:not(:disabled) {
background: var(--bg-robin-600);
color: var(--bg-vanilla-100);
}
}
&.alert-state-segmented__pill--active-muted {
background: var(--bg-amber-500);
color: #1a1407;
.alert-state-segmented__icon,
.alert-state-segmented__label {
color: #1a1407;
}
&:hover:not(:disabled) {
background: var(--bg-amber-600);
}
}
&--active-disabled {
background: var(--bg-slate-100);
color: var(--bg-vanilla-100);
&:hover:not(:disabled) {
background: var(--bg-slate-200);
color: var(--bg-vanilla-100);
}
}
}
&__dot {
width: 6px;
height: 6px;
background: var(--bg-vanilla-100);
border-radius: 999px;
}
&__icon {
flex-shrink: 0;
}
}
.lightMode {
.alert-state-segmented {
background: var(--bg-vanilla-200);
border-color: var(--bg-slate-500);
&__pill {
color: var(--bg-ink-300);
&:hover:not(:disabled) {
color: var(--bg-ink-500);
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,86 @@
import { forwardRef } from 'react';
import { BellOff } from '@signozhq/icons';
import classNames from 'classnames';
import './AlertStateSegmented.styles.scss';
export type AlertSegmentedState = 'active' | 'muted' | 'disabled';
export interface AlertStateSegmentedProps {
state: AlertSegmentedState;
onActive: () => void;
onMute: () => void;
onDisable: () => void;
disabled?: boolean;
}
const AlertStateSegmented = forwardRef<
HTMLDivElement,
AlertStateSegmentedProps
>(function AlertStateSegmented(props, ref): JSX.Element {
const { state, onActive, onMute, onDisable, disabled } = props;
const isMuted = state === 'muted';
const isDisabled = state === 'disabled';
return (
<div
className="alert-state-segmented"
role="tablist"
aria-label="Alert rule state"
ref={ref}
>
<button
type="button"
role="tab"
aria-selected={state === 'active'}
aria-label="Active"
className={classNames('alert-state-segmented__pill', {
'alert-state-segmented__pill--active-active': state === 'active',
})}
onClick={onActive}
// Per spec: when muted, un-muting must happen via Planned Downtimes,
// so the Active pill is non-interactive while muted.
disabled={disabled || isMuted}
>
{state === 'active' && (
<span className="alert-state-segmented__dot" aria-hidden />
)}
<span className="alert-state-segmented__label">Active</span>
</button>
<button
type="button"
role="tab"
aria-selected={state === 'muted'}
aria-label="Mute"
className={classNames('alert-state-segmented__pill', {
'alert-state-segmented__pill--active-muted': state === 'muted',
})}
onClick={onMute}
// Muting a disabled rule wouldn't change observable behavior, so the
// Mute pill is non-interactive while disabled.
disabled={disabled || isDisabled}
>
{state === 'muted' && (
<BellOff size={12} className="alert-state-segmented__icon" />
)}
<span className="alert-state-segmented__label">Mute</span>
</button>
<button
type="button"
role="tab"
aria-selected={state === 'disabled'}
aria-label="Disable"
className={classNames('alert-state-segmented__pill', {
'alert-state-segmented__pill--active-disabled': state === 'disabled',
})}
onClick={onDisable}
disabled={disabled}
>
<span className="alert-state-segmented__label">Disable</span>
</button>
</div>
);
});
export default AlertStateSegmented;

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { CircleOff } from '@signozhq/icons';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import './StateBanners.styles.scss';
dayjs.extend(relativeTime);
interface DisabledBannerProps {
rule: RuletypesRuleDTO;
}
function DisabledBanner({ rule }: DisabledBannerProps): JSX.Element {
const updatedAt = rule.updatedAt ? dayjs(rule.updatedAt) : null;
const [fromNow, setFromNow] = useState(updatedAt?.fromNow() ?? null);
useEffect(() => {
if (!rule.updatedAt) {
return;
}
const interval = setInterval(
() => setFromNow(dayjs(rule.updatedAt).fromNow()),
60_000,
);
return (): void => clearInterval(interval);
}, [rule.updatedAt]);
return (
<div className="state-banner state-banner--disabled" role="status">
<div className="state-banner__icon-disc state-banner__icon-disc--disabled">
<CircleOff size={18} color="var(--bg-slate-50)" />
</div>
<div className="state-banner__body">
<div className="state-banner__title">
<span>Rule disabled</span>
<span className="state-banner__pill state-banner__pill--disabled">
NOT EVALUATING
</span>
</div>
<div className="state-banner__meta">
<span>Evaluation paused no alerts will be recorded</span>
{fromNow && (
<>
{' · '}
<span>{fromNow}</span>
</>
)}
</div>
</div>
</div>
);
}
export default DisabledBanner;

View File

@@ -0,0 +1,193 @@
.mute-popover-overlay {
.ant-popover-inner {
padding: 0;
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-300);
border-radius: 10px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
}
.ant-popover-inner-content {
padding: 0;
}
}
.mute-popover {
width: 320px;
padding: 14px;
font-family: 'Inter', sans-serif;
color: var(--bg-vanilla-100);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
&__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--bg-vanilla-100);
}
&__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
color: var(--bg-vanilla-400);
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
transition:
color 140ms,
background 140ms;
&:hover {
color: var(--bg-vanilla-100);
background: var(--bg-ink-300);
}
}
&__hint {
margin: 0 0 12px 0;
font-size: 12px;
line-height: 1.45;
color: var(--bg-vanilla-400);
strong {
color: var(--bg-vanilla-100);
font-weight: 600;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 8px;
}
&__cell {
padding: 9px 0;
font-size: 12.5px;
color: var(--bg-vanilla-100);
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
border-radius: 6px;
cursor: pointer;
transition:
background 140ms,
border-color 140ms,
color 140ms;
&:hover:not(&--selected) {
background: rgba(78, 116, 248, 0.08);
border-color: var(--bg-robin-500);
}
&--selected {
background: var(--bg-robin-500);
border-color: var(--bg-robin-500);
color: var(--bg-vanilla-100);
}
}
&__custom {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
font-size: 12.5px;
color: var(--bg-vanilla-100);
background: transparent;
border: 1px dashed var(--bg-slate-200);
border-radius: 6px;
cursor: pointer;
transition:
border-color 140ms,
background 140ms;
&:hover {
border-color: var(--bg-robin-500);
background: rgba(78, 116, 248, 0.06);
}
}
&__divider {
height: 1px;
margin: 12px 0;
background: var(--bg-slate-300);
}
&__label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--bg-vanilla-400);
}
&__input.ant-input {
padding: 8px 10px;
font-size: 12.5px;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
border-radius: 6px;
color: var(--bg-vanilla-100);
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12.5px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition:
background 140ms,
color 140ms;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--ghost {
color: var(--bg-vanilla-400);
background: transparent;
border: 1px solid transparent;
&:hover:not(:disabled) {
color: var(--bg-vanilla-100);
background: var(--bg-ink-300);
}
}
&--primary {
color: var(--bg-vanilla-100);
background: var(--bg-robin-500);
border: 1px solid var(--bg-robin-500);
&:hover:not(:disabled) {
background: var(--bg-robin-600);
}
}
}
}

View File

@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import { BellOff, Calendar, X } from '@signozhq/icons';
import { Input, Popover } from 'antd';
import classNames from 'classnames';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import type { MutePayload } from './useMuteAlertRule';
import './MutePopover.styles.scss';
dayjs.extend(utc);
dayjs.extend(timezone);
type QuickDuration = {
label: string;
value: string;
minutes: number | null; // null = forever
};
export const QUICK_DURATIONS: QuickDuration[] = [
{ label: '15 min', value: '15m', minutes: 15 },
{ label: '1 hour', value: '1h', minutes: 60 },
{ label: '4 hours', value: '4h', minutes: 240 },
{ label: '1 day', value: '1d', minutes: 60 * 24 },
{ label: '1 week', value: '1w', minutes: 60 * 24 * 7 },
{ label: 'Forever', value: 'forever', minutes: null },
];
const DEFAULT_DURATION_VALUE = '4h';
export const buildMutePayloadFromQuickDuration = (
durationValue: string,
name: string,
): MutePayload | null => {
const duration = QUICK_DURATIONS.find((d) => d.value === durationValue);
if (!duration) {
return null;
}
// Format the times in the selected timezone so the ISO offset matches the
// `timezone` field. The backend ignores the offset and re-attaches the
// timezone to the raw wall-clock time, so the two must agree (mirrors
// PlannedDowntimeForm).
const tz = dayjs.tz.guess?.() || 'UTC';
const now = dayjs();
const startTime = now.tz(tz).format();
// duration.minutes === null → "Forever"; send endTime as null so the
// backend treats the mute as indefinite.
const endTime =
duration.minutes === null
? null
: now.add(duration.minutes, 'minute').tz(tz).format();
return {
name,
startTime,
endTime,
timezone: tz,
};
};
const getDefaultMuteName = (ruleName: string | undefined): string =>
ruleName ? `Muted: ${ruleName}` : 'Muted alert';
interface MutePopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
anchor: React.ReactNode;
ruleName: string | undefined;
isLoading: boolean;
onSubmit: (payload: MutePayload) => Promise<void> | void;
onOpenCustomWindow: () => void;
}
function MutePopover(props: MutePopoverProps): JSX.Element {
const {
open,
onOpenChange,
anchor,
ruleName,
isLoading,
onSubmit,
onOpenCustomWindow,
} = props;
const [selected, setSelected] = useState<string>(DEFAULT_DURATION_VALUE);
const [name, setName] = useState<string>(getDefaultMuteName(ruleName));
useEffect(() => {
if (open) {
setSelected(DEFAULT_DURATION_VALUE);
setName(getDefaultMuteName(ruleName));
}
}, [open, ruleName]);
// Close on outside click / Escape. We use trigger={[]} on the Popover so
// antd doesn't handle these — without this hook, the popover only closes
// via Cancel / × / Mute submit.
useEffect(() => {
if (!open) {
return undefined;
}
// Drop focus so the trigger button doesn't show a :focus-visible
// outline after the popover closes via Escape / outside click.
const closeAndBlur = (): void => {
(document.activeElement as HTMLElement | null)?.blur();
onOpenChange(false);
};
const handleMouseDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement | null;
if (target?.closest('.mute-popover-overlay')) {
return;
}
closeAndBlur();
};
const handleKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
closeAndBlur();
}
};
// Defer attaching listeners until after the click that opened the
// popover has finished bubbling — otherwise it counts as an outside
// click and we close immediately.
const timer = window.setTimeout(() => {
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKey);
}, 0);
return (): void => {
window.clearTimeout(timer);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKey);
};
}, [open, onOpenChange]);
const selectedDuration = QUICK_DURATIONS.find((d) => d.value === selected);
const primaryLabel =
selectedDuration?.minutes === null
? 'Mute indefinitely'
: `Mute for ${selectedDuration?.label.toLowerCase() ?? '4 hours'}`;
const handleSubmit = async (): Promise<void> => {
const payload = buildMutePayloadFromQuickDuration(selected, name.trim());
if (!payload || !payload.name) {
return;
}
await onSubmit(payload);
};
const content = (
<div
className="mute-popover"
onKeyDown={(e): void => {
if (e.key === 'Escape') {
onOpenChange(false);
}
}}
>
<div className="mute-popover__header">
<div className="mute-popover__title">
<BellOff size={14} />
<span>Mute notifications</span>
</div>
<button
type="button"
aria-label="Close"
className="mute-popover__close"
onClick={(): void => onOpenChange(false)}
>
<X size={14} />
</button>
</div>
<p className="mute-popover__hint">
Rule keeps evaluating in the background. You&apos;ll still see fires in{' '}
<strong>History</strong> just no pages, Slack, or email.
</p>
<div className="mute-popover__grid">
{QUICK_DURATIONS.map((d) => (
<button
type="button"
key={d.value}
className={classNames('mute-popover__cell', {
'mute-popover__cell--selected': selected === d.value,
})}
onClick={(): void => setSelected(d.value)}
>
{d.label}
</button>
))}
</div>
<button
type="button"
className="mute-popover__custom"
onClick={(): void => {
onOpenChange(false);
onOpenCustomWindow();
}}
>
<Calendar size={14} />
Custom window
</button>
<div className="mute-popover__divider" />
<label className="mute-popover__label" htmlFor="mute-popover-name">
Name
</label>
<Input
id="mute-popover-name"
className="mute-popover__input"
placeholder="e.g. Deployment window"
value={name}
onChange={(e): void => setName(e.target.value)}
maxLength={120}
/>
<div className="mute-popover__footer">
<button
type="button"
className="mute-popover__btn mute-popover__btn--ghost"
onClick={(): void => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
className="mute-popover__btn mute-popover__btn--primary"
onClick={handleSubmit}
disabled={isLoading || !name.trim()}
>
<BellOff size={12} />
{primaryLabel}
</button>
</div>
</div>
);
return (
<Popover
open={open}
onOpenChange={onOpenChange}
trigger={[]}
placement="bottomRight"
arrow={false}
destroyTooltipOnHide
overlayClassName="mute-popover-overlay"
content={content}
>
{anchor}
</Popover>
);
}
export default MutePopover;

View File

@@ -0,0 +1,125 @@
.mute-scheduler-drawer {
.ant-drawer-body {
padding: 24px 28px;
background: var(--bg-ink-500);
}
.ant-drawer-header {
display: none;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
&__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--bg-vanilla-100);
}
&__close {
position: absolute;
top: 18px;
right: 24px;
width: 28px;
height: 28px;
font-size: 18px;
line-height: 1;
color: var(--bg-vanilla-400);
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
transition:
background 140ms,
color 140ms;
&:hover {
color: var(--bg-vanilla-100);
background: var(--bg-ink-300);
}
}
&__subtitle {
margin: 8px 0 14px 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--bg-vanilla-400);
strong {
color: var(--bg-vanilla-100);
font-weight: 600;
}
}
&__divider {
height: 1px;
margin: 0 0 16px 0;
background: var(--bg-slate-300);
}
&__form {
.ant-form-item-label > label {
font-size: 12px;
color: var(--bg-vanilla-400);
}
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
&__date {
width: 100%;
}
&__callout {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 18px 0;
padding: 10px;
background: rgba(35, 196, 248, 0.06);
border: 1px solid rgba(35, 196, 248, 0.2);
border-radius: 6px;
p {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--bg-vanilla-400);
strong {
color: var(--bg-vanilla-100);
font-weight: 600;
}
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
margin-top: 6px;
border-top: 1px solid var(--bg-slate-300);
}
}
// The DatePicker popup is portaled to <body>, so this selector lives at the
// root rather than nested under .mute-scheduler-drawer. Without a solid panel
// background the time-column cells are transparent and the drawer content
// bleeds through.
.mute-scheduler-drawer__date-popup {
.ant-picker-panel-container {
background: var(--bg-ink-400) !important;
}
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useMemo, useState } from 'react';
import { BellOff, Check, Info } from '@signozhq/icons';
import { Button, DatePicker, Drawer, Form, Input, Select } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
recurrenceOptions,
recurrenceOptionWithSubmenu,
recurrenceWeeklyOptions,
} from 'container/PlannedDowntime/PlannedDowntimeutils';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
import type { MutePayload } from './useMuteAlertRule';
import './MuteSchedulerDrawer.styles.scss';
dayjs.extend(utc);
dayjs.extend(timezone);
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATETIME;
const TZ_OPTIONS: DefaultOptionType[] = ALL_TIME_ZONES.map((tz) => ({
label: tz,
value: tz,
key: tz,
}));
const DURATION_UNIT_OPTIONS = [
{ label: 'Mins', value: 'm' },
{ label: 'Hours', value: 'h' },
];
type MuteSchedulerFormData = {
name: string;
startTime: dayjs.Dayjs | null;
endTime: dayjs.Dayjs | null;
repeatType: string;
repeatOn?: string[];
duration?: number;
timezone: string;
};
interface MuteSchedulerDrawerProps {
open: boolean;
onClose: () => void;
ruleName: string | undefined;
isLoading: boolean;
onSubmit: (payload: MutePayload) => Promise<void> | void;
}
function MuteSchedulerDrawer(props: MuteSchedulerDrawerProps): JSX.Element {
const { open, onClose, ruleName, isLoading, onSubmit } = props;
const [form] = Form.useForm<MuteSchedulerFormData>();
const [recurrenceType, setRecurrenceType] = useState<string>(
recurrenceOptions.doesNotRepeat.value,
);
const [durationUnit, setDurationUnit] = useState<string>('m');
const defaultName = useMemo(
() => (ruleName ? `Muted: ${ruleName}` : 'Muted alert'),
[ruleName],
);
useEffect(() => {
if (open) {
const guess = (dayjs as any).tz?.guess?.() || 'UTC';
form.setFieldsValue({
name: defaultName,
startTime: dayjs(),
endTime: dayjs().add(1, 'hour'),
repeatType: recurrenceOptions.doesNotRepeat.value,
timezone: guess,
});
setRecurrenceType(recurrenceOptions.doesNotRepeat.value);
setDurationUnit('m');
}
}, [open, defaultName, form]);
const handleFinish = async (values: MuteSchedulerFormData): Promise<void> => {
const isRecurring =
values.repeatType &&
values.repeatType !== recurrenceOptions.doesNotRepeat.value;
// Reinterpret the picked wall-clock time in the selected timezone (keep
// local time, swap the offset) so the formatted ISO offset matches the
// `timezone` field. The backend ignores the offset and re-attaches the
// timezone to the raw time, so the two must agree (mirrors
// PlannedDowntimeForm's handleFormData).
const { timezone: tz } = values;
const startTime = (values.startTime || dayjs()).tz(tz, true).format();
const endTime = values.endTime ? values.endTime.tz(tz, true).format() : null;
const payload: MutePayload = {
name: values.name.trim(),
startTime,
endTime,
timezone: tz,
recurrence: isRecurring
? {
duration: values.duration ? `${values.duration}${durationUnit}` : '',
repeatOn: values.repeatOn as any,
repeatType: values.repeatType as any,
startTime,
endTime: endTime ?? undefined,
}
: undefined,
};
await onSubmit(payload);
};
const requiredRule = [{ required: true }];
return (
<Drawer
width={460}
open={open}
onClose={onClose}
placement="right"
closable={false}
destroyOnClose
className="mute-scheduler-drawer"
rootClassName="mute-scheduler-drawer-root"
>
<div className="mute-scheduler-drawer__header">
<div className="mute-scheduler-drawer__title">
<BellOff size={18} color="var(--bg-amber-500)" />
<span>Mute this alert rule</span>
</div>
<button
type="button"
className="mute-scheduler-drawer__close"
aria-label="Close"
onClick={onClose}
>
×
</button>
</div>
<p className="mute-scheduler-drawer__subtitle">
Creates a planned silence for <strong>{ruleName || 'this rule'}</strong>
rule continues to evaluate; notifications are suppressed for the window
below.
</p>
<div className="mute-scheduler-drawer__divider" />
<Form<MuteSchedulerFormData>
form={form}
layout="vertical"
onFinish={handleFinish}
onValuesChange={(_, all): void => {
if (all.repeatType !== recurrenceType) {
setRecurrenceType(all.repeatType);
}
}}
className="mute-scheduler-drawer__form"
autoComplete="off"
>
<Form.Item label="Name" name="name" rules={requiredRule}>
<Input placeholder="e.g. Deployment window" maxLength={120} />
</Form.Item>
<Form.Item label="Starts" name="startTime" rules={requiredRule}>
<DatePicker
className="mute-scheduler-drawer__date"
popupClassName="mute-scheduler-drawer__date-popup"
showTime
showNow={false}
format={(date): string => date.format(DATE_FORMAT)}
/>
</Form.Item>
<Form.Item
label="Ends"
name="endTime"
required={recurrenceType === recurrenceOptions.doesNotRepeat.value}
rules={[
{
required: recurrenceType === recurrenceOptions.doesNotRepeat.value,
},
]}
>
<DatePicker
className="mute-scheduler-drawer__date"
popupClassName="mute-scheduler-drawer__date-popup"
showTime
showNow={false}
format={(date): string => date.format(DATE_FORMAT)}
/>
</Form.Item>
<div className="mute-scheduler-drawer__row">
<Form.Item label="Repeats every" name="repeatType" rules={requiredRule}>
<Select placeholder="Select" options={recurrenceOptionWithSubmenu} />
</Form.Item>
<Form.Item label="Timezone" name="timezone" rules={requiredRule}>
<Select placeholder="Select timezone" showSearch options={TZ_OPTIONS} />
</Form.Item>
</div>
{recurrenceType === recurrenceOptions.weekly.value && (
<Form.Item label="Weekly occurrence" name="repeatOn" rules={requiredRule}>
<Select
placeholder="Select days"
mode="multiple"
options={Object.values(recurrenceWeeklyOptions)}
/>
</Form.Item>
)}
{recurrenceType &&
recurrenceType !== recurrenceOptions.doesNotRepeat.value && (
<Form.Item label="Duration" name="duration" rules={requiredRule}>
<Input
type="number"
min={1}
placeholder="Enter duration"
addonAfter={
<Select
value={durationUnit}
onChange={(v): void => setDurationUnit(v)}
options={DURATION_UNIT_OPTIONS}
/>
}
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
)}
<div className="mute-scheduler-drawer__callout">
<Info size={14} color="var(--bg-aqua-500)" />
<p>
The rule will <strong>keep evaluating</strong> and firing alerts to the
History tab. Only notifications (Slack, PagerDuty, email) are silenced.
</p>
</div>
<div className="mute-scheduler-drawer__footer">
<Button type="text" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
icon={<Check size={14} />}
>
Mute alert
</Button>
</div>
</Form>
</Drawer>
);
}
export default MuteSchedulerDrawer;

View File

@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { BellOff } from '@signozhq/icons';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import type { ActiveMute } from './useActiveMutes';
import './StateBanners.styles.scss';
const PLANNED_DOWNTIMES_URL = `${ROUTES.LIST_ALL_ALERT}?tab=PlannedDowntime`;
const formatRemaining = (endTime: string): string | null => {
const end = dayjs(endTime);
const now = dayjs();
const diffMs = end.diff(now);
if (diffMs <= 0) {
return null;
}
const totalMinutes = Math.floor(diffMs / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) {
return `${days}d ${hours}h LEFT`;
}
if (hours > 0) {
return `${hours}h ${minutes}m LEFT`;
}
return `${minutes}m LEFT`;
};
interface MutedBannerProps {
activeMute: ActiveMute;
onExpire?: () => void;
}
function MutedBanner({ activeMute, onExpire }: MutedBannerProps): JSX.Element {
const endTime = activeMute.end;
const [remaining, setRemaining] = useState(
endTime ? formatRemaining(endTime) : null,
);
useEffect(() => {
if (!endTime) {
return;
}
const interval = setInterval(() => {
const next = formatRemaining(endTime);
setRemaining(next);
if (next === null) {
clearInterval(interval);
onExpire?.();
}
}, 60_000);
return (): void => clearInterval(interval);
}, [endTime, onExpire]);
const titleText = useMemo(() => {
if (!endTime) {
return 'Notifications muted';
}
return `Notifications muted until ${dayjs(endTime).format('MMM D, h:mm A')}`;
}, [endTime]);
const reason = activeMute.description || activeMute.name;
return (
<div className="state-banner state-banner--muted" role="status">
<div className="state-banner__icon-disc state-banner__icon-disc--muted">
<BellOff size={18} color="var(--bg-amber-500)" />
</div>
<div className="state-banner__body">
<div className="state-banner__title">
<span>{titleText}</span>
{remaining && (
<span className="state-banner__pill state-banner__pill--muted">
{remaining}
</span>
)}
</div>
<div className="state-banner__meta">
<span>Rule is still evaluating alerts will appear in History</span>
{reason && (
<>
{' · '}
<span>
Name: <strong>{reason}</strong>
</span>
</>
)}
{' · '}
<Link to={PLANNED_DOWNTIMES_URL} className="state-banner__link">
Manage in Planned Downtimes
</Link>
</div>
</div>
</div>
);
}
export default MutedBanner;

View File

@@ -0,0 +1,98 @@
.state-banner {
display: flex;
gap: 14px;
align-items: center;
margin-top: 16px;
padding: 12px 16px;
border-radius: 8px;
font-family: 'Inter', sans-serif;
&--muted {
background: linear-gradient(
90deg,
rgba(255, 205, 86, 0.1),
rgba(255, 205, 86, 0.04)
);
border: 1px solid rgba(255, 205, 86, 0.25);
}
&--disabled {
background: rgba(98, 104, 124, 0.06);
border: 1px solid var(--bg-slate-200);
}
&__icon-disc {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
flex-shrink: 0;
&--muted {
background: rgba(255, 205, 86, 0.15);
}
&--disabled {
background: rgba(98, 104, 124, 0.15);
}
}
&__body {
flex: 1;
min-width: 0;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--bg-vanilla-100);
font-variant-numeric: tabular-nums;
}
&__pill {
display: inline-flex;
align-items: center;
padding: 2px 7px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border-radius: 4px;
&--muted {
color: var(--bg-amber-500);
background: rgba(255, 205, 86, 0.12);
}
&--disabled {
color: var(--bg-slate-50);
background: rgba(98, 104, 124, 0.18);
}
}
&__meta {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--bg-vanilla-400);
strong {
color: var(--bg-vanilla-100);
font-weight: 600;
}
}
&__link {
color: var(--bg-robin-500);
&:hover {
color: var(--bg-robin-400);
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from 'react';
import { useGetRuleByID } from 'api/generated/services/rules';
import type { RuletypesActiveMuteDTO } from 'api/generated/services/sigNoz.schemas';
export type ActiveMute = RuletypesActiveMuteDTO;
type UseActiveMuteResult = {
activeMutes: ActiveMute[];
isLoading: boolean;
isFetching: boolean;
refetch: () => void;
};
export const useActiveMutes = (
ruleId: string | undefined,
): UseActiveMuteResult => {
const { data, isLoading, isFetching, refetch } = useGetRuleByID(
{ id: ruleId || '' },
{
query: {
enabled: Boolean(ruleId),
refetchOnWindowFocus: false,
},
},
);
const activeMutes = useMemo(() => data?.data?.mutes ?? [], [data]);
return { activeMutes, isLoading, isFetching, refetch };
};

View File

@@ -0,0 +1,92 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import {
createDowntimeSchedule,
getListDowntimeSchedulesQueryKey,
} from 'api/generated/services/downtimeschedules';
import {
getGetRuleByIDQueryKey,
getListRulesQueryKey,
} from 'api/generated/services/rules';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
AlertmanagertypesPostablePlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export type MutePayload = {
name: string;
startTime: string;
endTime?: string | null;
timezone: string;
recurrence?: AlertmanagertypesRecurrenceDTO;
};
type UseMuteAlertRuleArgs = {
ruleId: string;
onSuccess?: () => void;
};
type UseMuteAlertRuleResult = {
mute: (payload: MutePayload) => Promise<void>;
isLoading: boolean;
};
export const useMuteAlertRule = ({
ruleId,
onSuccess,
}: UseMuteAlertRuleArgs): UseMuteAlertRuleResult => {
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const queryClient = useQueryClient();
const { mutateAsync, isLoading } = useMutation(
['createMuteDowntime', ruleId],
(payload: AlertmanagertypesPostablePlannedMaintenanceDTO) =>
createDowntimeSchedule(payload),
{
onSuccess: () => {
void queryClient.invalidateQueries(getListDowntimeSchedulesQueryKey());
void queryClient.invalidateQueries(getGetRuleByIDQueryKey({ id: ruleId }));
void queryClient.invalidateQueries(getListRulesQueryKey());
notifications.success({ message: 'Alert muted' });
onSuccess?.();
},
onError: (error) => {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
},
);
const mute = useCallback(
async (payload: MutePayload): Promise<void> => {
if (!ruleId) {
return;
}
const body: AlertmanagertypesPostablePlannedMaintenanceDTO = {
name: payload.name,
alertIds: [ruleId],
schedule: {
startTime: payload.startTime,
// null = no end ("Forever"). The generated type narrows endTime to
// string, but the API accepts null to mean indefinite.
endTime:
payload.endTime === null ? (null as unknown as string) : payload.endTime,
timezone: payload.timezone,
recurrence: payload.recurrence,
},
};
await mutateAsync(body);
},
[mutateAsync, ruleId],
);
return { mute, isLoading };
};

View File

@@ -7,11 +7,10 @@ const TAB_SELECTOR = '.ant-tabs-tab';
const LIST_ALERT_RULES_TEXT = 'List Alert Rules Component';
const TRIGGERED_ALERTS_TEXT = 'Triggered Alerts';
const ALERT_RULES_TEXT = 'Alert Rules';
const CONFIGURATION_TEXT = 'Configuration';
const PLANNED_DOWNTIME_TEXT = 'Planned Downtime';
const ROUTING_POLICIES_TEXT = 'Routing Policies';
const PLANNED_DOWNTIME_SUB_TAB = 'planned-downtime';
const ROUTING_POLICIES_SUB_TAB = 'routing-policies';
const PLANNED_DOWNTIME_TAB = 'PlannedDowntime';
const ROUTING_POLICIES_TAB = 'RoutingPolicies';
const mockUseLocation = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -122,7 +121,7 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render all three main tabs', () => {
it('should render all four top-level tabs', () => {
mockQueryParams({});
mockLocation(ALERTS_PATH);
@@ -130,7 +129,8 @@ describe('AlertList', () => {
expect(screen.getByText(TRIGGERED_ALERTS_TEXT)).toBeInTheDocument();
expect(screen.getByText(ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.getByText(CONFIGURATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
});
});
@@ -153,13 +153,22 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render Configuration tab with default Planned Downtime sub-tab when tab query param is Configuration', () => {
mockQueryParams({ tab: 'Configuration' });
it('should render PlannedDowntime tab when tab query param is PlannedDowntime', () => {
mockQueryParams({ tab: PLANNED_DOWNTIME_TAB });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
it('should render RoutingPolicies tab when tab query param is RoutingPolicies', () => {
mockQueryParams({ tab: ROUTING_POLICIES_TAB });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
});
it('should navigate to TriggeredAlerts tab when clicked', () => {
@@ -175,89 +184,32 @@ describe('AlertList', () => {
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=TriggeredAlerts');
});
it('should navigate to AlertRules tab when clicked', () => {
mockQueryParams({ tab: 'TriggeredAlerts' });
it('should navigate to PlannedDowntime tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(ALERT_RULES_TEXT);
clickTab(PLANNED_DOWNTIME_TEXT);
expect(mockSet).toHaveBeenCalledWith('tab', 'AlertRules');
expect(mockDelete).toHaveBeenCalledWith('subTab');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
});
});
describe('Configuration Tab', () => {
describe('Rendering', () => {
it('should render Configuration tab with default Planned Downtime sub-tab', () => {
mockQueryParams({ tab: CONFIGURATION_TEXT });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
it('should render Routing Policies sub-tab when subTab query param is routing-policies', () => {
mockQueryParams({
tab: CONFIGURATION_TEXT,
subTab: ROUTING_POLICIES_SUB_TAB,
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
});
expect(mockSet).toHaveBeenCalledWith('tab', PLANNED_DOWNTIME_TAB);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${PLANNED_DOWNTIME_TAB}`,
);
});
describe('Navigation', () => {
it('should navigate to Configuration tab with default subTab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
it('should navigate to RoutingPolicies tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
render(<AlertList />);
clickTab(CONFIGURATION_TEXT);
clickTab(ROUTING_POLICIES_TEXT);
expect(mockSet).toHaveBeenCalledWith('tab', CONFIGURATION_TEXT);
expect(mockSet).toHaveBeenCalledWith('subTab', PLANNED_DOWNTIME_SUB_TAB);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
);
});
it('should preserve existing subTab when navigating to Configuration tab', () => {
mockQueryParams({ tab: 'AlertRules', subTab: ROUTING_POLICIES_SUB_TAB });
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(CONFIGURATION_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
);
});
it('should clear subTab when navigating away from Configuration tab', () => {
mockQueryParams({
tab: CONFIGURATION_TEXT,
subTab: PLANNED_DOWNTIME_SUB_TAB,
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(ALERT_RULES_TEXT);
expect(mockDelete).toHaveBeenCalledWith('subTab');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
});
expect(mockSet).toHaveBeenCalledWith('tab', ROUTING_POLICIES_TAB);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${ROUTING_POLICIES_TAB}`,
);
});
});
});

View File

@@ -1,4 +1,3 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
@@ -10,10 +9,10 @@ import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import { AlertListSubTabs, AlertListTabs } from './types';
import { AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -23,43 +22,9 @@ function AllAlertList(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const tab = urlQuery.get('tab');
const subTab = urlQuery.get('subTab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const handleConfigurationTabChange = useCallback(
(subTab: string): void => {
urlQuery.set('tab', AlertListTabs.CONFIGURATION);
urlQuery.set('subTab', subTab);
urlQuery.delete('search');
safeNavigate(`/alerts?${urlQuery.toString()}`);
},
[safeNavigate, urlQuery],
);
const configurationTab = useMemo(() => {
const tabs = [
{
label: 'Planned Downtime',
key: AlertListSubTabs.PLANNED_DOWNTIME,
children: <PlannedDowntime />,
},
{
label: 'Routing Policies',
key: AlertListSubTabs.ROUTING_POLICIES,
children: <RoutingPolicies />,
},
];
return (
<Tabs
className="configuration-tabs"
activeKey={subTab || AlertListSubTabs.PLANNED_DOWNTIME}
items={tabs}
onChange={handleConfigurationTabChange}
/>
);
}, [subTab, handleConfigurationTabChange]);
const items: TabsProps['items'] = [
{
label: (
@@ -88,12 +53,22 @@ function AllAlertList(): JSX.Element {
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Configuration
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.CONFIGURATION,
children: configurationTab,
key: AlertListTabs.PLANNED_DOWNTIME,
children: <PlannedDowntime />,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
children: <RoutingPolicies />,
},
];
@@ -102,21 +77,10 @@ function AllAlertList(): JSX.Element {
destroyInactiveTabPane
items={items}
activeKey={tab || AlertListTabs.ALERT_RULES}
onChange={(tab): void => {
urlQuery.set('tab', tab);
// If navigating to Configuration tab, set default subTab
if (tab === AlertListTabs.CONFIGURATION) {
const currentSubTab = subTab || AlertListSubTabs.PLANNED_DOWNTIME;
urlQuery.set('subTab', currentSubTab);
} else {
// Clear subTab when navigating out of Configuration tab
urlQuery.delete('subTab');
}
// Clear search when navigating to any tab
onChange={(nextTab): void => {
urlQuery.set('tab', nextTab);
urlQuery.delete('subTab');
urlQuery.delete('search');
safeNavigate(`/alerts?${urlQuery.toString()}`);
}}
className={`alerts-container ${

View File

@@ -1,10 +1,6 @@
export enum AlertListSubTabs {
PLANNED_DOWNTIME = 'planned-downtime',
ROUTING_POLICIES = 'routing-policies',
}
export enum AlertListTabs {
TRIGGERED_ALERTS = 'TriggeredAlerts',
ALERT_RULES = 'AlertRules',
CONFIGURATION = 'Configuration',
PLANNED_DOWNTIME = 'PlannedDowntime',
ROUTING_POLICIES = 'RoutingPolicies',
}

View File

@@ -150,17 +150,29 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "cannot delete planned maintenance because it is referenced by associated rules, remove the rules from the planned maintenance first")
}
return r.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
_, err := r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
_, err = r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
})
}
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {

View File

@@ -29,15 +29,23 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
rules, err := handler.ruler.ListRuleStates(ctx)
if err != nil {
render.Error(rw, err)
return
}
schedules, _ := handler.ruler.MaintenanceStore().ListPlannedMaintenance(ctx, claims.OrgID)
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
for _, rule := range rules.Rules {
view = append(view, ruletypes.NewRule(rule))
view = append(view, ruletypes.NewRule(rule, schedules))
}
render.Success(rw, http.StatusOK, view)
@@ -47,6 +55,12 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
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"))
@@ -59,7 +73,9 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
return
}
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
schedules, _ := handler.ruler.MaintenanceStore().ListPlannedMaintenance(ctx, claims.OrgID)
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, schedules))
}
func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
@@ -79,7 +95,7 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule, nil))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
@@ -150,7 +166,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
return
}
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, nil))
}
func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {

View File

@@ -3,6 +3,7 @@ package alertmanagertypes
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/expr-lang/expr"
@@ -81,6 +82,11 @@ type PlannedMaintenance struct {
Kind MaintenanceKind `json:"kind" required:"true"`
}
type Window struct {
Start time.Time
End *time.Time
}
// PostablePlannedMaintenance is the input payload for creating or updating a
// planned maintenance. Server-owned fields (id, timestamps, audit users,
// derived status / kind) are deliberately not accepted from the client.
@@ -148,6 +154,12 @@ type PlannedMaintenanceWithRules struct {
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
}
// AppliesTo reports whether this maintenance applies to the given rule.
// An empty RuleIDs set means the maintenance applies to all rules.
func (m *PlannedMaintenance) AppliesTo(ruleID string) bool {
return len(m.RuleIDs) == 0 || slices.Contains(m.RuleIDs, ruleID)
}
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
// has different start/end bounds in Schedule and Schedule.Recurrence.
//
@@ -169,26 +181,7 @@ func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
for _, alertID := range m.RuleIDs {
if alertID == ruleID {
found = true
break
}
}
}
// If no alert ids, then skip all alerts
if len(m.RuleIDs) == 0 {
found = true
}
if !found {
return false, nil
}
if !m.IsActive(now) {
if !m.AppliesTo(ruleID) || !m.IsActive(now) {
return false, nil
}
@@ -206,39 +199,27 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model
// IsActive reports whether [now] falls inside the maintenance window's schedule.
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
return m.ActiveWindow(now) != nil
}
// ActiveWindow returns the maintenance window that [now] falls within, if any.
func (m *PlannedMaintenance) ActiveWindow(now time.Time) *Window {
// If alert is found, we check if it should be skipped based on the schedule
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
return nil
}
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
recurrence := m.Schedule.Recurrence
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
if now.Equal(startTime) || now.Equal(endTime) ||
(now.After(startTime) && now.Before(endTime)) {
return true
}
}
// recurring schedule
if recurrence != nil {
// Make sure the recurrence has started
if now.Before(recurrence.StartTime) {
return false
return nil
}
// Check if recurrence has expired
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return false
}
if recurrence.EndTime != nil && !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return nil
}
currentTime := now.In(loc)
@@ -249,15 +230,32 @@ func (m *PlannedMaintenance) IsActive(now time.Time) bool {
return m.checkWeekly(currentTime, recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
default:
// unreachable: invalid repeat type
return nil
}
}
return false
// Fixed schedule
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
if startTime.IsZero() || now.Before(startTime) {
// note: startTime should never be zero for fixed schedule
return nil
}
if endTime.IsZero() || !now.After(endTime) {
// zero endTime means "forever" downtime
return &Window{startTime, &endTime}
}
return nil
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) *Window {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
@@ -266,13 +264,17 @@ func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence,
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= rec.Duration.Duration()
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
}
return nil
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrences
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) *Window {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, rec, loc)
@@ -296,15 +298,16 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
candidate = candidate.AddDate(0, 0, -7)
}
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
return true
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
}
}
return false
return nil
}
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) *Window {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
@@ -333,21 +336,25 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
)
}
}
return currentTime.Sub(candidate) <= rec.Duration.Duration()
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
}
return nil
}
func (m *PlannedMaintenance) IsUpcoming() bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
now := time.Now()
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
// Note: this would return true even if the maintenance is Active.
// This isn't an issue right now because the only usage happens after the `IsActive` check.
return m.Schedule.Recurrence.EndTime == nil || now.Before(*m.Schedule.Recurrence.EndTime)
}
// Fixed schedule
if !m.Schedule.StartTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
return false
}
@@ -404,10 +411,10 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
}
var kind MaintenanceKind
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = MaintenanceKindFixed
} else {
if m.Schedule.Recurrence != nil {
kind = MaintenanceKindRecurring
} else {
kind = MaintenanceKindFixed
}
return json.Marshal(struct {

View File

@@ -441,6 +441,39 @@ func TestShouldSkipMaintenance(t *testing.T) {
ts: time.Now().UTC().Add(-time.Hour),
skip: false,
},
{
name: "fixed planned maintenance with no end time (forever), ts > start",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
},
},
ts: time.Now().UTC(),
skip: true,
},
{
name: "fixed planned maintenance with no end time (forever), ts == start",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
},
},
ts: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
skip: true,
},
{
name: "fixed planned maintenance with no end time (forever), ts < start",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(time.Hour),
},
},
ts: time.Now().UTC(),
skip: false,
},
{
name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone",
maintenance: &PlannedMaintenance{

View File

@@ -642,24 +642,83 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
}
}
// ActiveMute holds the currently active mute window for an alert rule.
type ActiveMute struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Start time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
}
// findActiveMutesForRule returns the active mute windows for the rule, if any.
// Scope expressions are skipped here because we operate at the rule level, and
// no labels are available.
func findActiveMutesForRule(ruleID string, maintenances []*alertmanagertypes.PlannedMaintenance) []ActiveMute {
mutes := make([]ActiveMute, 0)
if len(maintenances) == 0 || ruleID == "" {
return mutes
}
now := time.Now()
for _, m := range maintenances {
if !m.AppliesTo(ruleID) {
continue
}
if w := m.ActiveWindow(now); w != nil {
mutes = append(mutes, ActiveMute{
ID: m.ID.StringValue(),
Name: m.Name,
Description: m.Description,
Start: w.Start,
End: w.End,
})
}
}
// Sort by the furthest end time. nil (forever) wins.
slices.SortFunc(mutes, func(a, b ActiveMute) int {
if a.End == nil && b.End == nil {
return 0
}
if a.End == nil {
return -1
}
if b.End == nil {
return 1
}
return b.End.Compare(*a.End)
})
return mutes
}
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
// PlannedMaintenance and other entities. v1 handlers keep serializing
// GettableRule directly for back-compat with existing SDK / Terraform clients.
type Rule struct {
Id string `json:"id" required:"true"`
State AlertState `json:"state" required:"true"`
Id string `json:"id" required:"true"`
State AlertState `json:"state" required:"true"`
Mutes []ActiveMute `json:"mutes"`
PostableRule
types.TimeAuditable
types.UserAuditable
}
func NewRule(g *GettableRule) *Rule {
func NewRule(g *GettableRule, maintenances []*alertmanagertypes.PlannedMaintenance) *Rule {
r := &Rule{
Id: g.Id,
State: g.State,
PostableRule: g.PostableRule,
}
if r.State != StateDisabled {
r.Mutes = findActiveMutesForRule(g.Id, maintenances)
}
r.CreatedAt = g.CreatedAt
r.UpdatedAt = g.UpdatedAt
if g.CreatedBy != nil {