Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
dc9b9196f2 fix(create-alert-v2): tooltip not showing due to pointer-events none 2026-05-29 09:41:24 -03:00
61 changed files with 999 additions and 2060 deletions

View File

@@ -5945,22 +5945,6 @@ 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:
@@ -6242,11 +6226,6 @@ 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,31 +7051,6 @@ 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',
@@ -7444,10 +7419,6 @@ export interface RuletypesRuleDTO {
* @type object
*/
labels?: RuletypesRuleDTOLabels;
/**
* @type array,null
*/
mutes?: RuletypesActiveMuteDTO[] | null;
notificationSettings?: RuletypesNotificationSettingsDTO;
/**
* @type array

View File

@@ -11,6 +11,7 @@
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
border-color: var(--l1-border);
margin: 16px 0;
margin-top: 10px;
}

View File

@@ -11,7 +11,7 @@ import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import { QueryProps } from 'container/QueryBuilder/components/Query/Query.interfaces';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';

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 { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -172,24 +172,14 @@ function CreateRules(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.PLANNED_DOWNTIME,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
Configuration
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
key: AlertListTabs.CONFIGURATION,
children: null,
},
];

View File

@@ -4,4 +4,5 @@ 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=RoutingPolicies';
export const ROUTING_POLICIES_ROUTE =
'/alerts?tab=Configuration&subTab=routing-policies';

View File

@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
button = (
<Tooltip title={alertValidationMessage}>
<span>{button}</span>
</Tooltip>
);
}
return button;
}, [

View File

@@ -23,10 +23,6 @@
}
}
}
&__divider {
--divider-vertical-margin: 0;
}
}
.hide-update {
@@ -59,6 +55,12 @@
.hidden {
display: none;
}
.ant-divider {
margin: 0;
height: 28px;
border: 1px solid var(--l1-border);
}
}
.explorer-options {

View File

@@ -874,9 +874,7 @@ function ExplorerOptions({
<>
<Divider
type="vertical"
className={cx('explorer-options-container__divider', {
hidden: !isEditDeleteSupported,
})}
className={isEditDeleteSupported ? '' : 'hidden'}
/>
<Tooltip title="Update this view" placement="top">
<Button

View File

@@ -94,8 +94,11 @@
margin-bottom: 24px;
width: 100%;
&__divider {
--divider-border-width: 1px;
.ant-divider::before,
.ant-divider::after {
border-bottom: 2px dotted var(--l1-border);
border-top: 2px dotted var(--l1-border);
height: 8px;
}
.ant-typography {

View File

@@ -125,7 +125,7 @@ export function AlertsEmptyState(): JSX.Element {
</div>
</section>
<div className="get-started-text">
<Divider className="get-started-text__divider">
<Divider>
<Typography.Text className="get-started-text">
Or get started with these sample alerts
</Typography.Text>

View File

@@ -43,7 +43,6 @@ 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';
@@ -277,14 +276,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
onEditHandler(record, { newTab: isModifierKeyPressed(e) });
};
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>
);
return <Typography.Link onClick={onClickHandler}>{value}</Typography.Link>;
},
sortOrder: sortedInfo.columnKey === 'name' ? sortedInfo.order : null,
},

View File

@@ -1,20 +0,0 @@
.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

@@ -1,43 +0,0 @@
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

@@ -19,9 +19,9 @@
letter-spacing: 0.5px;
}
&__divider {
--divider-color: var(--l1-border);
--divider-margin: 8px 0;
.ant-divider {
margin: 8px 0 !important;
border: 0.5px solid var(--l1-border);
}
.explorer-columns-contents {

View File

@@ -234,7 +234,7 @@ function ExplorerColumnsRenderer({
</Tooltip>
)}
</div>
<Divider className="explorer-columns-renderer__divider" />
<Divider />
{!isError && (
<div className="explorer-columns-contents">
<DragDropContext onDragEnd={onDragEnd}>

View File

@@ -19,6 +19,12 @@
}
}
.ant-modal-body {
.ant-divider {
margin: 16px 0;
border: 0.5px solid var(--l1-border);
}
}
.downtime-schedule-btn {
display: flex;
}

View File

@@ -445,6 +445,12 @@ 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,16 +1,15 @@
import React, { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import {
AlertmanagertypesMaintenanceStatusDTO,
type ListDowntimeSchedules200,
type RenderErrorResponseDTO,
type AlertmanagertypesPlannedMaintenanceDTO,
type AlertmanagertypesScheduleDTO,
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import cx from 'classnames';
@@ -33,50 +32,6 @@ 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;
@@ -128,13 +83,11 @@ 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 {
@@ -142,10 +95,9 @@ function HeaderComponent({
const isCrudEnabled = user?.role !== USER_ROLES.VIEWER;
return (
<Flex className="header-content" justify="space-between">
<Flex gap={8} align="center">
<Flex gap={8}>
<Typography>{name}</Typography>
<Badge color="vanilla">{duration}</Badge>
<StatusBadge status={status} />
</Flex>
{isCrudEnabled && (
@@ -232,9 +184,7 @@ export function CollapseListContent({
{renderItems(
'Timeframe',
schedule?.startTime ? (
<Typography>
{schedule?.endTime ? `${startTime}${endTime}` : `${startTime} onwards`}
</Typography>
<Typography>{`${startTime}${endTime}`}</Typography>
) : (
'-'
),
@@ -279,7 +229,6 @@ export function CustomCollapseList(
createdAt,
createdBy,
schedule,
status,
updatedAt,
updatedBy,
name,
@@ -308,7 +257,6 @@ export function CustomCollapseList(
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
}
name={defaultTo(name, '')}
status={status}
handleEdit={() => {
setInitialValues({ ...props });
setModalOpen(true);
@@ -382,11 +330,6 @@ 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

@@ -0,0 +1,18 @@
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export type QueryProps = {
index: number;
isAvailableToDisable: boolean;
query: IBuilderQuery;
queryVariant?: 'static' | 'dropdown';
isListViewPanel?: boolean;
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
showOnlyWhereClause?: boolean;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;
signalSource?: string;
isMultiQueryAllowed?: boolean;
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;

View File

@@ -0,0 +1,8 @@
.qb-search-container {
display: block;
position: relative;
}
.qb-container {
padding: 0 24px;
}

View File

@@ -0,0 +1,628 @@
/* eslint-disable sonarjs/cognitive-complexity */
// ** Hooks
import {
ChangeEvent,
memo,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import { useLocation } from 'react-use';
import { Col, Input, Row, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ENTITY_VERSION_V4 } from 'constants/app';
// ** Constants
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
// ** Components
import {
AdditionalFiltersToggler,
DataSourceDropdown,
FilterLabel,
} from 'container/QueryBuilder/components';
import {
AggregatorFilter,
GroupByFilter,
HavingFilter,
MetricNameSelector,
OperatorsSelect,
OrderByFilter,
ReduceToFilter,
} from 'container/QueryBuilder/filters';
import AggregateEveryFilter from 'container/QueryBuilder/filters/AggregateEveryFilter';
import LimitFilter from 'container/QueryBuilder/filters/LimitFilter/LimitFilter';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { transformToUpperCase } from 'utils/transformToUpperCase';
import QBEntityOptions from '../QBEntityOptions/QBEntityOptions';
import SpaceAggregationOptions from '../SpaceAggregationOptions/SpaceAggregationOptions';
// ** Types
import { QueryProps } from './Query.interfaces';
import './Query.styles.scss';
export const Query = memo(function Query({
index,
queryVariant,
query,
filterConfigs,
queryComponents,
isListViewPanel = false,
showFunctions = false,
version,
}: QueryProps): JSX.Element {
const { panelType, currentQuery, cloneQuery } = useQueryBuilder();
const { pathname } = useLocation();
const [isCollapse, setIsCollapsed] = useState(false);
const {
operators,
spaceAggregationOptions,
isMetricsDataSource,
isTracePanelType,
listOfAdditionalFilters,
handleChangeAggregatorAttribute,
handleChangeQueryData,
handleChangeDataSource,
handleChangeOperator,
handleSpaceAggregationChange,
handleDeleteQuery,
handleQueryFunctionsUpdates,
} = useQueryOperations({
index,
query,
filterConfigs,
isListViewPanel,
entityVersion: version,
});
const handleChangeAggregateEvery = useCallback(
(value: IBuilderQuery['stepInterval']) => {
handleChangeQueryData('stepInterval', value);
},
[handleChangeQueryData],
);
const handleChangeLimit = useCallback(
(value: IBuilderQuery['limit']) => {
handleChangeQueryData('limit', value);
},
[handleChangeQueryData],
);
const handleChangeHavingFilter = useCallback(
(value: IBuilderQuery['having']) => {
handleChangeQueryData('having', value);
},
[handleChangeQueryData],
);
const handleChangeOrderByKeys = useCallback(
(value: IBuilderQuery['orderBy']) => {
handleChangeQueryData('orderBy', value);
},
[handleChangeQueryData],
);
const handleToggleDisableQuery = useCallback(() => {
handleChangeQueryData('disabled', !query.disabled);
}, [handleChangeQueryData, query]);
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleChangeQueryData('filters', value);
},
[handleChangeQueryData],
);
const handleChangeReduceTo = useCallback(
(value: IBuilderQuery['reduceTo']) => {
handleChangeQueryData('reduceTo', value);
},
[handleChangeQueryData],
);
const handleChangeGroupByKeys = useCallback(
(value: IBuilderQuery['groupBy']) => {
handleChangeQueryData('groupBy', value);
},
[handleChangeQueryData],
);
const handleChangeQueryLegend = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
handleChangeQueryData('legend', event.target.value);
},
[handleChangeQueryData],
);
const handleToggleCollapsQuery = (): void => {
setIsCollapsed(!isCollapse);
};
const renderOrderByFilter = useCallback((): ReactNode => {
if (queryComponents?.renderOrderBy) {
return queryComponents.renderOrderBy({
query,
onChange: handleChangeOrderByKeys,
});
}
return (
<OrderByFilter
entityVersion={version}
query={query}
onChange={handleChangeOrderByKeys}
isListViewPanel={isListViewPanel}
/>
);
}, [
queryComponents,
query,
version,
handleChangeOrderByKeys,
isListViewPanel,
]);
const renderAggregateEveryFilter = useCallback(
(): JSX.Element | null =>
!filterConfigs?.stepInterval?.isHidden ? (
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Aggregate Every" />
</Col>
<Col flex="1 1 6rem">
<AggregateEveryFilter
query={query}
disabled={filterConfigs?.stepInterval?.isDisabled || false}
onChange={handleChangeAggregateEvery}
/>
</Col>
</Row>
) : null,
[
filterConfigs?.stepInterval?.isHidden,
filterConfigs?.stepInterval?.isDisabled,
query,
handleChangeAggregateEvery,
],
);
const isExplorerPage = useMemo(
() =>
pathname === ROUTES.LOGS_EXPLORER || pathname === ROUTES.TRACES_EXPLORER,
[pathname],
);
const renderAdditionalFilters = useCallback((): ReactNode => {
switch (panelType) {
case PANEL_TYPES.TIME_SERIES: {
return (
<>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Limit" />
</Col>
<Col flex="1 1 12.5rem">
<LimitFilter query={query} onChange={handleChangeLimit} />
</Col>
</Row>
</Col>
<Col span={24}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter
entityVersion={version}
onChange={handleChangeHavingFilter}
query={query}
/>
</Col>
</Row>
</Col>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Order by" />
</Col>
<Col flex="1 1 12.5rem">{renderOrderByFilter()}</Col>
</Row>
</Col>
<Col span={11}>{renderAggregateEveryFilter()}</Col>
</>
);
}
case PANEL_TYPES.VALUE: {
return (
<>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter
onChange={handleChangeHavingFilter}
entityVersion={version}
query={query}
/>
</Col>
</Row>
</Col>
<Col span={11}>{renderAggregateEveryFilter()}</Col>
</>
);
}
default: {
return (
<>
{!filterConfigs?.limit?.isHidden && (
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Limit" />
</Col>
<Col flex="1 1 12.5rem">
<LimitFilter query={query} onChange={handleChangeLimit} />
</Col>
</Row>
</Col>
)}
{!filterConfigs?.having?.isHidden && (
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter
entityVersion={version}
onChange={handleChangeHavingFilter}
query={query}
/>
</Col>
</Row>
</Col>
)}
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Order by" />
</Col>
<Col flex="1 1 12.5rem">{renderOrderByFilter()}</Col>
</Row>
</Col>
<Col span={11}>{renderAggregateEveryFilter()}</Col>
</>
);
}
}
}, [
panelType,
query,
handleChangeLimit,
version,
handleChangeHavingFilter,
renderOrderByFilter,
renderAggregateEveryFilter,
filterConfigs?.limit?.isHidden,
filterConfigs?.having?.isHidden,
]);
const disableOperatorSelector =
!query?.aggregateAttribute?.key || query?.aggregateAttribute?.key === '';
const isVersionV4 = version && version === ENTITY_VERSION_V4;
return (
<Row gutter={[0, 12]} className={`query-builder-${version}`}>
<QBEntityOptions
isMetricsDataSource={isMetricsDataSource}
showFunctions={
(version && version === ENTITY_VERSION_V4) ||
query.dataSource === DataSource.LOGS ||
showFunctions ||
false
}
isCollapsed={isCollapse}
entityType="query"
entityData={query}
onToggleVisibility={handleToggleDisableQuery}
onDelete={handleDeleteQuery}
onCloneQuery={cloneQuery}
onCollapseEntity={handleToggleCollapsQuery}
query={query}
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
showDeleteButton={currentQuery.builder.queryData.length > 1}
isListViewPanel={isListViewPanel}
index={index}
queryVariant={queryVariant}
/>
{!isCollapse && (
<Row gutter={[0, 12]} className="qb-container">
<Col span={24}>
<Row align="middle" gutter={[5, 11]}>
{!isExplorerPage && (
<Col>
{queryVariant === 'dropdown' ? (
<DataSourceDropdown
onChange={handleChangeDataSource}
value={query.dataSource}
style={{ minWidth: '5.625rem' }}
isListViewPanel={isListViewPanel}
/>
) : (
<FilterLabel label={transformToUpperCase(query.dataSource)} />
)}
</Col>
)}
{isMetricsDataSource && (
<Col span={12}>
<Row gutter={[11, 5]}>
{version && version === 'v3' && (
<Col flex="5.93rem">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Select Aggregate Operator
<Typography.Link
className="learn-more"
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#aggregation"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<OperatorsSelect
value={query.aggregateOperator || ''}
onChange={handleChangeOperator}
operators={operators}
/>
</Tooltip>
</Col>
)}
<Col flex="auto">
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
/>
</Col>
{version &&
version === ENTITY_VERSION_V4 &&
operators &&
Array.isArray(operators) &&
operators.length > 0 && (
<Col flex="5.93rem">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Select Aggregate Operator
<Typography.Link
className="learn-more"
href="https://signoz.io/docs/metrics-management/types-and-aggregation/?utm_source=product&utm_medium=query-builder#aggregation"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<OperatorsSelect
value={query.aggregateOperator || ''}
onChange={handleChangeOperator}
operators={operators}
disabled={disableOperatorSelector}
/>
</Tooltip>
</Col>
)}
</Row>
</Col>
)}
<Col flex="1 1 40rem">
<Row gutter={[11, 5]}>
{isMetricsDataSource && (
<Col>
<FilterLabel label="WHERE" />
</Col>
)}
<Col flex="1" className="qb-search-container">
{[DataSource.LOGS, DataSource.TRACES].includes(query.dataSource) ? (
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}
whereClauseConfig={filterConfigs?.filters}
hideSpanScopeSelector={query.dataSource !== DataSource.TRACES}
/>
) : (
<QueryBuilderSearch
query={query}
onChange={handleChangeTagFilters}
whereClauseConfig={filterConfigs?.filters}
/>
)}
</Col>
</Row>
</Col>
</Row>
</Col>
{!isMetricsDataSource && !isListViewPanel && (
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Select Aggregate Operator
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#aggregation"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<OperatorsSelect
value={query.aggregateOperator || ''}
onChange={handleChangeOperator}
operators={operators}
/>
</Tooltip>
</Col>
<Col flex="1 1 12.5rem">
<AggregatorFilter
query={query}
onChange={handleChangeAggregatorAttribute}
disabled={
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE
}
/>
</Col>
</Row>
</Col>
)}
{!isListViewPanel && (
<Col span={24}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
{isVersionV4 && isMetricsDataSource ? (
<SpaceAggregationOptions
panelType={panelType}
key={`${panelType}${query.spaceAggregation}${query.timeAggregation}`}
aggregatorAttributeType={
query?.aggregateAttribute?.type as ATTRIBUTE_TYPES
}
selectedValue={query.spaceAggregation}
disabled={disableOperatorSelector}
onSelect={handleSpaceAggregationChange}
operators={spaceAggregationOptions}
/>
) : (
<FilterLabel
label={panelType === PANEL_TYPES.VALUE ? 'Reduce to' : 'Group by'}
/>
)}
</Col>
<Col flex="1 1 12.5rem">
{panelType === PANEL_TYPES.VALUE ? (
<Row>
{isVersionV4 && isMetricsDataSource && (
<Col span={4}>
<FilterLabel label="Reduce to" />
</Col>
)}
<Col span={isVersionV4 && isMetricsDataSource ? 20 : 24}>
<ReduceToFilter query={query} onChange={handleChangeReduceTo} />
</Col>
</Row>
) : (
<GroupByFilter
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
query={query}
onChange={handleChangeGroupByKeys}
/>
)}
</Col>
{isVersionV4 &&
isMetricsDataSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE) && (
<Col flex="1 1 12.5rem">
<Row>
<Col span={6}>
<FilterLabel label="Reduce to" />
</Col>
<Col span={18}>
<ReduceToFilter query={query} onChange={handleChangeReduceTo} />
</Col>
</Row>
</Col>
)}
</Row>
</Col>
)}
{!isTracePanelType && !isListViewPanel && (
<Col span={24}>
<AdditionalFiltersToggler
listOfAdditionalFilter={listOfAdditionalFilters}
>
<Row gutter={[0, 11]} justify="space-between">
{renderAdditionalFilters()}
</Row>
</AdditionalFiltersToggler>
</Col>
)}
{isListViewPanel && (
<Col span={24}>
<Row gutter={[0, 11]} justify="space-between">
{renderAdditionalFilters()}
</Row>
</Col>
)}
{panelType !== PANEL_TYPES.LIST && panelType !== PANEL_TYPES.TRACE && (
<Row style={{ width: '100%' }}>
<Tooltip
placement="right"
title={
<div style={{ textAlign: 'center' }}>
Name of legend
<Typography.Link
style={{ textDecoration: 'underline' }}
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#legend-format"
target="_blank"
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Input
onChange={handleChangeQueryLegend}
size="middle"
value={query.legend}
addonBefore="Legend Format"
/>
</Tooltip>
</Row>
)}
</Row>
)}
</Row>
);
});

View File

@@ -0,0 +1 @@
export { Query } from './Query';

View File

@@ -5,3 +5,4 @@ export { Formula } from './Formula';
export { HavingFilterTag } from './HavingFilterTag';
export { ListItemWrapper } from './ListItemWrapper';
export { ListMarker } from './ListMarker';
export { Query } from './Query';

View File

@@ -1,6 +1,4 @@
import { IQueryBuilderState } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface InitialStateI {
search: string;
@@ -20,19 +18,3 @@ export type Option = {
isIndexed?: boolean;
type?: string;
};
export type QueryProps = {
index: number;
isAvailableToDisable: boolean;
query: IBuilderQuery;
queryVariant?: 'static' | 'dropdown';
isListViewPanel?: boolean;
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
showOnlyWhereClause?: boolean;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;
signalSource?: string;
isMultiQueryAllowed?: boolean;
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;

View File

@@ -191,6 +191,13 @@
line-height: 20px;
}
}
.ant-modal-body {
.ant-divider {
margin: 16px 0;
border: 0.5px solid var(--l1-border);
}
}
}
.create-policy-container {

View File

@@ -15,8 +15,11 @@
}
}
&__divider {
--divider-vertical-margin: 10px;
.ant-divider {
margin-inline-start: 10px !important;
margin-inline-end: 16px !important;
height: 16px;
border-color: var(--l1-border);
}
.ant-drawer-close {
margin: 0 !important;

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
@@ -179,10 +179,7 @@ function SpanRelatedSignals({
width="50%"
title={
<>
<Divider
type="vertical"
className="span-related-signals-drawer__divider"
/>
<Divider type="vertical" />
<Typography.Text className="title">
Related Signals - {selectedSpan.name}
</Typography.Text>
@@ -197,7 +194,7 @@ function SpanRelatedSignals({
}}
className="span-related-signals-drawer"
destroyOnClose
closeIcon={<X size={16} />}
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{selectedSpan && (
<div className="span-related-signals-drawer__content">

View File

@@ -41,9 +41,9 @@
}
.alert-details {
&__divider {
--divider-color: var(--l1-border);
--divider-margin: 16px 0;
.divider {
border-color: var(--l1-border);
margin: 16px 0;
}
.breadcrumb-divider {
margin-top: 10px;

View File

@@ -104,7 +104,7 @@ function AlertDetails(): JSX.Element {
/>
{alertRuleDetails && <AlertHeader alertDetails={alertRuleDetails} />}
<Divider className="alert-details__divider" />
<Divider className="divider" />
<div className="tabs-and-filters">
<RouteTab
routes={routes}

View File

@@ -3,9 +3,10 @@
align-items: center;
gap: 12px;
color: var(--l1-border);
&__divider {
--divider-color: var(--l1-border);
--divider-vertical-margin: 0;
.ant-divider-vertical {
height: 16px;
border-color: var(--l1-border);
margin: 0;
}
.dropdown-trigger-wrapper {
display: flex;
@@ -13,20 +14,6 @@
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,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, 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 {
@@ -17,13 +18,6 @@ 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';
@@ -113,80 +107,22 @@ function AlertActionButtons({
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => (): void => setAlertRuleState(undefined), []);
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;
const toggleAlertRule = useCallback(() => {
setIsAlertRuleDisabled((prev) => !prev);
handleAlertStateToggle();
}, [handleAlertStateToggle]);
return (
<>
<div className="alert-action-buttons">
{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>
)}
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" className="alert-action-buttons__divider" />
<Divider type="vertical" />
<DropdownMenuSimple menu={{ items: menuItems }}>
<span className="dropdown-trigger-wrapper">
@@ -205,14 +141,6 @@ 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,13 +1,3 @@
.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,9 +12,6 @@ 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';
@@ -46,13 +43,6 @@ 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">
@@ -77,23 +67,14 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
);
return (
<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 className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}
ruleId={alertDetails?.id || ''}
/>
</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

@@ -1,102 +0,0 @@
.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

@@ -1,86 +0,0 @@
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

@@ -1,56 +0,0 @@
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

@@ -1,193 +0,0 @@
.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

@@ -1,261 +0,0 @@
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

@@ -1,125 +0,0 @@
.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

@@ -1,258 +0,0 @@
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

@@ -1,104 +0,0 @@
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

@@ -1,98 +0,0 @@
.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

@@ -1,30 +0,0 @@
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

@@ -1,92 +0,0 @@
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,10 +7,11 @@ 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_TAB = 'PlannedDowntime';
const ROUTING_POLICIES_TAB = 'RoutingPolicies';
const PLANNED_DOWNTIME_SUB_TAB = 'planned-downtime';
const ROUTING_POLICIES_SUB_TAB = 'routing-policies';
const mockUseLocation = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -121,7 +122,7 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render all four top-level tabs', () => {
it('should render all three main tabs', () => {
mockQueryParams({});
mockLocation(ALERTS_PATH);
@@ -129,8 +130,7 @@ describe('AlertList', () => {
expect(screen.getByText(TRIGGERED_ALERTS_TEXT)).toBeInTheDocument();
expect(screen.getByText(ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
expect(screen.getByText(CONFIGURATION_TEXT)).toBeInTheDocument();
});
});
@@ -153,22 +153,13 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render PlannedDowntime tab when tab query param is PlannedDowntime', () => {
mockQueryParams({ tab: PLANNED_DOWNTIME_TAB });
it('should render Configuration tab with default Planned Downtime sub-tab when tab query param is Configuration', () => {
mockQueryParams({ tab: 'Configuration' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
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();
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
});
it('should navigate to TriggeredAlerts tab when clicked', () => {
@@ -184,32 +175,89 @@ describe('AlertList', () => {
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=TriggeredAlerts');
});
it('should navigate to PlannedDowntime tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
it('should navigate to AlertRules tab when clicked', () => {
mockQueryParams({ tab: 'TriggeredAlerts' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(PLANNED_DOWNTIME_TEXT);
clickTab(ALERT_RULES_TEXT);
expect(mockSet).toHaveBeenCalledWith('tab', PLANNED_DOWNTIME_TAB);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${PLANNED_DOWNTIME_TAB}`,
);
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();
});
});
it('should navigate to RoutingPolicies tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
describe('Navigation', () => {
it('should navigate to Configuration tab with default subTab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
render(<AlertList />);
clickTab(ROUTING_POLICIES_TEXT);
clickTab(CONFIGURATION_TEXT);
expect(mockSet).toHaveBeenCalledWith('tab', ROUTING_POLICIES_TAB);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${ROUTING_POLICIES_TAB}`,
);
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');
});
});
});
});

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
@@ -9,10 +10,10 @@ import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import { AlertListTabs } from './types';
import { AlertListSubTabs, AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -22,9 +23,43 @@ 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: (
@@ -50,25 +85,15 @@ function AllAlertList(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.PLANNED_DOWNTIME,
children: <PlannedDowntime />,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
Configuration
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
children: <RoutingPolicies />,
key: AlertListTabs.CONFIGURATION,
children: configurationTab,
},
];
@@ -77,10 +102,21 @@ function AllAlertList(): JSX.Element {
destroyInactiveTabPane
items={items}
activeKey={tab || AlertListTabs.ALERT_RULES}
onChange={(nextTab): void => {
urlQuery.set('tab', nextTab);
urlQuery.delete('subTab');
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
urlQuery.delete('search');
safeNavigate(`/alerts?${urlQuery.toString()}`);
}}
className={`alerts-container ${

View File

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

View File

@@ -47,9 +47,10 @@
}
}
.section-body__divider {
--divider-color: var(--l1-border);
--divider-margin: 0;
.divider {
background-color: var(--l1-border);
margin: 0;
border-color: var(--l1-border);
}
.filter-header {

View File

@@ -65,7 +65,7 @@ export function Section(props: SectionProps): JSX.Element {
return (
<div>
<Divider plain className="section-body__divider" />
<Divider plain className="divider" />
<div className="section-body-header" data-testid={`collapse-${panelName}`}>
<Collapse
bordered={false}

View File

@@ -24,6 +24,9 @@
align-items: center;
}
gap: 12px;
.ant-divider-vertical {
margin: 0;
}
.funnel-configuration__rename-btn {
padding: 4px;
width: 24px;
@@ -71,7 +74,4 @@
gap: 12px;
padding: 16px;
}
&__divider {
--divider-margin: 0px;
}
}

View File

@@ -77,7 +77,7 @@ function FunnelConfiguration({
/>
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" className="funnel-configuration__divider" />
<Divider type="vertical" />
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}

View File

@@ -94,6 +94,9 @@
display: flex;
align-items: center;
}
.ant-divider-vertical {
margin: 0 12px;
}
.funnel-item__action-btn {
border: none;
padding: 4px;

View File

@@ -26,7 +26,10 @@
}
&__divider {
width: 100%;
--divider-margin: 0;
.ant-divider {
margin: 0;
border-color: var(--l1-border);
}
}
&__latency-options {
flex-shrink: 0;

View File

@@ -17,6 +17,14 @@
flex-shrink: 0;
}
&__divider {
width: 100%;
.ant-divider {
margin: 0;
border-color: var(--l1-border);
}
}
&__time-range {
width: 100%;
height: 32px;

View File

@@ -1,4 +1,4 @@
import { QueryProps } from 'container/QueryBuilder/type';
import { QueryProps } from 'container/QueryBuilder/components/Query/Query.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {

View File

@@ -150,29 +150,17 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
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
}
_, 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")
}
_, err = r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
})
return nil
}
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {

View File

@@ -29,23 +29,15 @@ 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, schedules))
view = append(view, ruletypes.NewRule(rule))
}
render.Success(rw, http.StatusOK, view)
@@ -55,12 +47,6 @@ 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"))
@@ -73,9 +59,7 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
return
}
schedules, _ := handler.ruler.MaintenanceStore().ListPlannedMaintenance(ctx, claims.OrgID)
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, schedules))
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
}
func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
@@ -95,7 +79,7 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule, nil))
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
@@ -166,7 +150,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
return
}
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule, nil))
render.Success(rw, http.StatusOK, ruletypes.NewRule(rule))
}
func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {

View File

@@ -3,7 +3,6 @@ package alertmanagertypes
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/expr-lang/expr"
@@ -82,11 +81,6 @@ 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.
@@ -154,12 +148,6 @@ 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.
//
@@ -181,7 +169,26 @@ func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
if !m.AppliesTo(ruleID) || !m.IsActive(now) {
// 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) {
return false, nil
}
@@ -199,27 +206,39 @@ 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 nil
return false
}
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 nil
return false
}
// Check if recurrence has expired
if recurrence.EndTime != nil && !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return nil
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return false
}
}
currentTime := now.In(loc)
@@ -230,32 +249,15 @@ func (m *PlannedMaintenance) ActiveWindow(now time.Time) *Window {
return m.checkWeekly(currentTime, recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
default:
// unreachable: invalid repeat type
return nil
}
}
// 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
return false
}
// 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) *Window {
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
@@ -264,17 +266,13 @@ func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence,
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
}
return nil
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
// 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) *Window {
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, rec, loc)
@@ -298,16 +296,15 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
candidate = candidate.AddDate(0, 0, -7)
}
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
return true
}
}
return nil
return false
}
// 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) *Window {
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
@@ -336,26 +333,22 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
)
}
}
if currentTime.Sub(candidate) <= rec.Duration.Duration() {
end := candidate.Add(rec.Duration.Duration())
return &Window{candidate, &end}
}
return nil
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
func (m *PlannedMaintenance) IsUpcoming() bool {
now := time.Now()
if m.Schedule.Recurrence != nil {
// 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)
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
// Fixed schedule
if !m.Schedule.StartTime.IsZero() {
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)
}
return false
}
@@ -411,10 +404,10 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
}
var kind MaintenanceKind
if m.Schedule.Recurrence != nil {
kind = MaintenanceKindRecurring
} else {
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = MaintenanceKindFixed
} else {
kind = MaintenanceKindRecurring
}
return json.Marshal(struct {

View File

@@ -441,39 +441,6 @@ 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,83 +642,24 @@ 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"`
Mutes []ActiveMute `json:"mutes"`
Id string `json:"id" required:"true"`
State AlertState `json:"state" required:"true"`
PostableRule
types.TimeAuditable
types.UserAuditable
}
func NewRule(g *GettableRule, maintenances []*alertmanagertypes.PlannedMaintenance) *Rule {
func NewRule(g *GettableRule) *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 {