Compare commits

..

2 Commits

Author SHA1 Message Date
Ekansh Gupta
2f1bd624e6 Merge branch 'main' into thirdpartylistapifix 2025-09-28 22:11:29 +05:30
eKuG
a45f427533 feat: fixed the third party api based on the column availability in the database 2025-09-28 22:09:16 +05:30
120 changed files with 1051 additions and 6511 deletions

View File

@@ -26,6 +26,10 @@ type resources
define create: [user, role#assignee]
define list: [user, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resource
relations
define read: [user, anonymous, role#assignee]

View File

@@ -107,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
}
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -115,13 +115,13 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return
}
selector, err := cb(req.Context(), claims)
selector, parentSelectors, err := cb(req)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector)
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
if err != nil {
render.Error(rw, err)
return

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
AlertRuleV2,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
export interface CreateAlertRuleResponse {
data: AlertRuleV2;
status: string;
}
const createAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<CreateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/rules`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default createAlertRule;

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface TestAlertRuleResponse {
data: {
alertCount: number;
message: string;
};
status: string;
}
const testAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<TestAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/testRule`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default testAlertRule;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface UpdateAlertRuleResponse {
data: string;
status: string;
}
const updateAlertRule = async (
id: string,
postableAlertRule: PostableAlertRuleV2,
): Promise<SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.put(`/rules/${id}`, {
...postableAlertRule,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateAlertRule;

View File

@@ -6,7 +6,9 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface CreateRoutingPolicyBody {
name: string;
expression: string;
channels: string[];
actions: {
channels: string[];
};
description?: string;
}
@@ -21,7 +23,7 @@ const createRoutingPolicy = async (
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.post(`/route_policies`, props);
const response = await axios.post(`/notification-policy`, props);
return {
httpStatusCode: response.status,
data: response.data,

View File

@@ -14,7 +14,9 @@ const deleteRoutingPolicy = async (
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.delete(`/route_policies/${routingPolicyId}`);
const response = await axios.delete(
`/notification-policy/${routingPolicyId}`,
);
return {
httpStatusCode: response.status,

View File

@@ -25,7 +25,7 @@ export const getRoutingPolicies = async (
headers?: Record<string, string>,
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
try {
const response = await axios.get('/route_policies', {
const response = await axios.get('/notification-policy', {
signal,
headers,
});

View File

@@ -6,7 +6,9 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface UpdateRoutingPolicyBody {
name: string;
expression: string;
channels: string[];
actions: {
channels: string[];
};
description: string;
}
@@ -22,7 +24,7 @@ const updateRoutingPolicy = async (
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.put(`/route_policies/${id}`, {
const response = await axios.put(`/notification-policy/${id}`, {
...props,
});

View File

@@ -634,260 +634,4 @@ describe('prepareQueryRangePayloadV5', () => {
}),
);
});
it('builds payload for builder queries with filters array but no filter expression', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q8',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: '' },
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'payment-service',
},
{
id: '2',
key: { key: 'http.status_code', type: 'number' },
op: '>=',
value: 400,
},
{
id: '3',
key: { key: 'message', type: 'string' },
op: 'contains',
value: 'error',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
expect(result.legendMap).toEqual({ A: 'Legend A' });
expect(result.queryPayload.compositeQuery.queries).toHaveLength(1);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.name).toBe('A');
expect(logSpec.signal).toBe('logs');
expect(logSpec.filter).toEqual({
expression:
"service.name = 'payment-service' AND http.status_code >= 400 AND message contains 'error'",
});
});
it('uses filter.expression when only expression is provided', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q9',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: 'http.status_code >= 500' },
filters: (undefined as unknown) as IBuilderQuery['filters'],
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: 'http.status_code >= 500' });
});
it('derives expression from filters when filter is undefined', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q10',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: (undefined as unknown) as IBuilderQuery['filter'],
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'checkout',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: "service.name = 'checkout'" });
});
it('prefers filter.expression over filters when both are present', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q11',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: "service.name = 'frontend'" },
filters: {
items: [
{
id: '1',
key: { key: 'service.name', type: 'string' },
op: '=',
value: 'backend',
},
],
op: 'AND',
},
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: "service.name = 'frontend'" });
});
it('returns empty expression when neither filter nor filters provided', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q12',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: (undefined as unknown) as IBuilderQuery['filter'],
filters: (undefined as unknown) as IBuilderQuery['filters'],
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: '' });
});
it('returns empty expression when filters provided with empty items', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.QUERY_BUILDER,
id: 'q13',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
baseBuilderQuery({
dataSource: DataSource.LOGS,
filter: { expression: '' },
filters: { items: [], op: 'AND' },
}),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const result = prepareQueryRangePayloadV5(props);
const builderQuery = result.queryPayload.compositeQuery.queries.find(
(q) => q.type === 'builder_query',
) as QueryEnvelope;
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toEqual({ expression: '' });
});
});

View File

@@ -1,6 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable sonarjs/no-identical-functions */
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
@@ -15,7 +14,6 @@ import {
BaseBuilderQuery,
FieldContext,
FieldDataType,
Filter,
FunctionName,
GroupByKey,
Having,
@@ -113,23 +111,6 @@ function isDeprecatedField(fieldName: string): boolean {
);
}
function getFilter(queryData: IBuilderQuery): Filter {
const { filter } = queryData;
if (filter?.expression) {
return {
expression: filter.expression,
};
}
if (queryData.filters && queryData.filters?.items?.length > 0) {
return convertFiltersToExpression(queryData.filters);
}
return {
expression: '',
};
}
function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
@@ -143,7 +124,7 @@ function createBaseSpec(
return {
stepInterval: queryData?.stepInterval || null,
disabled: queryData.disabled,
filter: getFilter(queryData),
filter: queryData?.filter?.expression ? queryData.filter : undefined,
groupBy:
queryData.groupBy?.length > 0
? queryData.groupBy.map(

View File

@@ -42,31 +42,18 @@ export function useNavigateToExplorer(): (
builder: {
...widgetQuery.builder,
queryData: widgetQuery.builder.queryData
.map((item) => {
// filter out filters with unique ids
const seen = new Set();
const filterItems = [
...(item.filters?.items || []),
...selectedFilters,
].filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
return {
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: filterItems,
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
};
})
.map((item) => ({
...item,
dataSource,
aggregateOperator: MetricAggregateOperator.NOOP,
filters: {
...item.filters,
items: [...(item.filters?.items || []), ...selectedFilters],
op: item.filters?.op || 'AND',
},
groupBy: [],
disabled: false,
}))
.slice(0, 1),
queryFormulas: [],
},

View File

@@ -2,7 +2,6 @@ import { Form, Row } from 'antd';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
@@ -126,16 +125,6 @@ function CreateRules(): JSX.Element {
);
}
const showNewCreateAlertsPageFlag =
queryParams.get('showNewCreateAlertsPage') === 'true';
if (
showNewCreateAlertsPageFlag &&
alertType !== AlertTypes.ANOMALY_BASED_ALERT
) {
return <CreateAlertV2 alertType={alertType} />;
}
return (
<FormAlertRules
alertType={alertType}

View File

@@ -1,14 +1,9 @@
import './styles.scss';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from 'lucide-react';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -22,16 +17,6 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const {
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
@@ -42,16 +27,15 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {
@@ -92,22 +76,8 @@ function AlertCondition(): JSX.Element {
))}
</div>
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<AlertThreshold
channels={channels}
isLoadingChannels={isLoadingChannels}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
)}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && (
<AnomalyThreshold
channels={channels}
isLoadingChannels={isLoadingChannels}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
)}
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />

View File

@@ -1,11 +1,14 @@
import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Tooltip, Typography } from 'antd';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react';
import { useEffect } from 'react';
import { useQuery } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import {
@@ -18,48 +21,32 @@ import {
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import { UpdateThreshold } from './types';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
getMatchTypeTooltip,
getQueryNames,
RoutingPolicyBanner,
} from './utils';
function AlertThreshold({
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
function AlertThreshold(): JSX.Element {
const {
alertState,
thresholdState,
setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { data, isLoading: isLoadingChannels } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
useEffect(() => {
if (
queryNames.length > 0 &&
!queryNames.some((query) => query.value === thresholdState.selectedQuery)
) {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: queryNames[0].value,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryNames, thresholdState.selectedQuery]);
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory || '',
@@ -98,65 +85,6 @@ function AlertThreshold({
});
};
const onTooltipOpenChange = (open: boolean): void => {
// Stop propagation of click events on tooltip text to dropdown
if (open) {
setTimeout(() => {
const tooltipElement = document.querySelector(
'.copyable-tooltip .ant-tooltip-inner',
);
if (tooltipElement) {
tooltipElement.addEventListener(
'click',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true,
);
tooltipElement.addEventListener(
'mousedown',
(e) => {
e.stopPropagation();
e.preventDefault();
},
true,
);
}
}, 0);
}
};
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
(option) => ({
...option,
label: (
<Tooltip
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
placement="left"
overlayClassName="copyable-tooltip"
overlayStyle={{
maxWidth: '450px',
minWidth: '400px',
}}
overlayInnerStyle={{
padding: '12px 16px',
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text',
}}
mouseEnterDelay={0.2}
trigger={['hover', 'click']}
destroyTooltipOnHide={false}
onOpenChange={onTooltipOpenChange}
>
<span style={{ display: 'block', width: '100%' }}>{option.label}</span>
</Tooltip>
),
}),
);
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
@@ -186,7 +114,8 @@ function AlertThreshold({
style={{ width: 80 }}
options={queryNames}
/>
<Typography.Text className="sentence-text">is</Typography.Text>
</div>
<div className="alert-condition-sentence">
<Select
value={thresholdState.operator}
onChange={(value): void => {
@@ -195,7 +124,7 @@ function AlertThreshold({
payload: value,
});
}}
style={{ width: 180 }}
style={{ width: 120 }}
options={THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text className="sentence-text">
@@ -209,8 +138,8 @@ function AlertThreshold({
payload: value,
});
}}
style={{ width: 180 }}
options={matchTypeOptionsWithTooltips}
style={{ width: 140 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text className="sentence-text">
during the {evaluationWindowContext}
@@ -229,8 +158,6 @@ function AlertThreshold({
channels={channels}
isLoadingChannels={isLoadingChannels}
units={categorySelectOptions}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/>
))}
<Button
@@ -242,11 +169,6 @@ function AlertThreshold({
Add Threshold
</Button>
</div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { Select, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
@@ -11,26 +10,10 @@ import {
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
NotificationChannelsNotFoundContent,
RoutingPolicyBanner,
} from './utils';
import { getQueryNames } from './utils';
function AnomalyThreshold({
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { user } = useAppContext();
const {
thresholdState,
setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
function AnomalyThreshold(): JSX.Element {
const { thresholdState, setThresholdState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
@@ -44,11 +27,7 @@ function AnomalyThreshold({
return options;
}, []);
const updateThreshold = (
id: string,
field: string,
value: string | string[],
): void => {
const updateThreshold = (id: string, field: string, value: string): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) =>
@@ -74,6 +53,7 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={queryNames}
/>
<Typography.Text
@@ -91,11 +71,12 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_TIME_DURATION_OPTIONS}
/>
</div>
{/* Sentence 2 */}
<div className="alert-condition-sentence">
{/* Sentence 2 */}
<Typography.Text data-testid="threshold-text" className="sentence-text">
is
</Typography.Text>
@@ -109,6 +90,7 @@ function AnomalyThreshold({
value.toString(),
);
}}
style={{ width: 80 }}
options={deviationOptions}
/>
<Typography.Text data-testid="deviations-text" className="sentence-text">
@@ -123,6 +105,7 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
/>
<Typography.Text
@@ -140,6 +123,7 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/>
</div>
@@ -157,6 +141,7 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_ALGORITHM_OPTIONS}
/>
<Typography.Text
@@ -174,58 +159,14 @@ function AnomalyThreshold({
payload: value,
});
}}
style={{ width: 80 }}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"
className="sentence-text"
>
seasonality to
</Typography.Text>
<Select
value={thresholdState.thresholds[0].channels}
onChange={(value): void =>
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={
<NotificationChannelsNotFoundContent
user={user}
refreshChannels={refreshChannels}
/>
}
/>
</>
) : (
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
)}
<Typography.Text data-testid="seasonality-text" className="sentence-text">
seasonality
</Typography.Text>
</div>
</div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div>
);
}

View File

@@ -1,12 +1,8 @@
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { CircleX, Trash } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { ThresholdItemProps } from './types';
import { NotificationChannelsNotFoundContent } from './utils';
function ThresholdItem({
threshold,
@@ -15,12 +11,7 @@ function ThresholdItem({
showRemoveButton,
channels,
units,
isErrorChannels,
refreshChannels,
isLoadingChannels,
}: ThresholdItemProps): JSX.Element {
const { user } = useAppContext();
const { thresholdState, notificationSettings } = useCreateAlertState();
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => {
@@ -54,31 +45,6 @@ function ThresholdItem({
return component;
}, [units, threshold.unit, updateThreshold, threshold.id]);
const getOperatorSymbol = (): string => {
switch (thresholdState.operator) {
case AlertThresholdOperator.IS_ABOVE:
return '>';
case AlertThresholdOperator.IS_BELOW:
return '<';
case AlertThresholdOperator.IS_EQUAL_TO:
return '=';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return '!=';
default:
return '';
}
};
// const addRecoveryThreshold = (): void => {
// setShowRecoveryThreshold(true);
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
// };
const removeRecoveryThreshold = (): void => {
setShowRecoveryThreshold(false);
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
};
return (
<div key={threshold.id} className="threshold-item">
<div className="threshold-row">
@@ -88,111 +54,80 @@ function ThresholdItem({
style={{ backgroundColor: threshold.color }}
/>
</div>
<div className="threshold-controls">
<Input
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 200 }}
/>
<Typography.Text className="sentence-text">on value</Typography.Text>
<Typography.Text className="sentence-text highlighted-text">
{getOperatorSymbol()}
</Typography.Text>
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
{yAxisUnitSelect}
{!notificationSettings.routingPolicies && (
<>
<Typography.Text className="sentence-text">send to</Typography.Text>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.name,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={2}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={
<NotificationChannelsNotFoundContent
user={user}
refreshChannels={refreshChannels}
/>
}
/>
</>
)}
{showRecoveryThreshold && (
<>
<Typography.Text className="sentence-text">recover on</Typography.Text>
<Space className="threshold-controls">
<div className="threshold-inputs">
<Input.Group>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue ?? ''}
placeholder="Enter threshold name"
value={threshold.label}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 100 }}
type="number"
style={{ width: 260 }}
/>
<Tooltip title="Remove recovery threshold">
<Button
type="default"
icon={<Trash size={16} />}
onClick={removeRecoveryThreshold}
className="icon-btn"
/>
</Tooltip>
</>
)}
<Input
placeholder="Enter threshold value"
value={threshold.thresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
{yAxisUnitSelect}
</Input.Group>
</div>
<Typography.Text className="sentence-text">to</Typography.Text>
<Select
value={threshold.channels}
onChange={(value): void =>
updateThreshold(threshold.id, 'channels', value)
}
style={{ width: 260 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
/>
<Button.Group>
{/* TODO: Add recovery threshold back once the functionality is implemented */}
{/* {!showRecoveryThreshold && (
<Tooltip title="Add recovery threshold">
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={addRecoveryThreshold}
/>
</Tooltip>
)} */}
{!showRecoveryThreshold && (
<Button
type="default"
icon={<ChartLine size={16} />}
className="icon-btn"
onClick={(): void => setShowRecoveryThreshold(true)}
/>
)}
{showRemoveButton && (
<Tooltip title="Remove threshold">
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
</Tooltip>
<Button
type="default"
icon={<CircleX size={16} />}
onClick={(): void => removeThreshold(threshold.id)}
className="icon-btn"
/>
)}
</Button.Group>
</div>
</Space>
</div>
{showRecoveryThreshold && (
<Input.Group className="recovery-threshold-input-group">
<Input
placeholder="Recovery threshold"
disabled
style={{ width: 260 }}
className="recovery-threshold-label"
/>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 210 }}
/>
</Input.Group>
)}
</div>
);
}

View File

@@ -3,7 +3,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -106,7 +105,7 @@ const renderAlertCondition = (
return render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertProvider>
<AlertCondition />
</CreateAlertProvider>
</QueryClientProvider>
@@ -127,10 +126,9 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -138,8 +136,7 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -154,8 +151,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
@@ -169,8 +165,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -181,8 +176,7 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
it('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -207,8 +201,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -229,21 +222,21 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
renderAlertCondition('ANOMALY_BASED_ALERT');
// Both tabs should be visible
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -3,23 +3,11 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
const mockProps = {
channels: mockChannels,
isLoadingChannels: mockIsLoadingChannels,
isErrorChannels: mockIsErrorChannels,
refreshChannels: mockRefreshChannels,
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -108,15 +96,10 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
]),
}));
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
showCondensedLayout: jest.fn().mockReturnValue(false),
}));
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'ABOVE',
IS_ABOVE: 'IS ABOVE',
} as const;
const createTestQueryClient = (): QueryClient =>
@@ -133,8 +116,8 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertThreshold {...mockProps} />
<CreateAlertProvider>
<AlertThreshold />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
@@ -142,10 +125,7 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
};
const verifySelectRenders = (title: string): void => {
let select = screen.queryByTitle(title);
if (!select) {
select = screen.getByText(title);
}
const select = screen.getByTitle(title);
expect(select).toBeInTheDocument();
};
@@ -209,11 +189,11 @@ describe('AlertThreshold', () => {
// First addition should add WARNING threshold
fireEvent.click(addButton);
expect(screen.getByText('warning')).toBeInTheDocument();
expect(screen.getByText('WARNING')).toBeInTheDocument();
// Second addition should add INFO threshold
fireEvent.click(addButton);
expect(screen.getByText('info')).toBeInTheDocument();
expect(screen.getByText('INFO')).toBeInTheDocument();
// Third addition should add random threshold
fireEvent.click(addButton);
@@ -285,7 +265,7 @@ describe('AlertThreshold', () => {
renderAlertThreshold();
// Should have initial critical threshold
expect(screen.getByText('critical')).toBeInTheDocument();
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});

View File

@@ -1,15 +1,14 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import {
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
} from 'container/CreateAlertV2/context/constants';
import * as context from '../../context';
import AnomalyThreshold from '../AnomalyThreshold';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -24,12 +23,12 @@ jest.mock('uplot', () => {
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
alertState: INITIAL_ALERT_STATE,
setAlertState: mockSetAlertState,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setThresholdState: mockSetThresholdState,
} as any);
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -55,14 +54,7 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(
<AnomalyThreshold
channels={[]}
isLoadingChannels={false}
isErrorChannels={false}
refreshChannels={jest.fn()}
/>,
);
render(<AnomalyThreshold />);
describe('AnomalyThreshold', () => {
beforeEach(() => {

View File

@@ -2,37 +2,15 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
import { ThresholdItemProps } from '../types';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock: any = jest.fn(() => ({
paths,
}));
uplotMock.paths = paths;
return uplotMock;
});
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
// Mock the enableRecoveryThreshold utility
jest.mock('../../utils', () => ({
enableRecoveryThreshold: jest.fn(() => true),
}));
const TEST_CONSTANTS = {
THRESHOLD_ID: 'test-threshold-1',
@@ -43,7 +21,6 @@ const TEST_CONSTANTS = {
CHANNEL_2: 'channel-2',
CHANNEL_3: 'channel-3',
EMAIL_CHANNEL_NAME: 'Email Channel',
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
ENTER_THRESHOLD_NAME: 'Enter threshold name',
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
@@ -82,8 +59,6 @@ const defaultProps: ThresholdItemProps = {
channels: mockChannels,
isLoadingChannels: false,
units: mockUnits,
isErrorChannels: false,
refreshChannels: jest.fn(),
};
const renderThresholdItem = (
@@ -102,11 +77,10 @@ const verifySelectorWidth = (
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
};
// TODO: Unskip this when recovery threshold is implemented
// const showRecoveryThreshold = (): void => {
// const recoveryButton = screen.getByRole('button', { name: '' });
// fireEvent.click(recoveryButton);
// };
const showRecoveryThreshold = (): void => {
const recoveryButton = screen.getByRole('button', { name: '' });
fireEvent.click(recoveryButton);
};
const verifyComponentRendersWithLoading = (): void => {
expect(
@@ -148,7 +122,7 @@ describe('ThresholdItem', () => {
const valueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(valueInput).toHaveValue(100);
expect(valueInput).toHaveValue('100');
});
it('renders unit selector with correct value', () => {
@@ -158,6 +132,15 @@ describe('ThresholdItem', () => {
expect(screen.getByText('Bytes')).toBeInTheDocument();
});
it('renders channels selector with correct value', () => {
renderThresholdItem();
// Check for the channels selector by looking for the displayed text
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
});
it('updates threshold label when label input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
@@ -229,31 +212,38 @@ describe('ThresholdItem', () => {
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // remove button
expect(buttons).toHaveLength(2); // Recovery button + remove button
});
it('does not show remove button when showRemoveButton is false', () => {
renderThresholdItem({ showRemoveButton: false });
// No buttons should be present
const buttons = screen.queryAllByRole('button');
expect(buttons).toHaveLength(0);
// Only the recovery button should be present
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Only recovery button
});
it('calls removeThreshold when remove button is clicked', () => {
const removeThreshold = jest.fn();
renderThresholdItem({ showRemoveButton: true, removeThreshold });
// The remove button is the first button (with circle-x icon)
// The remove button is the second button (with circle-x icon)
const buttons = screen.getAllByRole('button');
const removeButton = buttons[0];
const removeButton = buttons[1]; // Second button is the remove button
fireEvent.click(removeButton);
expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID);
});
// TODO: Unskip this when recovery threshold is implemented
it.skip('shows recovery threshold inputs when recovery button is clicked', () => {
it('shows recovery threshold button when recovery threshold is enabled', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1); // Recovery button
});
it('shows recovery threshold inputs when recovery button is clicked', () => {
renderThresholdItem();
// The recovery button is the first button (with chart-line icon)
@@ -261,16 +251,13 @@ describe('ThresholdItem', () => {
const recoveryButton = buttons[0]; // First button is the recovery button
fireEvent.click(recoveryButton);
expect(
screen.getByPlaceholderText('Enter recovery threshold value'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
).toBeInTheDocument();
});
// TODO: Unskip this when recovery threshold is implemented
it.skip('updates recovery threshold value when input changes', () => {
it('updates recovery threshold value when input changes', () => {
const updateThreshold = jest.fn();
renderThresholdItem({ updateThreshold });
@@ -303,6 +290,22 @@ describe('ThresholdItem', () => {
verifyUnitSelectorDisabled();
});
it('renders channels as multiple select options', () => {
renderThresholdItem();
// Check that channels are rendered as multiple select
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select multiple channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, {
target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] },
});
});
it('handles empty threshold values correctly', () => {
const emptyThreshold = {
...mockThreshold,
@@ -315,7 +318,7 @@ describe('ThresholdItem', () => {
renderThresholdItem({ threshold: emptyThreshold });
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
});
it('renders with correct input widths', () => {
@@ -328,13 +331,13 @@ describe('ThresholdItem', () => {
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
);
expect(labelInput).toHaveStyle('width: 200px');
expect(valueInput).toHaveStyle('width: 100px');
expect(labelInput).toHaveStyle('width: 260px');
expect(valueInput).toHaveStyle('width: 210px');
});
it('renders channels selector with correct width', () => {
renderThresholdItem();
verifySelectorWidth(1, '350px');
verifySelectorWidth(1, '260px');
});
it('renders unit selector with correct width', () => {
@@ -347,14 +350,37 @@ describe('ThresholdItem', () => {
verifyComponentRendersWithLoading();
});
it.skip('renders recovery threshold with correct initial value', () => {
it('renders recovery threshold with correct initial value', () => {
renderThresholdItem();
// showRecoveryThreshold();
showRecoveryThreshold();
const recoveryValueInput = screen.getByPlaceholderText(
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
);
expect(recoveryValueInput).toHaveValue(80);
expect(recoveryValueInput).toHaveValue('80');
});
it('renders recovery threshold label as disabled', () => {
renderThresholdItem();
showRecoveryThreshold();
const recoveryLabelInput = screen.getByPlaceholderText('Recovery threshold');
expect(recoveryLabelInput).toBeDisabled();
});
it('renders correct channel options', () => {
renderThresholdItem();
// Check that channels are rendered
expect(
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
).toBeInTheDocument();
// Should be able to select different channels
const channelSelectors = screen.getAllByRole('combobox');
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
fireEvent.change(channelSelector, { target: { value: 'channel-2' } });
expect(screen.getByText('Slack Channel')).toBeInTheDocument();
});
it('handles threshold without channels', () => {

View File

@@ -67,7 +67,7 @@
padding-right: 72px;
background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400);
width: 100%;
width: fit-content;
.alert-condition-sentences {
display: flex;
@@ -90,7 +90,7 @@
}
.ant-select {
width: 240px;
width: 240px !important;
.ant-select-selector {
background-color: var(--bg-ink-300);
@@ -148,7 +148,6 @@
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.ant-input {
background-color: var(--bg-ink-400);
@@ -278,29 +277,6 @@
}
}
}
.routing-policies-info-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
background-color: #4568dc1a;
border: 1px solid var(--bg-robin-500);
padding: 8px 16px;
.ant-typography {
color: var(--bg-robin-500);
}
}
}
.anomaly-threshold-container {
.ant-select {
.ant-select-selector {
min-width: 150px;
}
}
}
.condensed-alert-threshold-container,
@@ -317,8 +293,7 @@
.ant-btn {
display: flex;
align-items: center;
min-width: 240px;
width: auto;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
@@ -326,7 +301,6 @@
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
@@ -334,7 +308,6 @@
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
@@ -345,235 +318,3 @@
}
}
}
.lightMode {
.alert-condition-container {
.alert-condition {
.alert-condition-tabs {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
}
.alert-threshold-container,
.anomaly-threshold-container {
background-color: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
.alert-condition-sentences {
.alert-condition-sentence {
.sentence-text {
color: var(--text-ink-400);
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--text-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
}
}
.thresholds-section {
.threshold-item {
.threshold-row {
.threshold-controls {
.threshold-inputs {
display: flex;
align-items: center;
gap: 8px;
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
.icon-btn {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
}
}
}
.recovery-threshold-input-group {
.recovery-threshold-btn {
color: var(--bg-ink-400);
background-color: var(--bg-vanilla-200) !important;
border: 1px solid var(--bg-vanilla-300);
}
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}
}
.add-threshold-btn,
.ant-btn.add-threshold-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-300);
background-color: transparent;
.ant-typography {
color: var(--bg-ink-400);
}
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-400);
}
}
}
}
.condensed-evaluation-settings-container {
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
min-width: 240px;
width: auto;
.evaluate-alert-conditions-button-left {
color: var(--bg-ink-400);
flex-shrink: 0;
}
.evaluate-alert-conditions-button-right {
color: var(--bg-ink-400);
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text {
background-color: var(--bg-vanilla-300);
}
}
}
}
}
.highlighted-text {
font-weight: bold;
color: var(--bg-robin-400);
margin: 0 4px;
}
// Tooltip styles
.tooltip-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.tooltip-description {
margin-bottom: 8px;
span {
font-weight: bold;
color: var(--bg-robin-400);
}
}
.tooltip-example {
margin-bottom: 8px;
color: #8b92a0;
}
.tooltip-link {
.tooltip-link-text {
color: #1890ff;
font-size: 11px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}

View File

@@ -1,18 +1,14 @@
import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import {
NotificationSettingsAction,
NotificationSettingsState,
Threshold,
} from '../context/types';
import { Threshold } from '../context/types';
export type UpdateThreshold = {
(thresholdId: string, field: 'channels', value: string[]): void;
(
thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>,
value: string | number | null,
value: string,
): void;
};
@@ -24,20 +20,4 @@ export interface ThresholdItemProps {
channels: Channels[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
refreshChannels: () => void;
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;
}
export interface RoutingPolicyBannerProps {
notificationSettings: NotificationSettingsState;
setNotificationSettings: (
notificationSettings: NotificationSettingsAction,
) => void;
}

View File

@@ -1,19 +1,9 @@
import { Button, Flex, Switch, Typography } from 'antd';
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import ROUTES from 'constants/routes';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { RoutingPolicyBannerProps } from './types';
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
@@ -54,360 +44,3 @@ export function getCategorySelectOptionByName(
) || []
);
}
const getOperatorWord = (op: AlertThresholdOperator): string => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 'exceed';
case AlertThresholdOperator.IS_BELOW:
return 'fall below';
case AlertThresholdOperator.IS_EQUAL_TO:
return 'equal';
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 'not equal';
default:
return 'exceed';
}
};
const getThresholdValue = (op: AlertThresholdOperator): number => {
switch (op) {
case AlertThresholdOperator.IS_ABOVE:
return 80;
case AlertThresholdOperator.IS_BELOW:
return 50;
case AlertThresholdOperator.IS_EQUAL_TO:
return 100;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return 0;
default:
return 80;
}
};
const getDataPoints = (
matchType: AlertThresholdMatchType,
op: AlertThresholdOperator,
): number[] => {
const dataPointMap: Record<
AlertThresholdMatchType,
Record<AlertThresholdOperator, number[]>
> = {
[AlertThresholdMatchType.AT_LEAST_ONCE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 45, 40, 55, 35],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 100, 105, 90, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 0, 10, 15, 0],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.ALL_THE_TIME]: {
[AlertThresholdOperator.IS_BELOW]: [45, 40, 35, 42, 38],
[AlertThresholdOperator.IS_EQUAL_TO]: [100, 100, 100, 100, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [85, 87, 90, 88, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [85, 87, 90, 88, 95],
},
[AlertThresholdMatchType.ON_AVERAGE]: {
[AlertThresholdOperator.IS_BELOW]: [60, 40, 45, 35, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [95, 105, 100, 95, 105],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [5, 10, 15, 8, 12],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
[AlertThresholdMatchType.IN_TOTAL]: {
[AlertThresholdOperator.IS_BELOW]: [8, 5, 10, 12, 8],
[AlertThresholdOperator.IS_EQUAL_TO]: [20, 20, 20, 20, 20],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.IS_ABOVE]: [10, 15, 25, 5, 30],
[AlertThresholdOperator.ABOVE_BELOW]: [10, 15, 25, 5, 30],
},
[AlertThresholdMatchType.LAST]: {
[AlertThresholdOperator.IS_BELOW]: [75, 85, 90, 78, 45],
[AlertThresholdOperator.IS_EQUAL_TO]: [75, 85, 90, 78, 100],
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: [75, 85, 90, 78, 25],
[AlertThresholdOperator.IS_ABOVE]: [75, 85, 90, 78, 95],
[AlertThresholdOperator.ABOVE_BELOW]: [75, 85, 90, 78, 95],
},
};
return dataPointMap[matchType]?.[op] || [75, 85, 90, 78, 95];
};
const getTooltipOperatorSymbol = (op: AlertThresholdOperator): string => {
const symbolMap: Record<AlertThresholdOperator, string> = {
[AlertThresholdOperator.IS_ABOVE]: '>',
[AlertThresholdOperator.IS_BELOW]: '<',
[AlertThresholdOperator.IS_EQUAL_TO]: '=',
[AlertThresholdOperator.IS_NOT_EQUAL_TO]: '!=',
[AlertThresholdOperator.ABOVE_BELOW]: '>',
};
return symbolMap[op] || '>';
};
const handleTooltipClick = (
e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
): void => {
e.stopPropagation();
};
function TooltipContent({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<div
role="button"
tabIndex={0}
onClick={handleTooltipClick}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleTooltipClick(e);
}
}}
className="tooltip-content"
>
{children}
</div>
);
}
function TooltipExample({
children,
dataPoints,
operatorSymbol,
thresholdValue,
matchType,
}: {
children: React.ReactNode;
dataPoints: number[];
operatorSymbol: string;
thresholdValue: number;
matchType: AlertThresholdMatchType;
}): JSX.Element {
return (
<div className="tooltip-example">
<strong>Example:</strong>
<br />
Say, For a 5-minute window (configured in Evaluation settings), 1 min
aggregation interval (set up in query) 5{' '}
{matchType === AlertThresholdMatchType.IN_TOTAL
? 'error counts'
: 'data points'}
: [{dataPoints.join(', ')}]<br />
With threshold {operatorSymbol} {thresholdValue}: {children}
</div>
);
}
function TooltipLink(): JSX.Element {
return (
<div className="tooltip-link">
<a
href="https://signoz.io/docs"
target="_blank"
rel="noopener noreferrer"
className="tooltip-link-text"
>
Learn more
</a>
</div>
);
}
export const getMatchTypeTooltip = (
matchType: AlertThresholdMatchType,
operator: AlertThresholdOperator,
): React.ReactNode => {
const operatorSymbol = getTooltipOperatorSymbol(operator);
const operatorWord = getOperatorWord(operator);
const thresholdValue = getThresholdValue(operator);
const dataPoints = getDataPoints(matchType, operator);
const getMatchingPointsCount = (): number =>
dataPoints.filter((p) => {
switch (operator) {
case AlertThresholdOperator.IS_ABOVE:
return p > thresholdValue;
case AlertThresholdOperator.IS_BELOW:
return p < thresholdValue;
case AlertThresholdOperator.IS_EQUAL_TO:
return p === thresholdValue;
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
return p !== thresholdValue;
default:
return p > thresholdValue;
}
}).length;
switch (matchType) {
case AlertThresholdMatchType.AT_LEAST_ONCE:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ANY</span> of
those aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers ({getMatchingPointsCount()} points {operatorWord}{' '}
{thresholdValue})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ALL_THE_TIME:
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if <span>ALL</span>{' '}
aggregated data points cross the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (all points {operatorWord} {thresholdValue})<br />
If any point was {thresholdValue}, no alert would fire
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
case AlertThresholdMatchType.ON_AVERAGE: {
const average = (
dataPoints.reduce((a, b) => a + b, 0) / dataPoints.length
).toFixed(1);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>AVERAGE</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (average = {average})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.IN_TOTAL: {
const total = dataPoints.reduce((a, b) => a + b, 0);
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers if the{' '}
<span>SUM</span> of all aggregated data points crosses the threshold.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (total = {total})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
case AlertThresholdMatchType.LAST: {
const lastPoint = dataPoints[dataPoints.length - 1];
return (
<TooltipContent>
<div className="tooltip-description">
Data is aggregated at each interval within your evaluation window,
creating multiple data points. This option triggers based on the{' '}
<span>MOST RECENT</span> aggregated data point only.
</div>
<TooltipExample
dataPoints={dataPoints}
operatorSymbol={operatorSymbol}
thresholdValue={thresholdValue}
matchType={matchType}
>
Alert triggers (last point = {lastPoint})
</TooltipExample>
<TooltipLink />
</TooltipContent>
);
}
default:
return '';
}
};
export function NotificationChannelsNotFoundContent({
user,
refreshChannels,
}: {
user: IUser;
refreshChannels: () => void;
}): JSX.Element {
return (
<Flex justify="space-between">
<Flex gap={4} align="center">
<Typography.Text>No channels yet.</Typography.Text>
{user?.role === USER_ROLES.ADMIN ? (
<Typography.Text>
Create one
<Button
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.
</Button>
</Typography.Text>
) : (
<Typography.Text>Please ask your admin to create one.</Typography.Text>
)}
</Flex>
<Button type="text" onClick={refreshChannels}>
Refresh
</Button>
</Flex>
);
}
export function RoutingPolicyBanner({
notificationSettings,
setNotificationSettings,
}: RoutingPolicyBannerProps): JSX.Element {
return (
<div className="routing-policies-info-banner">
<Typography.Text>
Use <strong>Routing Policies</strong> for dynamic routing
</Typography.Text>
<Switch
checked={notificationSettings.routingPolicies}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_ROUTING_POLICIES',
payload: value,
});
}}
/>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import './styles.scss';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { Labels } from 'types/api/alerts/def';
@@ -9,7 +8,7 @@ import { useCreateAlertState } from '../context';
import LabelsInput from './LabelsInput';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
const { alertState, setAlertState } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
@@ -35,14 +34,11 @@ function CreateAlertHeader(): JSX.Element {
);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
>
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
)}
<div className="alert-header">
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
<div className="alert-header__content">
<input
type="text"
@@ -53,6 +49,15 @@ function CreateAlertHeader(): JSX.Element {
className="alert-header__input title"
placeholder="Enter alert rule name"
/>
<input
type="text"
value={alertState.description}
onChange={(e): void =>
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
}
className="alert-header__input description"
placeholder="Click to add description..."
/>
<LabelsInput
labels={alertState.labels}
onLabelsChange={(labels: Labels): void =>

View File

@@ -1,28 +1,9 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
@@ -44,11 +25,9 @@ jest.mock('react-router-dom', () => ({
}),
}));
const ENTER_ALERT_RULE_NAME_PLACEHOLDER = 'Enter alert rule name';
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render(
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertProvider>
<CreateAlertHeader />
</CreateAlertProvider>,
);
@@ -61,12 +40,18 @@ describe('CreateAlertHeader', () => {
it('renders name input with placeholder', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
expect(nameInput).toBeInTheDocument();
});
it('renders description input with placeholder', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
expect(descriptionInput).toBeInTheDocument();
});
it('renders LabelsInput component', () => {
renderCreateAlertHeader();
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
@@ -74,30 +59,19 @@ describe('CreateAlertHeader', () => {
it('updates name when typing in name input', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
expect(nameInput).toHaveValue('Test Alert');
});
it('renders the header with title when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
it('updates description when typing in description input', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
expect(screen.queryByText('New Alert Rule')).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
).toHaveValue('TEST_ALERT');
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
});
});

View File

@@ -3,6 +3,21 @@
font-family: inherit;
color: var(--text-vanilla-100);
/* Top bar with diagonal stripes */
&__tab-bar {
height: 32px;
display: flex;
align-items: center;
background: repeating-linear-gradient(
-45deg,
#0f0f0f,
#0f0f0f 10px,
#101010 10px,
#101010 20px
);
padding-left: 0;
}
/* Tab block visuals */
&__tab {
display: flex;
@@ -29,8 +44,6 @@
display: flex;
flex-direction: column;
gap: 8px;
min-width: 300px;
flex: 1;
}
&__input.title {
@@ -38,8 +51,6 @@
font-weight: 500;
background-color: transparent;
color: var(--text-vanilla-100);
width: 100%;
min-width: 300px;
}
&__input:focus,
@@ -53,15 +64,6 @@
background-color: transparent;
color: var(--text-vanilla-300);
}
.ant-btn {
display: flex;
gap: 4px;
align-items: center;
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-300);
margin-right: 16px;
}
}
.labels-input {
@@ -147,74 +149,3 @@
}
}
}
.lightMode {
.alert-header {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
&__tab {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
}
&__tab::before {
color: var(--bg-ink-100);
}
&__content {
background: var(--bg-vanilla-100);
}
&__input.title {
color: var(--text-ink-100);
}
&__input.description {
color: var(--text-ink-300);
}
}
.edit-alert-header {
width: 100%;
}
.edit-alert-header .alert-header__content {
background: var(--bg-vanilla-200);
}
.labels-input {
&__add-button {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-ink-300);
color: var(--bg-ink-500);
}
}
&__label-pill {
background-color: #ad7f581a;
color: var(--bg-sienna-400);
border: 1px solid var(--bg-sienna-500);
}
&__remove-button {
color: var(--bg-sienna-400);
&:hover {
color: var(--text-ink-100);
}
}
&__input {
color: var(--bg-ink-500);
&::placeholder {
color: var(--bg-ink-300);
}
}
}
}

View File

@@ -1,20 +1,17 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
.create-alert-v2-container {
background-color: var(--bg-ink-500);
padding-bottom: 100px;
}
.lightMode {
.create-alert-v2-container {
background-color: var(--bg-vanilla-100);
}
}
.sticky-page-spinner {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.35);
z-index: 10000;
pointer-events: auto;
.top-nav-container {
background: repeating-linear-gradient(
-45deg,
$top-nav-background-1,
$top-nav-background-1 10px,
$top-nav-background-2 10px,
$top-nav-background-2 20px
);
margin-bottom: 0;
}

View File

@@ -2,32 +2,27 @@ import './CreateAlertV2.styles.scss';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import { buildInitialAlertDef } from './context/utils';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import Footer from './Footer';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { CreateAlertV2Props } from './types';
import { showCondensedLayout, Spinner } from './utils';
import { showCondensedLayout } from './utils';
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
const queryToRedirect = buildInitialAlertDef(alertType);
const currentQueryToRedirect = mapQueryDataFromApi(
queryToRedirect.condition.compositeQuery,
);
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
}: {
initialQuery?: Query;
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<CreateAlertProvider initialAlertType={alertType}>
<Spinner />
<CreateAlertProvider>
<div className="create-alert-v2-container">
<CreateAlertHeader />
<QuerySection />
@@ -35,7 +30,6 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
<Footer />
</CreateAlertProvider>
);
}

View File

@@ -2,7 +2,7 @@ import './styles.scss';
import { Switch, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { IAdvancedOptionItemProps } from '../types';
@@ -12,14 +12,9 @@ function AdvancedOptionItem({
input,
tooltipText,
onToggle,
defaultShowInput,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
useEffect(() => {
setShowInput(defaultShowInput);
}, [defaultShowInput]);
const handleOnToggle = (): void => {
onToggle?.();
setShowInput((currentShowInput) => !currentShowInput);
@@ -47,7 +42,7 @@ function AdvancedOptionItem({
>
{input}
</div>
<Switch onChange={handleOnToggle} checked={showInput} />
<Switch onChange={handleOnToggle} />
</div>
</div>
);

View File

@@ -114,14 +114,6 @@
height: 32px;
border: 1px solid var(--bg-slate-400);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}

View File

@@ -1,4 +1,5 @@
import { Collapse, Input, Typography } from 'antd';
import { Collapse, Input, Select, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
@@ -7,6 +8,10 @@ import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return (
<div className="advanced-options-container">
<Collapse bordered={false}>
@@ -33,16 +38,24 @@ function AdvancedOptions(): JSX.Element {
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/>
<Typography.Text>Minutes</Typography.Text>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit:
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
timeUnit: value as string,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
/>
</div>
}
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
})
}
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
/>
<AdvancedOptionItem
title="Minimum data required"
@@ -67,16 +80,8 @@ function AdvancedOptions(): JSX.Element {
<Typography.Text>Datapoints</Typography.Text>
</div>
}
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
})
}
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
/>
{/* TODO: Add back when the functionality is implemented */}
{/* <AdvancedOptionItem
<AdvancedOptionItem
title="Account for data delay"
description="Shift the evaluation window backwards to account for data processing delays."
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
@@ -114,7 +119,7 @@ function AdvancedOptions(): JSX.Element {
/>
</div>
}
/> */}
/>
</Collapse.Panel>
</Collapse>
</div>

View File

@@ -1,8 +1,8 @@
import './styles.scss';
import '../AdvancedOptionItem/styles.scss';
import { Input, Select, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { Info, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCreateAlertState } from '../../context';
@@ -36,10 +36,10 @@ function EvaluationCadence(): JSX.Element {
);
}, [advancedOptions.evaluationCadence.mode]);
// const showCustomSchedule = (): void => {
// setIsEvaluationCadenceDetailsVisible(true);
// setIsCustomScheduleButtonVisible(false);
// };
const showCustomSchedule = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
setIsCustomScheduleButtonVisible(false);
};
return (
<div className="evaluation-cadence-container">
@@ -98,14 +98,13 @@ function EvaluationCadence(): JSX.Element {
}
/>
</Input.Group>
{/* TODO: Add custom schedule back once the functionality is implemented */}
{/* <Button
<Button
className="advanced-option-item-button"
onClick={showCustomSchedule}
>
<Plus size={12} />
<Typography.Text>Add custom schedule</Typography.Text>
</Button> */}
</Button>
</div>
)}
</div>

View File

@@ -164,14 +164,6 @@
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
@@ -537,15 +529,6 @@
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}

View File

@@ -3,8 +3,8 @@ import { useMemo } from 'react';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import {
getCumulativeWindowDescription,
getRollingWindowDescription,
CUMULATIVE_WINDOW_DESCRIPTION,
ROLLING_WINDOW_DESCRIPTION,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
@@ -116,9 +116,7 @@ function EvaluationWindowDetails({
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
@@ -136,9 +134,7 @@ function EvaluationWindowDetails({
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
@@ -163,9 +159,7 @@ function EvaluationWindowDetails({
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
@@ -198,9 +192,7 @@ function EvaluationWindowDetails({
return (
<div className="evaluation-window-details">
<Typography.Text>
{getRollingWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">

View File

@@ -3,10 +3,10 @@ import classNames from 'classnames';
import { Check } from 'lucide-react';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
getCumulativeWindowDescription,
getRollingWindowDescription,
ROLLING_WINDOW_DESCRIPTION,
} from '../constants';
import {
CumulativeWindowTimeframes,
@@ -96,9 +96,7 @@ function EvaluationWindowPopover({
}
return (
<div className="selection-content">
<Typography.Text>
{getRollingWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
@@ -110,9 +108,7 @@ function EvaluationWindowPopover({
) {
return (
<div className="selection-content">
<Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);

View File

@@ -33,7 +33,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -51,7 +50,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -67,7 +65,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -91,7 +88,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -121,7 +117,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -151,7 +146,6 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@@ -166,24 +160,9 @@ describe('AdvancedOptionItem', () => {
description={defaultProps.description}
input={defaultProps.input}
tooltipText="mock tooltip text"
defaultShowInput={false}
/>,
);
const tooltipIcon = screen.getByTestId('tooltip-icon');
expect(tooltipIcon).toBeInTheDocument();
});
it('should show input when defaultShowInput is true', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput
/>,
);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeVisible();
});
});

View File

@@ -28,10 +28,9 @@ describe('AdvancedOptions', () => {
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
// ).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
});
it('should be able to expand the advanced options', () => {
@@ -43,10 +42,9 @@ describe('AdvancedOptions', () => {
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
// ).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
@@ -54,8 +52,7 @@ describe('AdvancedOptions', () => {
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
// TODO: Uncomment this when account for data delay is implemented
// expect(screen.getByText('Account for data delay')).toBeInTheDocument();
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
});
it('"Alert when data stops coming" works as expected', () => {
@@ -115,7 +112,7 @@ describe('AdvancedOptions', () => {
});
});
it.skip('"Account for data delay" works as expected', () => {
it('"Account for data delay" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });

View File

@@ -64,12 +64,10 @@ describe('EvaluationCadence', () => {
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
expect(screen.getByText('Minutes')).toBeInTheDocument();
// TODO: Uncomment this when add custom schedule button is implemented
// expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should hide the input group when add custom schedule button is clicked', () => {
it('should hide the input group when add custom schedule button is clicked', () => {
render(<EvaluationCadence />);
expect(
@@ -86,14 +84,12 @@ describe('EvaluationCadence', () => {
).toBeInTheDocument();
});
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should not show the edit custom schedule component in default mode', () => {
it('should not show the edit custom schedule component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.queryByTestId('edit-custom-schedule')).not.toBeInTheDocument();
});
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should show the custom schedule text when the mode is custom with selected values', () => {
it('should show the custom schedule text when the mode is custom with selected values', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
@@ -122,8 +118,7 @@ describe('EvaluationCadence', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when add custom schedule button is implemented
it.skip('should show evaluation cadence details component when clicked on add custom schedule button', () => {
it('should show evaluation cadence details component when clicked on add custom schedule button', () => {
render(<EvaluationCadence />);
expect(

View File

@@ -6,11 +6,6 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import EvaluationSettings from '../EvaluationSettings';
import { createMockAlertContextState } from './testUtils';
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
showCondensedLayout: jest.fn().mockReturnValue(false),
}));
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({

View File

@@ -24,12 +24,9 @@ describe('EvaluationWindowDetails', () => {
/>,
);
expect(
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'Monitors data over a fixed time period that moves forward continuously',
) ?? false,
)[0],
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();

View File

@@ -125,12 +125,9 @@ describe('EvaluationWindowPopover', () => {
/>,
);
expect(
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'Monitors data over a fixed time period that moves forward continuously',
) ?? false,
)[0],
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),

View File

@@ -26,14 +26,6 @@ export const createMockAlertContextState = (
setEvaluationWindow: jest.fn(),
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
setNotificationSettings: jest.fn(),
discardAlertRule: jest.fn(),
testAlertRule: jest.fn(),
isCreatingAlertRule: false,
isTestingAlertRule: false,
createAlertRule: jest.fn(),
isUpdatingAlertRule: false,
updateAlertRule: jest.fn(),
isEditMode: false,
...overrides,
});

View File

@@ -62,87 +62,8 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
value: timezone.value,
}));
export const getCumulativeWindowDescription = (timeframe?: string): string => {
let example = '';
switch (timeframe) {
case 'currentHour':
example =
'An hourly cumulative window for error count alerts when errors exceed 100. Starting at the top of the hour, it tracks: 20 errors by :15, 55 by :30, 105 by :45 (alert fires).';
break;
case 'currentDay':
example =
'A daily cumulative window for sales alerts when total revenue exceeds $10,000. Starting at midnight, it tracks: $2,000 by 9 AM, $5,500 by noon, $11,000 by 3 PM (alert fires).';
break;
case 'currentMonth':
example =
'A monthly cumulative window for expense alerts when spending exceeds $50,000. Starting on the 1st, it tracks: $15,000 by the 7th, $32,000 by the 15th, $51,000 by the 22nd (alert fires).';
break;
default:
example = '';
}
return `Monitors data accumulated since a fixed starting point. The window grows over time, keeping all historical data from the start.\n\nExample: ${example}`;
};
export const CUMULATIVE_WINDOW_DESCRIPTION =
'A Cumulative Window has a fixed starting point and expands over time.';
// eslint-disable-next-line sonarjs/cognitive-complexity
export const getRollingWindowDescription = (duration?: string): string => {
let timeWindow = '5-minute';
let examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
if (duration) {
const match = duration.match(/^(\d+)([mhs])/);
if (match) {
const value = parseInt(match[1], 10);
const unit = match[2];
if (unit === 'm' && !Number.isNaN(value)) {
timeWindow = `${value}-minute`;
const endMinutes1 = 1 + value;
const endMinutes2 = 2 + value;
examples = `14:01:00-14:${String(endMinutes1).padStart(
2,
'0',
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
} else if (unit === 'h' && !Number.isNaN(value)) {
timeWindow = `${value}-hour`;
const endHour1 = 14 + value;
const endHour2 = 14 + value;
examples = `14:00:00-${String(endHour1).padStart(
2,
'0',
)}:00:00, 14:01:00-${String(endHour2).padStart(2, '0')}:01:00`;
} else if (unit === 's' && !Number.isNaN(value)) {
timeWindow = `${value}-second`;
examples = `14:01:00-14:01:${String(value).padStart(
2,
'0',
)}, 14:01:01-14:01:${String(1 + value).padStart(2, '0')}`;
}
} else if (duration === 'custom') {
timeWindow = '5-minute';
examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
} else if (duration.includes('h')) {
const hours = parseInt(duration, 10);
if (!Number.isNaN(hours)) {
timeWindow = `${hours}-hour`;
const endHour = 14 + hours;
examples = `14:00:00-${String(endHour).padStart(
2,
'0',
)}:00:00, 14:01:00-${String(endHour).padStart(2, '0')}:01:00`;
}
} else if (duration.includes('m')) {
const minutes = parseInt(duration, 10);
if (!Number.isNaN(minutes)) {
timeWindow = `${minutes}-minute`;
const endMinutes1 = 1 + minutes;
const endMinutes2 = 2 + minutes;
examples = `14:01:00-14:${String(endMinutes1).padStart(
2,
'0',
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
}
}
}
return `Monitors data over a fixed time period that moves forward continuously.\n\nExample: A ${timeWindow} rolling window for error rate alerts with 1 minute evaluation cadence. Unlike fixed windows, this checks continuously: ${examples}, etc.`;
};
export const ROLLING_WINDOW_DESCRIPTION =
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';

View File

@@ -209,16 +209,6 @@
.ant-select {
width: 40px;
.ant-select-selector {
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
}
@@ -241,14 +231,6 @@
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
&:hover {
@@ -397,14 +379,6 @@
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
}
}
&:hover {

View File

@@ -11,7 +11,6 @@ export interface IAdvancedOptionItemProps {
input: JSX.Element;
tooltipText?: string;
onToggle?: () => void;
defaultShowInput: boolean;
}
export enum RollingWindowTimeframes {

View File

@@ -1,193 +0,0 @@
import './styles.scss';
import { toast } from '@signozhq/sonner';
import { Button, Tooltip, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Check, Send, X } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { useCreateAlertState } from '../context';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
function Footer(): JSX.Element {
const {
alertType,
alertState: basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleDiscard = (): void => {
discardAlertRule();
safeNavigate('/alerts');
};
const alertValidationMessage = useMemo(
() =>
validateCreateAlertState({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
}),
[
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
],
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
});
testAlertRule(payload, {
onSuccess: (response) => {
if (response.payload?.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
},
onError: (error) => {
toast.error(error.message);
},
});
}, [
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
testAlertRule,
]);
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
query: currentQuery,
});
if (isEditMode) {
updateAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
} else {
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
}
}, [
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
isEditMode,
updateAlertRule,
createAlertRule,
safeNavigate,
]);
const disableButtons =
isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule;
const saveAlertButton = useMemo(() => {
let button = (
<Button
type="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Check size={14} />
<Typography.Text>Save Alert Rule</Typography.Text>
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
}
return button;
}, [alertValidationMessage, disableButtons, handleSaveAlert]);
const testAlertButton = useMemo(() => {
let button = (
<Button
type="default"
onClick={handleTestNotification}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Send size={14} />
<Typography.Text>Test Notification</Typography.Text>
</Button>
);
if (alertValidationMessage) {
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
}
return button;
}, [alertValidationMessage, disableButtons, handleTestNotification]);
return (
<div className="create-alert-v2-footer">
<Button type="default" onClick={handleDiscard} disabled={disableButtons}>
<X size={14} /> Discard
</Button>
<div className="button-group">
{testAlertButton}
{saveAlertButton}
</div>
</div>
);
}
export default Footer;

View File

@@ -1,248 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as createAlertState from '../../context';
import Footer from '../Footer';
// Mock the hooks used by Footer component
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
const mockCreateAlertRule = jest.fn();
const mockTestAlertRule = jest.fn();
const mockUpdateAlertRule = jest.fn();
const mockDiscardAlertRule = jest.fn();
// Import the mocked hooks
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSafeNavigate } = jest.requireMock('hooks/useSafeNavigate');
const mockAlertContextState = createMockAlertContextState({
createAlertRule: mockCreateAlertRule,
testAlertRule: mockTestAlertRule,
updateAlertRule: mockUpdateAlertRule,
discardAlertRule: mockDiscardAlertRule,
alertState: {
name: 'Test Alert',
labels: {},
yAxisUnit: undefined,
},
thresholdState: {
selectedQuery: 'A',
operator: AlertThresholdOperator.ABOVE_BELOW,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
evaluationWindow: '5m0s',
algorithm: 'standard',
seasonality: 'hourly',
thresholds: [
{
id: '1',
label: 'CRITICAL',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: ['test-channel'],
color: '#ff0000',
},
],
},
});
jest
.spyOn(createAlertState, 'useCreateAlertState')
.mockReturnValue(mockAlertContextState);
const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
const TEST_NOTIFICATION_TEXT = 'Test Notification';
const DISCARD_TEXT = 'Discard';
describe('Footer', () => {
beforeEach(() => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [],
queryFormulas: [],
},
promql: [],
clickhouse_sql: [],
queryType: 'builder',
},
});
useSafeNavigate.mockReturnValue({
safeNavigate: jest.fn(),
});
});
it('should render the component with 3 buttons', () => {
render(<Footer />);
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('discard action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(DISCARD_TEXT));
expect(mockDiscardAlertRule).toHaveBeenCalled();
});
it('save alert rule action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockCreateAlertRule).toHaveBeenCalled();
});
it('update alert rule action works correctly', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isEditMode: true,
});
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockUpdateAlertRule).toHaveBeenCalled();
});
it('test notification action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
expect(mockTestAlertRule).toHaveBeenCalled();
});
it('all buttons are disabled when creating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when updating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when testing alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('create and test buttons are disabled when alert name is missing', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
alertState: {
...mockAlertContextState.alertState,
name: '',
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('create and test buttons are disabled when notifcation channels are missing and routing policies are disabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: false,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('buttons are enabled even with no notification channels when routing policies are enabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: true,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeEnabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeEnabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
});
});

View File

@@ -1,524 +0,0 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants';
import {
AdvancedOptionsState,
EvaluationWindowState,
NotificationSettingsState,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
getEvaluationProps,
getFormattedTimeValue,
getNotificationSettingsProps,
validateCreateAlertState,
} from '../utils';
describe('Footer utils', () => {
describe('getFormattedTimeValue', () => {
it('for 60 seconds', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.SECONDS)).toBe('60s');
});
it('for 60 minutes', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.MINUTES)).toBe('60m');
});
it('for 60 hours', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.HOURS)).toBe('60h');
});
it('for 60 days', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.DAYS)).toBe('60d');
});
});
describe('validateCreateAlertState', () => {
const args: BuildCreateAlertRulePayloadArgs = {
alertType: AlertTypes.METRICS_BASED_ALERT,
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
query: initialQueriesMap.metrics,
};
it('when alert name is not provided', () => {
expect(validateCreateAlertState(args)).toBeDefined();
expect(validateCreateAlertState(args)).toBe('Please enter an alert name');
});
it('when threshold label is not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
label: '',
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please enter a label for each threshold',
);
});
it('when threshold channels are not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please select at least one channel for each threshold or enable routing policies',
);
});
it('when threshold channels are not provided but routing policies are enabled', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
notificationSettings: {
...args.notificationSettings,
routingPolicies: true,
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
it('when threshold channels are provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
channels: ['test channel'],
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
});
describe('getNotificationSettingsProps', () => {
it('when initial notification settings are provided', () => {
const notificationSettings = INITIAL_NOTIFICATION_SETTINGS_STATE;
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '1m',
alertStates: [],
},
usePolicy: false,
});
});
});
it('renotification is enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
reNotification: {
enabled: true,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: true,
interval: '1m',
alertStates: ['firing'],
},
usePolicy: false,
});
});
it('routing policies are enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
routingPolicies: true,
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '1m',
alertStates: [],
},
usePolicy: true,
});
});
it('group by notifications are provided', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: ['test group'],
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: ['test group'],
renotify: {
enabled: false,
interval: '1m',
alertStates: [],
},
usePolicy: false,
});
});
describe('getAlertOnAbsentProps', () => {
it('when alert on absent is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: false,
toleranceLimit: 0,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: false,
});
});
it('when alert on absent is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 13,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: true,
absentFor: 13,
});
});
});
describe('getEnforceMinimumDatapointsProps', () => {
it('when enforce minimum datapoints is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: false,
minimumDatapoints: 0,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: false,
});
});
it('when enforce minimum datapoints is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 12,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: true,
requiredNumPoints: 12,
});
});
});
describe('getEvaluationProps', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
value: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
};
it('for rolling window with non-custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: '5m0s',
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '12m',
},
});
});
it('for rolling window with custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '13m',
frequency: '12m',
},
});
});
it('for cumulative window with current hour', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '14',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'hourly', minute: 14 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current day', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: '15:43:00',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'daily', hour: 15, minute: 43 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current month', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '17',
timezone: 'UTC',
time: '16:34:00',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'monthly', day: 17, hour: 16, minute: 34 },
frequency: '12m',
timezone: 'UTC',
},
});
});
});
describe('buildCreateThresholdAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('verify buildCreateThresholdAlertRulePayload', () => {
const props = buildCreateThresholdAlertRulePayload(
INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alert: '',
alertType: 'METRIC_BASED_ALERT',
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
},
condition: {
alertOnAbsent: false,
compositeQuery: {
builderQueries: undefined,
chQueries: undefined,
panelType: 'graph',
promQueries: undefined,
queries: [
{
spec: {
aggregations: [
{
metricName: '',
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
},
],
disabled: false,
filter: {
expression: '',
},
functions: undefined,
groupBy: undefined,
having: undefined,
legend: undefined,
limit: undefined,
name: 'A',
offset: undefined,
order: undefined,
selectFields: undefined,
signal: 'metrics',
source: '',
stepInterval: null,
},
type: 'builder_query',
},
],
queryType: 'builder',
unit: undefined,
},
requireMinPoints: false,
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
channels: [],
matchType: '1',
name: 'critical',
op: '1',
target: 0,
targetUnit: '',
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
},
labels: {},
notificationSettings: {
groupBy: [],
renotify: {
enabled: false,
interval: '1m',
alertStates: [],
},
usePolicy: false,
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});
it('verify for promql query type', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
query: {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.query,
queryType: EQueryType.PROM,
},
};
const props = buildCreateThresholdAlertRulePayload(currentArgs);
expect(props).toBeDefined();
expect(props.condition.compositeQuery.queryType).toBe('promql');
expect(props.ruleType).toBe('promql_rule');
});
});
});

View File

@@ -1,3 +0,0 @@
import Footer from './Footer';
export default Footer;

View File

@@ -1,51 +0,0 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
left: 63px;
right: 0;
background-color: var(--bg-ink-500);
height: 70px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px 24px;
z-index: 1000;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
.button-group {
display: flex;
gap: 12px;
}
.ant-btn {
display: flex;
align-items: center;
gap: 8px;
}
.ant-btn-default {
background-color: var(--bg-slate-500);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
}
}
.lightMode {
.create-alert-v2-footer {
background-color: var(--bg-vanilla-100);
border-top: 1px solid var(--bg-vanilla-300);
.ant-btn-default {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-400);
color: var(--bg-ink-400);
}
.ant-btn-primary {
.ant-typography {
color: var(--bg-vanilla-100);
}
}
}
}

View File

@@ -1,20 +0,0 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
export interface BuildCreateAlertRulePayloadArgs {
alertType: AlertTypes;
basicAlertState: AlertState;
thresholdState: AlertThresholdState;
advancedOptions: AdvancedOptionsState;
evaluationWindow: EvaluationWindowState;
notificationSettings: NotificationSettingsState;
query: Query;
}

View File

@@ -1,345 +0,0 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import {
BasicThreshold,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { EQueryType } from 'types/common/dashboard';
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
import {
AdvancedOptionsState,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
export function getFormattedTimeValue(timeValue: number, unit: string): string {
const unitMap: Record<string, string> = {
[UniversalYAxisUnit.SECONDS]: 's',
[UniversalYAxisUnit.MINUTES]: 'm',
[UniversalYAxisUnit.HOURS]: 'h',
[UniversalYAxisUnit.DAYS]: 'd',
};
return `${timeValue}${unitMap[unit]}`;
}
// Validate create alert api payload
export function validateCreateAlertState(
args: BuildCreateAlertRulePayloadArgs,
): string | null {
const { basicAlertState, thresholdState, notificationSettings } = args;
// Validate alert name
if (!basicAlertState.name) {
return 'Please enter an alert name';
}
// Validate threshold state if routing policies is not enabled
for (let i = 0; i < thresholdState.thresholds.length; i++) {
const threshold = thresholdState.thresholds[i];
if (!threshold.label) {
return 'Please enter a label for each threshold';
}
if (!notificationSettings.routingPolicies && !threshold.channels.length) {
return 'Please select at least one channel for each threshold or enable routing policies';
}
}
return null;
}
// Get notification settings props for create alert api payload
export function getNotificationSettingsProps(
notificationSettings: NotificationSettingsState,
): PostableAlertRuleV2['notificationSettings'] {
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
groupBy: notificationSettings.multipleNotifications || [],
usePolicy: notificationSettings.routingPolicies,
renotify: {
enabled: notificationSettings.reNotification.enabled,
interval: getFormattedTimeValue(
notificationSettings.reNotification.value,
notificationSettings.reNotification.unit,
),
alertStates: notificationSettings.reNotification.conditions,
},
};
return notificationSettingsProps;
}
// Get alert on absent props for create alert api payload
export function getAlertOnAbsentProps(
advancedOptions: AdvancedOptionsState,
): Partial<PostableAlertRuleV2['condition']> {
if (advancedOptions.sendNotificationIfDataIsMissing.enabled) {
return {
alertOnAbsent: true,
absentFor: advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
};
}
return {
alertOnAbsent: false,
};
}
// Get enforce minimum datapoints props for create alert api payload
export function getEnforceMinimumDatapointsProps(
advancedOptions: AdvancedOptionsState,
): Partial<PostableAlertRuleV2['condition']> {
if (advancedOptions.enforceMinimumDatapoints.enabled) {
return {
requireMinPoints: true,
requiredNumPoints:
advancedOptions.enforceMinimumDatapoints.minimumDatapoints,
};
}
return {
requireMinPoints: false,
};
}
// Get evaluation props for create alert api payload
export function getEvaluationProps(
evaluationWindow: EvaluationWindowState,
advancedOptions: AdvancedOptionsState,
): PostableAlertRuleV2['evaluation'] {
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: evaluationWindow.timeframe,
frequency,
},
};
}
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: getFormattedTimeValue(
Number(evaluationWindow.startingAt.number),
evaluationWindow.startingAt.unit,
),
frequency,
},
};
}
// Only cumulative window type left now
if (evaluationWindow.timeframe === 'currentHour') {
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'hourly',
minute: Number(evaluationWindow.startingAt.number),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
if (evaluationWindow.timeframe === 'currentDay') {
// time is in the format of "HH:MM:SS"
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'daily',
hour: Number(hour),
minute: Number(minute),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
if (evaluationWindow.timeframe === 'currentMonth') {
// time is in the format of "HH:MM:SS"
const [hour, minute] = evaluationWindow.startingAt.time.split(':');
return {
kind: evaluationWindow.windowType,
spec: {
schedule: {
type: 'monthly',
day: Number(evaluationWindow.startingAt.number),
hour: Number(hour),
minute: Number(minute),
},
frequency,
timezone: evaluationWindow.startingAt.timezone,
},
};
}
return {
kind: evaluationWindow.windowType,
spec: {
evalWindow: evaluationWindow.timeframe,
frequency,
},
};
}
// Build Create Threshold Alert Rule Payload
export function buildCreateThresholdAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
evaluationWindow,
advancedOptions,
notificationSettings,
query,
} = args;
const compositeQuery = compositeQueryToQueryEnvelope({
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
queryType: query.queryType,
panelType: PANEL_TYPES.TIME_SERIES,
unit: basicAlertState.yAxisUnit,
});
// Thresholds
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => ({
name: threshold.label,
target: parseFloat(threshold.thresholdValue.toString()),
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
}),
);
// Alert on absent data
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
// Enforce minimum datapoints
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
advancedOptions,
);
// Notification settings
const notificationSettingsProps = getNotificationSettingsProps(
notificationSettings,
);
// Evaluation
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
if (query.queryType === EQueryType.PROM) {
ruleType = 'promql_rule';
}
return {
alert: basicAlertState.name,
ruleType,
alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
evaluation: evaluationProps,
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
const compositeQuery = compositeQueryToQueryEnvelope({
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
queryType: query.queryType,
panelType: PANEL_TYPES.TIME_SERIES,
unit: basicAlertState.yAxisUnit,
});
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps = getEnforceMinimumDatapointsProps(
advancedOptions,
);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps = getNotificationSettingsProps(
notificationSettings,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType,
condition: {
compositeQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
};
}

View File

@@ -1,4 +1,4 @@
import { Tooltip, Typography } from 'antd';
import { Button, Popover, Tooltip, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { Info } from 'lucide-react';
@@ -10,46 +10,46 @@ function NotificationMessage(): JSX.Element {
setNotificationSettings,
} = useCreateAlertState();
// const templateVariables = [
// { variable: '{{alertname}}', description: 'Name of the alert rule' },
// {
// variable: '{{value}}',
// description: 'Current value that triggered the alert',
// },
// {
// variable: '{{threshold}}',
// description: 'Threshold value from alert condition',
// },
// { variable: '{{unit}}', description: 'Unit of measurement for the metric' },
// {
// variable: '{{severity}}',
// description: 'Alert severity level (Critical, Warning, Info)',
// },
// {
// variable: '{{queryname}}',
// description: 'Name of the query that triggered the alert',
// },
// {
// variable: '{{labels}}',
// description: 'All labels associated with the alert',
// },
// {
// variable: '{{timestamp}}',
// description: 'Timestamp when alert was triggered',
// },
// ];
const templateVariables = [
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
{
variable: '{{value}}',
description: 'Current value that triggered the alert',
},
{
variable: '{{threshold}}',
description: 'Threshold value from alert condition',
},
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
{
variable: '{{severity}}',
description: 'Alert severity level (Critical, Warning, Info)',
},
{
variable: '{{queryname}}',
description: 'Name of the query that triggered the alert',
},
{
variable: '{{labels}}',
description: 'All labels associated with the alert',
},
{
variable: '{{timestamp}}',
description: 'Timestamp when alert was triggered',
},
];
// const templateVariableContent = (
// <div className="template-variable-content">
// <Typography.Text strong>Available Template Variables:</Typography.Text>
// {templateVariables.map((item) => (
// <div className="template-variable-content-item" key={item.variable}>
// <code>{item.variable}</code>
// <Typography.Text>{item.description}</Typography.Text>
// </div>
// ))}
// </div>
// );
const templateVariableContent = (
<div className="template-variable-content">
<Typography.Text strong>Available Template Variables:</Typography.Text>
{templateVariables.map((item) => (
<div className="template-variable-content-item" key={item.variable}>
<code>{item.variable}</code>
<Typography.Text>{item.description}</Typography.Text>
</div>
))}
</div>
);
return (
<div className="notification-message-container">
@@ -67,13 +67,12 @@ function NotificationMessage(): JSX.Element {
</Typography.Text>
</div>
<div className="notification-message-header-actions">
{/* TODO: Add back when the functionality is implemented */}
{/* <Popover content={templateVariableContent}>
<Popover content={templateVariableContent}>
<Button type="text">
<Info size={12} />
Variables
</Button>
</Popover> */}
</Popover>
</div>
</div>
<TextArea

View File

@@ -103,7 +103,6 @@ function NotificationSettings(): JSX.Element {
},
});
}}
defaultShowInput={notificationSettings.reNotification.enabled}
/>
</div>
</div>

View File

@@ -24,11 +24,6 @@ jest.mock(
}),
);
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
showCondensedLayout: jest.fn().mockReturnValue(false),
}));
const initialNotificationSettings = createMockAlertContextState()
.notificationSettings;
const mockSetNotificationSettings = jest.fn();

View File

@@ -84,28 +84,12 @@
.ant-select {
.ant-select-selector {
width: 120px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
.ant-select-multiple {
.ant-select-selector {
width: 200px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
@@ -218,15 +202,6 @@
flex-shrink: 0;
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
}
@@ -352,15 +327,6 @@
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}

View File

@@ -1,4 +1,3 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
@@ -17,7 +16,7 @@ export interface ChartPreviewProps {
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
const { thresholdState, alertState } = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
@@ -26,24 +25,14 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const yAxisUnit = alertState.yAxisUnit || '';
const headline = (
<div className="chart-preview-headline">
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
<YAxisUnitSelector
value={alertState.yAxisUnit}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
/>
</div>
);
const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
headline={
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name=""
query={stagedQuery}
selectedInterval={globalSelectedInterval}
@@ -58,7 +47,12 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent
headline={headline}
headline={
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview"
query={stagedQuery}
alertDef={alertDef}

View File

@@ -2,13 +2,12 @@ import './styles.scss';
import { Button } from 'antd';
import classNames from 'classnames';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
@@ -16,20 +15,17 @@ import ChartPreview from './ChartPreview';
import { buildAlertDefForChartPreview } from './utils';
function QuerySection(): JSX.Element {
const { currentQuery, handleRunQuery } = useQueryBuilder();
const {
currentQuery,
handleRunQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
alertState,
setAlertState,
alertType,
setAlertType,
thresholdState,
} = useCreateAlertState();
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
const onQueryCategoryChange = (val: EQueryType): void => {
const query: Query = { ...currentQuery, queryType: val };
redirectWithQueryBuilderData(query);
};
const tabs = [
{
label: 'Metrics',
@@ -55,8 +51,17 @@ function QuerySection(): JSX.Element {
return (
<div className="query-section">
<Stepper stepNumber={1} label="Define the query" />
<Stepper
stepNumber={1}
label="Define the query you want to set an alert on"
/>
<ChartPreview alertDef={alertDef} />
<YAxisUnitSelector
value={alertState.yAxisUnit}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
/>
<div className="query-section-tabs">
<div className="query-section-query-actions">
{tabs.map((tab) => (
@@ -77,7 +82,7 @@ function QuerySection(): JSX.Element {
</div>
<QuerySectionComponent
queryCategory={currentQuery.queryType}
setQueryCategory={onQueryCategoryChange}
setQueryCategory={(): void => {}}
alertType={alertType}
runQuery={handleRunQuery}
alertDef={alertDef}

View File

@@ -134,7 +134,7 @@ const renderChartPreview = (): ReturnType<typeof render> =>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertProvider>
<ChartPreview alertDef={mockAlertDef} />
</CreateAlertProvider>
</MemoryRouter>

View File

@@ -2,7 +2,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
@@ -105,7 +104,7 @@ const renderQuerySection = (): ReturnType<typeof render> =>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertProvider>
<QuerySection />
</CreateAlertProvider>
</MemoryRouter>
@@ -136,7 +135,7 @@ describe('QuerySection', () => {
expect(screen.getByTestId('stepper')).toBeInTheDocument();
expect(screen.getByTestId('step-number')).toHaveTextContent('1');
expect(screen.getByTestId('step-label')).toHaveTextContent(
'Define the query',
'Define the query you want to set an alert on',
);
// Check if ChartPreview is rendered
@@ -187,7 +186,6 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -202,7 +200,6 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,

View File

@@ -77,14 +77,6 @@
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
}
}
}
@@ -96,18 +88,6 @@
border: 1px solid var(--bg-slate-500);
.ant-card-body {
background-color: var(--bg-ink-500);
.chart-preview-headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
.y-axis-unit-selector-component {
margin-top: 0;
}
}
}
}
}
@@ -119,78 +99,3 @@
border: 1px solid var(--bg-slate-400);
}
}
.lightMode {
.query-section {
.query-section-tabs {
.query-section-query-actions {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-300);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
.y-axis-unit-selector-component {
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
}
}
}
.chart-preview-container {
.alert-chart-container {
.ant-card {
border: 1px solid var(--bg-vanilla-300);
.ant-card-body {
background-color: var(--bg-vanilla-100);
}
.ant-card-body {
.chart-preview-header {
.plot-tag {
background-color: var(--bg-vanilla-300);
color: var(--bg-slate-100);
}
}
}
}
}
}
.alert-query-section-container {
background-color: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -42,22 +42,3 @@
background-position: center;
margin-left: 8px;
}
.lightMode {
.step-number {
background-color: var(--bg-robin-400);
color: var(--text-slate-400);
}
.step-label {
color: var(--bg-ink-300);
}
.dotted-line {
background-image: radial-gradient(
circle,
var(--bg-ink-200) 1px,
transparent 1px
);
}
}

View File

@@ -1,358 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { defaultPostableAlertRuleV2 } from '../constants';
import { INITIAL_ALERT_STATE } from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import {
getAdvancedOptionsStateFromAlertDef,
getColorForThreshold,
getCreateAlertLocalStateFromAlertDef,
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
getThresholdStateFromAlertDef,
parseGoTime,
} from '../utils';
describe('CreateAlertV2 utils', () => {
describe('getColorForThreshold', () => {
it('should return the correct color for the pre-defined threshold', () => {
expect(getColorForThreshold('critical')).toBe(Color.BG_SAKURA_500);
expect(getColorForThreshold('warning')).toBe(Color.BG_AMBER_500);
expect(getColorForThreshold('info')).toBe(Color.BG_ROBIN_500);
});
});
describe('parseGoTime', () => {
it('should return the correct time and unit for the given input', () => {
expect(parseGoTime('1h')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
expect(parseGoTime('1m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.MINUTES,
});
expect(parseGoTime('1s')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.SECONDS,
});
expect(parseGoTime('1h0m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
});
});
describe('getEvaluationWindowStateFromAlertDef', () => {
it('for rolling window with non-custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '5m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: '5m0s',
});
});
it('for rolling window with custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '13m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
});
});
it('for cumulative window with current hour', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
kind: 'cumulative',
spec: {
schedule: {
type: 'hourly',
minute: 14,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
number: '14',
},
});
});
it('for cumulative window with current day', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'daily',
hour: 14,
minute: 15,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
time: '14:15:00',
},
});
});
it('for cumulative window with current month', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'monthly',
day: 12,
hour: 16,
minute: 34,
},
timezone: 'UTC',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
number: '12',
timezone: 'UTC',
time: '16:34:00',
},
});
});
});
describe('getNotificationSettingsStateFromAlertDef', () => {
it('should return the correct notification settings state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
renotify: {
enabled: true,
interval: '1m0s',
alertStates: ['firing'],
},
usePolicy: true,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: true,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
routingPolicies: true,
});
});
it('when renotification is not provided', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
usePolicy: false,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: false,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},
});
});
});
describe('getAdvancedOptionsStateFromAlertDef', () => {
it('should return the correct advanced options state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
requiredNumPoints: 13,
requireMinPoints: true,
alertOnAbsent: true,
absentFor: 12,
},
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
spec: {
frequency: '1m0s',
},
},
};
const props = getAdvancedOptionsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 13,
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
});
});
});
describe('getThresholdStateFromAlertDef', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_ABOVE,
},
],
},
selectedQueryName: 'test',
},
};
const props = getThresholdStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
selectedQuery: 'test',
operator: AlertThresholdOperator.IS_ABOVE,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
thresholds: [
{
id: expect.any(String),
label: 'critical',
thresholdValue: 1,
recoveryThresholdValue: null,
unit: UniversalYAxisUnit.MINUTES,
color: Color.BG_SAKURA_500,
channels: ['email'],
},
],
});
});
describe('getCreateAlertLocalStateFromAlertDef', () => {
it('should return the correct create alert local state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
alert: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
},
};
const props = getCreateAlertLocalStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
basicAlertState: {
...INITIAL_ALERT_STATE,
name: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
yAxisUnit: UniversalYAxisUnit.MINUTES,
},
// as we have already verified these utils in their respective tests
thresholdState: expect.any(Object),
advancedOptionsState: expect.any(Object),
evaluationWindowState: expect.any(Object),
notificationSettingsState: expect.any(Object),
});
});
});
});

View File

@@ -1,74 +0,0 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { EQueryType } from 'types/common/dashboard';
const defaultAnnotations = {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
};
const defaultNotificationSettings: PostableAlertRuleV2['notificationSettings'] = {
groupBy: [],
renotify: {
enabled: false,
interval: '1m',
alertStates: [],
},
usePolicy: false,
};
const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
};
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {
builderQueries: {
A: initialQueryBuilderFormValuesMap.metrics,
},
promQueries: { A: initialQueryPromQLData },
chQueries: {
A: {
name: 'A',
query: ``,
legend: '',
disabled: false,
},
},
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
},
selectedQueryName: 'A',
alertOnAbsent: true,
absentFor: 10,
requireMinPoints: false,
requiredNumPoints: 0,
},
labels: {
severity: 'warning',
},
annotations: defaultAnnotations,
notificationSettings: defaultNotificationSettings,
alert: 'TEST_ALERT',
evaluation: defaultEvaluation,
};

View File

@@ -21,15 +21,16 @@ import {
export const INITIAL_ALERT_STATE: AlertState = {
name: '',
description: '',
labels: {},
yAxisUnit: undefined,
};
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
id: v4(),
label: 'critical',
label: 'CRITICAL',
thresholdValue: 0,
recoveryThresholdValue: null,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_SAKURA_500,
@@ -37,9 +38,9 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
export const INITIAL_WARNING_THRESHOLD: Threshold = {
id: v4(),
label: 'warning',
label: 'WARNING',
thresholdValue: 0,
recoveryThresholdValue: null,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_AMBER_500,
@@ -47,9 +48,9 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
export const INITIAL_INFO_THRESHOLD: Threshold = {
id: v4(),
label: 'info',
label: 'INFO',
thresholdValue: 0,
recoveryThresholdValue: null,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: Color.BG_ROBIN_500,
@@ -59,7 +60,7 @@ export const INITIAL_RANDOM_THRESHOLD: Threshold = {
id: v4(),
label: '',
thresholdValue: 0,
recoveryThresholdValue: null,
recoveryThresholdValue: 0,
unit: '',
channels: [],
color: getRandomColor(),
@@ -79,11 +80,9 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 15,
timeUnit: UniversalYAxisUnit.MINUTES,
enabled: false,
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
enabled: false,
},
delayEvaluation: {
delay: 5,
@@ -121,10 +120,10 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
};
export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'IS EQUAL TO' },
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'IS NOT EQUAL TO' },
];
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
@@ -170,6 +169,7 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
];
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
@@ -177,7 +177,7 @@ export const NOTIFICATION_MESSAGE_PLACEHOLDER =
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
{ value: 'nodata', label: 'No Data' },
{ value: 'no-data', label: 'No Data' },
];
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
@@ -189,5 +189,4 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
conditions: [],
},
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
routingPolicies: false,
};

View File

@@ -1,8 +1,4 @@
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import {
@@ -51,7 +47,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
export function CreateAlertProvider(
props: ICreateAlertProviderProps,
): JSX.Element {
const { children, initialAlertState, isEditMode, ruleId } = props;
const { children } = props;
const [alertState, setAlertState] = useReducer(
alertCreationReducer,
@@ -76,10 +72,6 @@ export function CreateAlertProvider(
currentQueryToRedirect,
{
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
value === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -115,65 +107,6 @@ export function CreateAlertProvider(
});
}, [alertType]);
useEffect(() => {
if (isEditMode && initialAlertState) {
setAlertState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.basicAlertState,
});
setThresholdState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.thresholdState,
});
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.evaluationWindowState,
});
setAdvancedOptions({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.advancedOptionsState,
});
setNotificationSettings({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.notificationSettingsState,
});
}
}, [initialAlertState, isEditMode]);
const discardAlertRule = useCallback(() => {
setAlertState({
type: 'RESET',
});
setThresholdState({
type: 'RESET',
});
setEvaluationWindow({
type: 'RESET',
});
setAdvancedOptions({
type: 'RESET',
});
setNotificationSettings({
type: 'RESET',
});
handleAlertTypeChange(AlertTypes.METRICS_BASED_ALERT);
}, [handleAlertTypeChange]);
const {
mutate: createAlertRule,
isLoading: isCreatingAlertRule,
} = useCreateAlertRule();
const {
mutate: testAlertRule,
isLoading: isTestingAlertRule,
} = useTestAlertRule();
const {
mutate: updateAlertRule,
isLoading: isUpdatingAlertRule,
} = useUpdateAlertRule(ruleId || '');
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
alertState,
@@ -188,14 +121,6 @@ export function CreateAlertProvider(
setAdvancedOptions,
notificationSettings,
setNotificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode: isEditMode || false,
}),
[
alertState,
@@ -205,14 +130,6 @@ export function CreateAlertProvider(
evaluationWindow,
advancedOptions,
notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
],
);

View File

@@ -1,16 +1,8 @@
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
export interface ICreateAlertContextProps {
alertState: AlertState;
setAlertState: Dispatch<CreateAlertAction>;
@@ -24,37 +16,10 @@ export interface ICreateAlertContextProps {
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
notificationSettings: NotificationSettingsState;
setNotificationSettings: Dispatch<NotificationSettingsAction>;
isCreatingAlertRule: boolean;
createAlertRule: UseMutateFunction<
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isTestingAlertRule: boolean;
testAlertRule: UseMutateFunction<
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
discardAlertRule: () => void;
isUpdatingAlertRule: boolean;
updateAlertRule: UseMutateFunction<
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isEditMode: boolean;
}
export interface ICreateAlertProviderProps {
children: React.ReactNode;
initialAlertType: AlertTypes;
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
isEditMode?: boolean;
ruleId?: string;
}
export enum AlertCreationStep {
@@ -66,22 +31,23 @@ export enum AlertCreationStep {
export interface AlertState {
name: string;
description: string;
labels: Labels;
yAxisUnit: string | undefined;
}
export type CreateAlertAction =
| { type: 'SET_ALERT_NAME'; payload: string }
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
| { type: 'SET_ALERT_LABELS'; payload: Labels }
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
| { type: 'SET_INITIAL_STATE'; payload: AlertState }
| { type: 'RESET' };
export interface Threshold {
id: string;
label: string;
thresholdValue: number;
recoveryThresholdValue: number | null;
recoveryThresholdValue: number;
unit: string;
channels: string[];
color: string;
@@ -142,18 +108,15 @@ export type AlertThresholdAction =
| { type: 'SET_ALGORITHM'; payload: string }
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState }
| { type: 'RESET' };
export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: {
toleranceLimit: number;
timeUnit: string;
enabled: boolean;
};
enforceMinimumDatapoints: {
minimumDatapoints: number;
enabled: boolean;
};
delayEvaluation: {
delay: number;
@@ -184,18 +147,10 @@ export type AdvancedOptionsAction =
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string };
}
| {
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: boolean;
}
| {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number };
}
| {
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS';
payload: boolean;
}
| {
type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string };
@@ -214,7 +169,6 @@ export type AdvancedOptionsAction =
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState }
| { type: 'RESET' };
export interface EvaluationWindowState {
@@ -236,7 +190,6 @@ export type EvaluationWindowAction =
payload: { time: string; number: string; timezone: string; unit: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
@@ -247,10 +200,9 @@ export interface NotificationSettingsState {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'nodata')[];
conditions: ('firing' | 'no-data')[];
};
description: string;
routingPolicies: boolean;
}
export type NotificationSettingsAction =
@@ -264,10 +216,8 @@ export type NotificationSettingsAction =
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'nodata')[];
conditions: ('firing' | 'no-data')[];
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
| { type: 'RESET' };

View File

@@ -41,6 +41,11 @@ export const alertCreationReducer = (
...state,
name: action.payload,
};
case 'SET_ALERT_DESCRIPTION':
return {
...state,
description: action.payload,
};
case 'SET_ALERT_LABELS':
return {
...state,
@@ -53,8 +58,6 @@ export const alertCreationReducer = (
};
case 'RESET':
return INITIAL_ALERT_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -96,10 +99,6 @@ export function getInitialAlertTypeFromURL(
urlSearchParams: URLSearchParams,
currentQuery: Query,
): AlertTypes {
const ruleType = urlSearchParams.get(QueryParams.ruleType);
if (ruleType === 'anomaly_rule') {
return AlertTypes.ANOMALY_BASED_ALERT;
}
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
return alertTypeFromURL
? (alertTypeFromURL as AlertTypes)
@@ -121,8 +120,6 @@ export const alertThresholdReducer = (
return { ...state, thresholds: action.payload };
case 'RESET':
return INITIAL_ALERT_THRESHOLD_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -134,38 +131,9 @@ export const advancedOptionsReducer = (
): AdvancedOptionsState => {
switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return {
...state,
sendNotificationIfDataIsMissing: {
...state.sendNotificationIfDataIsMissing,
toleranceLimit: action.payload.toleranceLimit,
timeUnit: action.payload.timeUnit,
},
};
case 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return {
...state,
sendNotificationIfDataIsMissing: {
...state.sendNotificationIfDataIsMissing,
enabled: action.payload,
},
};
return { ...state, sendNotificationIfDataIsMissing: action.payload };
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
minimumDatapoints: action.payload.minimumDatapoints,
},
};
case 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS':
return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
enabled: action.payload,
},
};
return { ...state, enforceMinimumDatapoints: action.payload };
case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE':
@@ -178,8 +146,6 @@ export const advancedOptionsReducer = (
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'SET_INITIAL_STATE':
return action.payload;
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
@@ -208,8 +174,6 @@ export const evaluationWindowReducer = (
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@@ -226,12 +190,8 @@ export const notificationSettingsReducer = (
return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION':
return { ...state, description: action.payload };
case 'SET_ROUTING_POLICIES':
return { ...state, routingPolicies: action.payload };
case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}

View File

@@ -1,21 +0,0 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
export interface CreateAlertV2Props {
alertType: AlertTypes;
}
export interface GetCreateAlertLocalStateFromAlertDefReturn {
basicAlertState: AlertState;
thresholdState: AlertThresholdState;
advancedOptionsState: AdvancedOptionsState;
evaluationWindowState: EvaluationWindowState;
notificationSettingsState: NotificationSettingsState;
}

View File

@@ -1,32 +1,3 @@
import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { createPortal } from 'react-dom';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
import { useCreateAlertState } from './context';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './context/constants';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';
@@ -35,277 +6,4 @@ export const showNewCreateAlertsPage = (): boolean =>
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('hideCondensedLayout') !== 'true';
export function Spinner(): JSX.Element | null {
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
if (!isCreatingAlertRule && !isUpdatingAlertRule) return null;
return createPortal(
<div className="sticky-page-spinner">
<Spin size="large" spinning />
</div>,
document.body,
);
}
export function getColorForThreshold(thresholdLabel: string): string {
if (thresholdLabel === 'critical') {
return Color.BG_SAKURA_500;
}
if (thresholdLabel === 'warning') {
return Color.BG_AMBER_500;
}
if (thresholdLabel === 'info') {
return Color.BG_ROBIN_500;
}
return getRandomColor();
}
export function parseGoTime(
input: string,
): { time: number; unit: UniversalYAxisUnit } {
const regex = /(\d+)([hms])/g;
const matches = [...input.matchAll(regex)];
const nonZero = matches.find(([, value]) => parseInt(value, 10) > 0);
if (!nonZero) {
return { time: 1, unit: UniversalYAxisUnit.MINUTES };
}
const time = parseInt(nonZero[1], 10);
const unitMap: Record<string, UniversalYAxisUnit> = {
h: UniversalYAxisUnit.HOURS,
m: UniversalYAxisUnit.MINUTES,
s: UniversalYAxisUnit.SECONDS,
};
return { time, unit: unitMap[nonZero[2]] };
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function getEvaluationWindowStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): EvaluationWindowState {
const windowType = alertDef.evaluation?.kind as 'rolling' | 'cumulative';
function getRollingWindowTimeframe(): string {
if (
// Default values for rolling window
EVALUATION_WINDOW_TIMEFRAME.rolling
.map((option) => option.value)
.includes(alertDef.evaluation?.spec?.evalWindow || '')
) {
return alertDef.evaluation?.spec?.evalWindow || '';
}
return 'custom';
}
function getCumulativeWindowTimeframe(): string {
switch (alertDef.evaluation?.spec?.schedule?.type) {
case 'hourly':
return 'currentHour';
case 'daily':
return 'currentDay';
case 'monthly':
return 'currentMonth';
default:
return 'currentHour';
}
}
function convertApiFieldToTime(hour: number, minute: number): string {
return `${hour.toString().padStart(2, '0')}:${minute
.toString()
.padStart(2, '0')}:00`;
}
function getCumulativeWindowStartingAt(): EvaluationWindowState['startingAt'] {
const timeframe = getCumulativeWindowTimeframe();
if (timeframe === 'currentHour') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.minute?.toString() || '0',
};
}
if (timeframe === 'currentDay') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
};
}
if (timeframe === 'currentMonth') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.day?.toString() || '0',
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
};
}
return INITIAL_EVALUATION_WINDOW_STATE.startingAt;
}
if (windowType === 'rolling') {
const timeframe = getRollingWindowTimeframe();
if (timeframe === 'custom') {
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: parseGoTime(
alertDef.evaluation?.spec?.evalWindow || '1m',
).time.toString(),
unit: parseGoTime(alertDef.evaluation?.spec?.evalWindow || '1m').unit,
},
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe: getCumulativeWindowTimeframe(),
startingAt: getCumulativeWindowStartingAt(),
};
}
export function getNotificationSettingsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): NotificationSettingsState {
const description = alertDef.annotations?.description || '';
const multipleNotifications = alertDef.notificationSettings?.groupBy || [];
const routingPolicies = alertDef.notificationSettings?.usePolicy || false;
const reNotificationEnabled =
alertDef.notificationSettings?.renotify?.enabled || false;
const reNotificationConditions =
alertDef.notificationSettings?.renotify?.alertStates?.map(
(state) => state as 'firing' | 'nodata',
) || [];
const reNotificationValue = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').time
: 1;
const reNotificationUnit = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '1m').unit
: UniversalYAxisUnit.MINUTES;
return {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
description,
multipleNotifications,
routingPolicies,
reNotification: {
enabled: reNotificationEnabled,
conditions: reNotificationConditions,
value: reNotificationValue,
unit: reNotificationUnit,
},
};
}
export function getAdvancedOptionsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AdvancedOptionsState {
return {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
toleranceLimit: alertDef.condition.absentFor || 0,
enabled: alertDef.condition.alertOnAbsent || false,
},
enforceMinimumDatapoints: {
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
minimumDatapoints: alertDef.condition.requiredNumPoints || 0,
enabled: alertDef.condition.requireMinPoints || false,
},
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence.default,
value: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').time,
timeUnit: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').unit,
},
},
};
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
channels: threshold.channels,
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
AlertThresholdOperator.IS_ABOVE,
matchType:
(alertDef.condition.thresholds?.spec[0]
.matchType as AlertThresholdMatchType) ||
AlertThresholdMatchType.AT_LEAST_ONCE,
};
}
export function getCreateAlertLocalStateFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): GetCreateAlertLocalStateFromAlertDefReturn {
if (!alertDef) {
return {
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
};
}
// Basic alert state
const basicAlertState: AlertState = {
...INITIAL_ALERT_STATE,
name: alertDef.alert,
labels: alertDef.labels || {},
yAxisUnit: alertDef.condition.compositeQuery.unit,
};
const thresholdState = getThresholdStateFromAlertDef(alertDef);
const advancedOptionsState = getAdvancedOptionsStateFromAlertDef(alertDef);
const evaluationWindowState = getEvaluationWindowStateFromAlertDef(alertDef);
const notificationSettingsState = getNotificationSettingsStateFromAlertDef(
alertDef,
);
return {
basicAlertState,
thresholdState,
advancedOptionsState,
evaluationWindowState,
notificationSettingsState,
};
}
localStorage.getItem('showCondensedLayout') === 'true';

View File

@@ -1,56 +0,0 @@
import '../CreateAlertV2/CreateAlertV2.styles.scss';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useMemo } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertCondition from '../CreateAlertV2/AlertCondition';
import { buildInitialAlertDef } from '../CreateAlertV2/context/utils';
import EvaluationSettings from '../CreateAlertV2/EvaluationSettings';
import Footer from '../CreateAlertV2/Footer';
import NotificationSettings from '../CreateAlertV2/NotificationSettings';
import QuerySection from '../CreateAlertV2/QuerySection';
import { showCondensedLayout, Spinner } from '../CreateAlertV2/utils';
interface EditAlertV2Props {
alertType?: AlertTypes;
initialAlert: PostableAlertRuleV2;
}
function EditAlertV2({
alertType = AlertTypes.METRICS_BASED_ALERT,
initialAlert,
}: EditAlertV2Props): JSX.Element {
const currentQueryToRedirect = useMemo(() => {
const basicAlertDef = buildInitialAlertDef(alertType);
return mapQueryDataFromApi(
initialAlert?.condition.compositeQuery ||
basicAlertDef.condition.compositeQuery,
);
}, [initialAlert, alertType]);
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<>
<Spinner />
<div className="create-alert-v2-container">
<QuerySection />
<AlertCondition />
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
<Footer />
</>
);
}
EditAlertV2.defaultProps = {
alertType: AlertTypes.METRICS_BASED_ALERT,
};
export default EditAlertV2;

View File

@@ -1,3 +0,0 @@
import EditAlertV2 from './EditAlertV2';
export default EditAlertV2;

View File

@@ -1,32 +1,11 @@
import { Form } from 'antd';
import EditAlertV2 from 'container/EditAlertV2';
import FormAlertRules from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
function EditRules({
initialValue,
ruleId,
initialV2AlertValue,
}: EditRulesProps): JSX.Element {
function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm();
if (
initialV2AlertValue !== null &&
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
) {
return (
<EditAlertV2
initialAlert={initialV2AlertValue}
alertType={initialValue.alertType as AlertTypes}
/>
);
}
return (
<FormAlertRules
alertType={
@@ -44,7 +23,6 @@ function EditRules({
interface EditRulesProps {
initialValue: AlertDef;
ruleId: string;
initialV2AlertValue: PostableAlertRuleV2 | null;
}
export default EditRules;

View File

@@ -10,7 +10,6 @@
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.prom-ql-icon {
height: 14px;

View File

@@ -1,7 +1,7 @@
import './QuerySection.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Tooltip, Typography } from 'antd';
import { Button, Tabs, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
@@ -71,7 +71,6 @@ function QuerySection({
<Tooltip title="Query Builder">
<Button className="nav-btns">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
</Tooltip>
),
@@ -82,7 +81,6 @@ function QuerySection({
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
@@ -97,7 +95,6 @@ function QuerySection({
<Tooltip title="Query Builder">
<Button className="nav-btns" data-testid="query-builder-tab">
<Atom size={14} />
<Typography.Text>Query Builder</Typography.Text>
</Button>
</Tooltip>
),
@@ -108,7 +105,6 @@ function QuerySection({
<Tooltip title="ClickHouse">
<Button className="nav-btns">
<Terminal size={14} />
<Typography.Text>ClickHouse Query</Typography.Text>
</Button>
</Tooltip>
),
@@ -121,7 +117,6 @@ function QuerySection({
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
/>
<Typography.Text>PromQL</Typography.Text>
</Button>
</Tooltip>
),

View File

@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { PlusOutlined } from '@ant-design/icons';
import { Button, Dropdown, Flex, Input, MenuProps, Typography } from 'antd';
import { Flex, Input, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import saveAlertApi from 'api/alerts/save';
import logEvent from 'api/common/logEvent';
@@ -31,7 +31,7 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
import { GettableAlert } from 'types/api/alerts/get';
import DeleteAlert from './DeleteAlert';
import { ColumnButton, SearchContainer } from './styles';
import { Button, ColumnButton, SearchContainer } from './styles';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
@@ -97,37 +97,14 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
});
}, [notificationsApi, t]);
const onClickNewAlertV2Handler = useCallback(() => {
const onClickNewAlertHandler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'new',
});
history.push(`${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onClickNewClassicAlertHandler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'classic',
});
history.push(ROUTES.ALERTS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const newAlertMenuItems: MenuProps['items'] = [
{
key: 'new',
label: 'Try the new experience',
onClick: onClickNewAlertV2Handler,
},
{
key: 'classic',
label: 'Continue with the current experience',
onClick: onClickNewClassicAlertHandler,
},
];
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
params.set(
@@ -391,11 +368,13 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
/>
<Flex gap={12}>
{addNewAlert && (
<Dropdown menu={{ items: newAlertMenuItems }} trigger={['click']}>
<Button type="primary" icon={<PlusOutlined />}>
New Alert
</Button>
</Dropdown>
<Button
type="primary"
onClick={onClickNewAlertHandler}
icon={<PlusOutlined />}
>
New Alert
</Button>
)}
<TextToolTip
{...{

View File

@@ -36,7 +36,9 @@ export function mapRoutingPolicyToCreateApiPayload(
return {
name,
expression,
channels,
actions: {
channels,
},
description,
};
}
@@ -51,7 +53,9 @@ export function mapRoutingPolicyToUpdateApiPayload(
return {
name,
expression,
channels,
actions: {
channels,
},
description,
};
}

View File

@@ -1,20 +0,0 @@
import createAlertRule, {
CreateAlertRuleResponse,
} from 'api/alerts/createAlertRule';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useCreateAlertRule(): UseMutationResult<
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => createAlertRule(alertData),
});
}

View File

@@ -1,18 +0,0 @@
import testAlertRule, { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useTestAlertRule(): UseMutationResult<
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => testAlertRule(alertData),
});
}

View File

@@ -1,22 +0,0 @@
import updateAlertRule, {
UpdateAlertRuleResponse,
} from 'api/alerts/updateAlertRule';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useUpdateAlertRule(
id: string,
): UseMutationResult<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => updateAlertRule(id, alertData),
});
}

View File

@@ -6,14 +6,10 @@ import { Filters } from 'components/AlertDetailsFilters/Filters';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import history from 'lib/history';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertHeader from './AlertHeader/AlertHeader';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
@@ -89,16 +85,6 @@ function AlertDetails(): JSX.Element {
document.title = alertTitle || document.title;
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
const alertRuleDetails = useMemo(
() => alertDetailsResponse?.payload?.data as PostableAlertRuleV2 | undefined,
[alertDetailsResponse],
);
const initialAlertState = useMemo(
() => getCreateAlertLocalStateFromAlertDef(alertRuleDetails),
[alertRuleDetails],
);
if (
isError ||
!isValidRuleId ||
@@ -118,43 +104,36 @@ function AlertDetails(): JSX.Element {
};
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div className="alert-details">
<Breadcrumb
className="alert-details__breadcrumb"
items={[
{
title: (
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
),
},
{
title: <BreadCrumbItem title={ruleId} isLast />,
},
]}
/>
<Divider className="divider breadcrumb-divider" />
<div className="alert-details">
<Breadcrumb
className="alert-details__breadcrumb"
items={[
{
title: (
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
),
},
{
title: <BreadCrumbItem title={ruleId} isLast />,
},
]}
/>
<Divider className="divider breadcrumb-divider" />
<AlertDetailsStatusRenderer
{...{ isLoading, isError, isRefetching, data: alertDetailsResponse }}
<AlertDetailsStatusRenderer
{...{ isLoading, isError, isRefetching, data: alertDetailsResponse }}
/>
<Divider className="divider" />
<div className="tabs-and-filters">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
onChangeHandler={handleTabChange}
tabBarExtraContent={<Filters />}
/>
<Divider className="divider" />
<div className="tabs-and-filters">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
onChangeHandler={handleTabChange}
tabBarExtraContent={<Filters />}
/>
</div>
</div>
</CreateAlertProvider>
</div>
);
}

View File

@@ -14,7 +14,6 @@ import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAlertRule } from 'providers/Alert';
import { useCallback, useEffect, useState } from 'react';
import { CSSProperties } from 'styled-components';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
import { AlertHeaderProps } from '../AlertHeader';
@@ -61,20 +60,14 @@ function AlertActionButtons({
setIsRenameAlertOpen(false);
}, [handleAlertUpdate]);
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const menuItems: MenuProps['items'] = [
...(!isV2Alert
? [
{
key: 'rename-rule',
label: 'Rename',
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: handleRename,
style: menuItemStyle,
},
]
: []),
{
key: 'rename-rule',
label: 'Rename',
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: handleRename,
style: menuItemStyle,
},
{
key: 'duplicate-rule',
label: 'Duplicate',

View File

@@ -1,14 +1,8 @@
import './AlertHeader.styles.scss';
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useAlertRule } from 'providers/Alert';
import { useMemo, useState } from 'react';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { GettableAlert } from 'types/api/alerts/get';
import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
@@ -16,7 +10,13 @@ import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
export type AlertHeaderProps = {
alertDetails: GettableAlert | PostableAlertRuleV2;
alertDetails: {
state: string;
alert: string;
id: string;
labels: Record<string, string | undefined> | undefined;
disabled: boolean;
};
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert: alertName, labels } = alertDetails;
@@ -32,38 +32,32 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
return {};
}, [labels]);
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const CreateAlertV1Header = (
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<LineClampedText text={updatedName || alertName} />
return (
<div className="alert-info">
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state} />
<div className="alert-title">
<LineClampedText text={updatedName || alertName} />
</div>
</div>
</div>
</div>
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
<div className="bottom-section">
{labels?.severity && <AlertSeverity severity={labels.severity} />}
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labelsWithoutSeverity} />
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
</div>
);
return (
<div className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}
ruleId={alertDetails?.id || ''}
ruleId={alertDetails.id}
setUpdatedName={setUpdatedName}
/>
</div>

View File

@@ -1,6 +1,16 @@
import CreateAlertRule from 'container/CreateAlertRule';
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
import { lazy } from 'react';
const CreateAlertV2 = lazy(() => import('container/CreateAlertV2'));
function CreateAlertPage(): JSX.Element {
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
if (showNewCreateAlertsPageFlag) {
return <CreateAlertV2 />;
}
return <CreateAlertRule />;
}

View File

@@ -14,10 +14,6 @@ import history from 'lib/history';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import {
errorMessageReceivedFromBackend,
@@ -92,18 +88,9 @@ function EditRules(): JSX.Element {
return <Spinner tip="Loading Rules..." />;
}
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
if (data.payload.data.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = data.payload.data as PostableAlertRuleV2;
}
return (
<div className="edit-rules-container">
<EditRulesContainer
ruleId={ruleId || ''}
initialValue={data.payload.data}
initialV2AlertValue={initialV2AlertValue}
/>
<EditRulesContainer ruleId={ruleId || ''} initialValue={data.payload.data} />
</div>
);
}

View File

@@ -1,76 +0,0 @@
import { AlertTypes } from './alertTypes';
import { ICompositeMetricQuery } from './compositeQuery';
import { Labels } from './def';
export interface BasicThreshold {
name: string;
target: number;
matchType: string;
op: string;
channels: string[];
targetUnit: string;
}
export interface PostableAlertRuleV2 {
schemaVersion: string;
id?: string;
alert: string;
alertType?: AlertTypes;
ruleType?: string;
condition: {
thresholds?: {
kind: string;
spec: BasicThreshold[];
};
compositeQuery: ICompositeMetricQuery;
selectedQueryName?: string;
alertOnAbsent?: boolean;
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
};
evaluation?: {
kind?: 'rolling' | 'cumulative';
spec?: {
evalWindow?: string;
frequency?: string;
schedule?: {
type?: 'hourly' | 'daily' | 'monthly';
minute?: number;
hour?: number;
day?: number;
};
timezone?: string;
};
};
labels?: Labels;
annotations?: {
description: string;
summary: string;
};
notificationSettings?: {
groupBy?: string[];
renotify?: {
enabled: boolean;
interval?: string;
alertStates?: string[];
};
usePolicy?: boolean;
};
version?: string;
source?: string;
state?: string;
disabled?: boolean;
}
export interface AlertRuleV2 extends PostableAlertRuleV2 {
schemaVersion: string;
state: string;
disabled: boolean;
createAt: string;
createBy: string;
updateAt: string;
updateBy: string;
}
export const NEW_ALERT_SCHEMA_VERSION = 'v2alpha1';

View File

@@ -13,7 +13,6 @@ export interface GettableAlert extends AlertDef {
createBy: string;
updateAt: string;
updateBy: string;
schemaVersion: string;
}
export type PayloadProps = {

View File

@@ -12,14 +12,8 @@ type AuthZ interface {
factory.Service
// Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o).
Check(context.Context, *openfgav1.TupleKey) error
Check(context.Context, *openfgav1.CheckRequestTupleKey) error
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, []authtypes.Selector) error
// writes the tuples to upstream server
Write(context.Context, *openfgav1.WriteRequest) error
// lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, authtypes.Typeable) ([]*authtypes.Object, error)
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error
}

View File

@@ -176,17 +176,13 @@ func (provider *provider) isModelEqual(expected *openfgav1.AuthorizationModel, a
}
func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKey) error {
func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRequestTupleKey) error {
checkResponse, err := provider.openfgaServer.Check(
ctx,
&openfgav1.CheckRequest{
StoreId: provider.storeID,
AuthorizationModelId: provider.modelID,
TupleKey: &openfgav1.CheckRequestTupleKey{
User: tupleReq.User,
Relation: tupleReq.Relation,
Object: tupleReq.Object,
},
TupleKey: tupleReq,
})
if err != nil {
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
@@ -199,79 +195,39 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKe
return nil
}
func (provider *provider) BatchCheck(ctx context.Context, tupleReq []*openfgav1.TupleKey) error {
batchCheckItems := make([]*openfgav1.BatchCheckItem, 0)
for _, tuple := range tupleReq {
batchCheckItems = append(batchCheckItems, &openfgav1.BatchCheckItem{
TupleKey: &openfgav1.CheckRequestTupleKey{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
},
})
}
checkResponse, err := provider.openfgaServer.BatchCheck(
ctx,
&openfgav1.BatchCheckRequest{
StoreId: provider.storeID,
AuthorizationModelId: provider.modelID,
Checks: batchCheckItems,
})
if err != nil {
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
}
for _, checkResponse := range checkResponse.Result {
if checkResponse.GetAllowed() {
return nil
}
}
return errors.New(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "")
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, relation authtypes.Relation, typeable authtypes.Typeable, selector authtypes.Selector, parentTypeable authtypes.Typeable, parentSelectors ...authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors)
tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...)
if err != nil {
return err
}
err = provider.BatchCheck(ctx, tuples)
check, err := provider.sequentialCheck(ctx, tuples)
if err != nil {
return err
}
if !check {
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue())
}
return nil
}
func (provider *provider) Write(ctx context.Context, req *openfgav1.WriteRequest) error {
_, err := provider.openfgaServer.Write(ctx, &openfgav1.WriteRequest{
StoreId: provider.storeID,
AuthorizationModelId: provider.modelID,
Writes: req.Writes,
})
return err
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
response, err := provider.openfgaServer.ListObjects(ctx, &openfgav1.ListObjectsRequest{
StoreId: provider.storeID,
AuthorizationModelId: provider.modelID,
User: subject,
Relation: relation.StringValue(),
Type: typeable.Type().StringValue(),
})
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "cannot list objects for subject %s with relation %s for type %s", subject, relation.StringValue(), typeable.Type().StringValue())
func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) {
for _, tupleReq := range tuplesReq {
err := provider.Check(ctx, tupleReq)
if err == nil {
return true, nil
}
if errors.Ast(err, errors.TypeInternal) {
// return at the first internal error as the evaluation will be incorrect
return false, err
}
}
return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
return false, nil
}

View File

@@ -114,7 +114,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, tran
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, []authtypes.Selector{authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID)})
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.MustNewSelector(authtypes.TypeOrganization, claims.OrgID), nil)
if err != nil {
render.Error(rw, err)
return

View File

@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
@@ -27,7 +26,6 @@ type Module interface {
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
statsreporter.StatsCollector
role.RegisterTypeable
}
type Handler interface {

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -223,7 +222,3 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return dashboardtypes.NewStatsFromStorableDashboards(dashboards), nil
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{dashboardtypes.ResourceDashboard, dashboardtypes.ResourcesDashboards}
}

View File

@@ -1,291 +0,0 @@
package implrole
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module role.Module
}
func NewHandler(module role.Module) (role.Handler, error) {
return &handler{module: module}, nil
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := new(roletypes.PostableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
role, err := handler.module.Create(ctx, orgID, req.DisplayName, req.Description)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, role.ID.StringValue())
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
role, err := handler.module.Get(ctx, orgID, roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, role)
}
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
relationStr, ok := mux.Vars(r)["relation"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
return
}
relation, err := authtypes.NewRelation(relationStr)
if err != nil {
render.Error(rw, err)
return
}
objects, err := handler.module.GetObjects(ctx, orgID, roleID, relation)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, objects)
}
func (handler *handler) GetResources(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resources := handler.module.GetResources(ctx)
var resourceRelations = struct {
Resources []*authtypes.Resource `json:"resources"`
Relations map[authtypes.Type][]authtypes.Relation `json:"relations"`
}{
Resources: resources,
Relations: authtypes.TypeableRelations,
}
render.Success(rw, http.StatusOK, resourceRelations)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
roles, err := handler.module.List(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, roles)
}
func (handler *handler) Patch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := new(roletypes.PatchableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
err = handler.module.Patch(ctx, orgID, roleID, req.DisplayName, req.Description)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
relationStr, ok := mux.Vars(r)["relation"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "relation is missing from the request"))
return
}
relation, err := authtypes.NewRelation(relationStr)
if err != nil {
render.Error(rw, err)
return
}
req := new(roletypes.PatchableObjects)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
patchableObjects, err := roletypes.NewPatchableObjects(req.Additions, req.Deletions, relation)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.PatchObjects(ctx, orgID, roleID, relation, patchableObjects.Additions, patchableObjects.Deletions)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, ok := mux.Vars(r)["id"]
if !ok {
render.Error(rw, errors.New(errors.TypeInvalidInput, roletypes.ErrCodeRoleInvalidInput, "id is missing from the request"))
return
}
roleID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.Delete(ctx, orgID, roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -1,172 +0,0 @@
package implrole
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
type module struct {
store roletypes.Store
registry []role.RegisterTypeable
authz authz.AuthZ
}
func NewModule(ctx context.Context, store roletypes.Store, authz authz.AuthZ, registry []role.RegisterTypeable) (role.Module, error) {
return &module{
store: store,
authz: authz,
registry: registry,
}, nil
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, displayName, description string) (*roletypes.Role, error) {
role := roletypes.NewRole(displayName, description, orgID)
storableRole, err := roletypes.NewStorableRoleFromRole(role)
if err != nil {
return nil, err
}
err = module.store.Create(ctx, storableRole)
if err != nil {
return nil, err
}
return role, nil
}
func (module *module) GetResources(_ context.Context) []*authtypes.Resource {
typeables := make([]authtypes.Typeable, 0)
for _, register := range module.registry {
typeables = append(typeables, register.MustGetTypeables()...)
}
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
storableRole, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
role, err := roletypes.NewRoleFromStorableRole(storableRole)
if err != nil {
return nil, err
}
return role, nil
}
func (module *module) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
storableRole, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, resource := range module.GetResources(ctx) {
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
resourceObjects, err := module.
authz.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeRole, storableRole.ID.String(), authtypes.RelationAssignee),
relation,
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
}
return objects, nil
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
storableRoles, err := module.store.List(ctx, orgID)
if err != nil {
return nil, err
}
roles := make([]*roletypes.Role, len(storableRoles))
for idx, storableRole := range storableRoles {
role, err := roletypes.NewRoleFromStorableRole(storableRole)
if err != nil {
return nil, err
}
roles[idx] = role
}
return roles, nil
}
func (module *module) Patch(ctx context.Context, orgID valuer.UUID, id valuer.UUID, displayName, description *string) error {
storableRole, err := module.store.Get(ctx, orgID, id)
if err != nil {
return err
}
role, err := roletypes.NewRoleFromStorableRole(storableRole)
if err != nil {
return err
}
role.PatchMetadata(displayName, description)
updatedRole, err := roletypes.NewStorableRoleFromRole(role)
if err != nil {
return err
}
err = module.store.Update(ctx, orgID, updatedRole)
if err != nil {
return err
}
return nil
}
func (module *module) PatchObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
additionTuples, err := roletypes.GetAdditionTuples(id, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(id, relation, deletions)
if err != nil {
return err
}
err = module.authz.Write(ctx, &openfgav1.WriteRequest{
Writes: &openfgav1.WriteRequestWrites{
TupleKeys: additionTuples,
},
Deletes: &openfgav1.WriteRequestDeletes{
TupleKeys: deletionTuples,
},
})
if err != nil {
return err
}
return nil
}
func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.Delete(ctx, orgID, id)
}

View File

@@ -1,103 +0,0 @@
package implrole
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) (roletypes.Store, error) {
return &store{sqlstore: sqlstore}, nil
}
func (store *store) Create(ctx context.Context, role *roletypes.StorableRole) error {
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(role).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "role with id: %s already exists", role.ID)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.StorableRole, error) {
role := new(roletypes.StorableRole)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(role).
Where("orgID = ?", orgID).
Where("id = ?", id).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id: %s doesn't exist", id)
}
return role, nil
}
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.StorableRole, error) {
roles := make([]*roletypes.StorableRole, 0)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&roles).
Where("orgID = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "no roles found in org_id: %s", orgID)
}
return roles, nil
}
func (store *store) Update(ctx context.Context, orgID valuer.UUID, role *roletypes.StorableRole) error {
_, err := store.
sqlstore.
BunDB().
NewUpdate().
Model(role).
WherePK().
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, errors.CodeAlreadyExists, "role with id %s doesn't exist", role.ID)
}
return nil
}
func (store *store) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.
BunDB().
NewDelete().
Model(new(roletypes.StorableRole)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, roletypes.ErrCodeRoleNotFound, "role with id %s doesn't exist", id)
}
return nil
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)
})
}

View File

@@ -1,66 +0,0 @@
package role
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// Creates the role metadata
Create(context.Context, valuer.UUID, string, string) (*roletypes.Role, error)
// Gets the role metadata
Get(context.Context, valuer.UUID, valuer.UUID) (*roletypes.Role, error)
// Gets the objects associated with the given role and relation
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*authtypes.Object, error)
// Lists all the roles metadata for the organization
List(context.Context, valuer.UUID) ([]*roletypes.Role, error)
// Gets all the typeable resources registered from role registry
GetResources(context.Context) []*authtypes.Resource
// Patches the roles metadata
Patch(context.Context, valuer.UUID, valuer.UUID, *string, *string) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation, []*authtypes.Object, []*authtypes.Object) error
// Deletes the role metadata and tuples in authorization server
Delete(context.Context, valuer.UUID, valuer.UUID) error
}
type RegisterTypeable interface {
MustGetTypeables() []authtypes.Typeable
}
type Handler interface {
// Creates the role metadata and tuples in authorization server
Create(http.ResponseWriter, *http.Request)
// Gets the role metadata
Get(http.ResponseWriter, *http.Request)
// Gets the objects for the given relation and role
GetObjects(http.ResponseWriter, *http.Request)
// Gets all the resources and the relations
GetResources(http.ResponseWriter, *http.Request)
// Lists all the roles metadata for the organization
List(http.ResponseWriter, *http.Request)
// Patches the role metdata
Patch(http.ResponseWriter, *http.Request)
// Patches the objects for the given relation and role
PatchObjects(http.ResponseWriter, *http.Request)
// Deletes the role metadata and tuples in authorization server
Delete(http.ResponseWriter, *http.Request)
}

View File

@@ -1,14 +1,17 @@
package thirdpartyapi
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
"net"
"regexp"
"time"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
@@ -19,6 +22,149 @@ const (
serverAddressKey = "server.address"
)
type ColumnAvailability struct {
HttpURL bool
URLFull bool
NetPeerName bool
ServerAddress bool
}
func CheckColumnAvailability(ctx context.Context, querier interface{}, orgID interface{}, start, end uint64) *ColumnAvailability {
availability := &ColumnAvailability{
HttpURL: true,
URLFull: false,
NetPeerName: true,
ServerAddress: false,
}
var orgUUID valuer.UUID
if uuid, ok := orgID.(valuer.UUID); ok {
orgUUID = uuid
} else {
return availability
}
testQueries := map[string]*bool{
urlPathKey: &availability.URLFull,
serverAddressKey: &availability.ServerAddress,
}
for attrKey, availabilityFlag := range testQueries {
testQuery := &qbtypes.QueryRangeRequest{
SchemaVersion: "v5",
Start: start,
End: end,
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "test",
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: defaultStepInterval},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: &qbtypes.Filter{
Expression: fmt.Sprintf("%s EXISTS AND kind_string = 'Client'", attrKey),
},
Limit: 1,
},
},
},
},
}
if q, ok := querier.(interface {
QueryRange(context.Context, valuer.UUID, *v3.QueryRangeParamsV3) ([]*v3.Result, map[string]error, error)
}); ok {
v3CompositeQuery := &v3.CompositeQuery{
Queries: testQuery.CompositeQuery.Queries,
}
v3Query := &v3.QueryRangeParamsV3{
Start: int64(start * 1000),
End: int64(end * 1000),
CompositeQuery: v3CompositeQuery,
}
_, queryErrors, err := q.QueryRange(ctx, orgUUID, v3Query)
if err == nil && len(queryErrors) == 0 {
*availabilityFlag = true
} else {
*availabilityFlag = false
}
}
}
return availability
}
func getAvailableServerGroupBy(availability *ColumnAvailability) []qbtypes.GroupByKey {
var groupByKeys []qbtypes.GroupByKey
if availability.ServerAddress && availability.NetPeerName {
groupByKeys = dualSemconvGroupByKeys["server"]
} else if availability.ServerAddress {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
} else {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
}
return groupByKeys
}
func getAvailableURLGroupBy(availability *ColumnAvailability) []qbtypes.GroupByKey {
var groupByKeys []qbtypes.GroupByKey
if availability.URLFull && availability.HttpURL {
groupByKeys = dualSemconvGroupByKeys["url"]
} else if availability.URLFull {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
} else {
groupByKeys = []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
}
}
return groupByKeys
}
var defaultStepInterval = 60 * time.Second
type SemconvFieldMapping struct {
@@ -211,6 +357,29 @@ func isValidValue(val any) bool {
return true
}
func FilterNullValues(result *qbtypes.QueryRangeResponse) *qbtypes.QueryRangeResponse {
if result == nil || result.Data.Results == nil {
return result
}
for _, res := range result.Data.Results {
scalarData, ok := res.(*qbtypes.ScalarData)
if !ok {
continue
}
filteredData := make([][]any, 0)
for _, row := range scalarData.Data {
if len(row) > 0 && isValidValue(row[0]) {
filteredData = append(filteredData, row)
}
}
scalarData.Data = filteredData
}
return result
}
func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRangeResponse {
filteredResults := make([]*qbtypes.QueryRangeResponse, 0, len(results))
@@ -296,18 +465,18 @@ func mergeGroupBy(base, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append(base, additional...)
}
func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.QueryRangeRequest, error) {
func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
queries := []qbtypes.QueryEnvelope{
buildEndpointsQuery(req),
buildLastSeenQuery(req),
buildRpsQuery(req),
buildErrorQuery(req),
buildTotalSpanQuery(req),
buildP99Query(req),
buildEndpointsQuery(req, availability),
buildLastSeenQuery(req, availability),
buildRpsQuery(req, availability),
buildErrorQuery(req, availability),
buildTotalSpanQuery(req, availability),
buildP99Query(req, availability),
buildErrorRateFormula(),
}
@@ -325,16 +494,16 @@ func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.Que
}, nil
}
func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.QueryRangeRequest, error) {
func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
queries := []qbtypes.QueryEnvelope{
buildEndpointsInfoQuery(req),
buildP99InfoQuery(req),
buildErrorRateInfoQuery(req),
buildLastSeenInfoQuery(req),
buildEndpointsInfoQuery(req, availability),
buildP99InfoQuery(req, availability),
buildErrorRateInfoQuery(req, availability),
buildLastSeenInfoQuery(req, availability),
}
return &qbtypes.QueryRangeRequest{
@@ -351,7 +520,7 @@ func BuildDomainInfo(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.Que
}, nil
}
func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -361,13 +530,13 @@ func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count_distinct(http.url)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -377,13 +546,13 @@ func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Qu
Aggregations: []qbtypes.TraceAggregation{
{Expression: "max(timestamp)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -393,13 +562,13 @@ func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -409,13 +578,13 @@ func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Query
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildErrorFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildErrorFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -425,13 +594,13 @@ func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -441,8 +610,8 @@ func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
Aggregations: []qbtypes.TraceAggregation{
{Expression: "p99(duration_nano)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableServerGroupBy(availability), req.GroupBy),
},
}
}
@@ -457,7 +626,7 @@ func buildErrorRateFormula() qbtypes.QueryEnvelope {
}
}
func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -467,13 +636,13 @@ func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtyp
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate(http.url)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["url"], req.GroupBy),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: mergeGroupBy(getAvailableURLGroupBy(availability), req.GroupBy),
},
}
}
func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -483,13 +652,13 @@ func buildP99InfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Que
Aggregations: []qbtypes.TraceAggregation{
{Expression: "p99(duration_nano)"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -499,13 +668,13 @@ func buildErrorRateInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtyp
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate()"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest, availability *ColumnAvailability) qbtypes.QueryEnvelope {
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -515,15 +684,24 @@ func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtype
Aggregations: []qbtypes.TraceAggregation{
{Expression: "max(timestamp)"},
},
Filter: buildBaseFilter(req.Filter),
Filter: buildBaseFilter(req.Filter, availability),
GroupBy: req.GroupBy,
},
}
}
func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
baseExpression := fmt.Sprintf("(%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
func buildBaseFilter(additionalFilter *qbtypes.Filter, availability *ColumnAvailability) *qbtypes.Filter {
var urlExistsExpression string
if availability.URLFull && availability.HttpURL {
urlExistsExpression = fmt.Sprintf("(%s EXISTS OR %s EXISTS)", urlPathKeyLegacy, urlPathKey)
} else if availability.URLFull {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKey)
} else {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKeyLegacy)
}
baseExpression := fmt.Sprintf("(%s) AND kind_string = 'Client'", urlExistsExpression)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
@@ -535,9 +713,18 @@ func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
return &qbtypes.Filter{Expression: baseExpression}
}
func buildErrorFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
errorExpression := fmt.Sprintf("has_error = true AND (%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
func buildErrorFilter(additionalFilter *qbtypes.Filter, availability *ColumnAvailability) *qbtypes.Filter {
var urlExistsExpression string
if availability.URLFull && availability.HttpURL {
urlExistsExpression = fmt.Sprintf("(%s EXISTS OR %s EXISTS)", urlPathKeyLegacy, urlPathKey)
} else if availability.URLFull {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKey)
} else {
urlExistsExpression = fmt.Sprintf("%s EXISTS", urlPathKeyLegacy)
}
errorExpression := fmt.Sprintf("has_error = true AND (%s) AND kind_string = 'Client'", urlExistsExpression)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {

View File

@@ -5045,8 +5045,10 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
return
}
availability := thirdpartyapi.CheckColumnAvailability(r.Context(), aH.Signoz.Querier, orgID, thirdPartyQueryRequest.Start, thirdPartyQueryRequest.End)
// Build the v5 query range request for domain listing
queryRangeRequest, err := thirdpartyapi.BuildDomainList(thirdPartyQueryRequest)
queryRangeRequest, err := thirdpartyapi.BuildDomainList(thirdPartyQueryRequest, availability)
if err != nil {
zap.L().Error("Failed to build domain list query", zap.Error(err))
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
@@ -5065,6 +5067,7 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
result = thirdpartyapi.FilterNullValues(result)
// Filter IP addresses if ShowIp is false
var finalResult = result
@@ -5102,8 +5105,16 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
return
}
// Check column availability for semconv attributes
availability := thirdpartyapi.CheckColumnAvailability(r.Context(), aH.Signoz.Querier, orgID, thirdPartyQueryRequest.Start, thirdPartyQueryRequest.End)
zap.L().Debug("Column availability detected for domain info",
zap.Bool("http_url", availability.HttpURL),
zap.Bool("url_full", availability.URLFull),
zap.Bool("net_peer_name", availability.NetPeerName),
zap.Bool("server_address", availability.ServerAddress))
// Build the v5 query range request for domain info
queryRangeRequest, err := thirdpartyapi.BuildDomainInfo(thirdPartyQueryRequest)
queryRangeRequest, err := thirdpartyapi.BuildDomainInfo(thirdPartyQueryRequest, availability)
if err != nil {
zap.L().Error("Failed to build domain info query", zap.Error(err))
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
@@ -5122,6 +5133,7 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
result = thirdpartyapi.FilterNullValues(result)
// Filter IP addresses if ShowIp is false
var finalResult *qbtypes.QueryRangeResponse = result

View File

@@ -114,78 +114,3 @@ func (index *UniqueIndex) ToDropSQL(fmter SQLFormatter) []byte {
return sql
}
type NormalIndex struct {
TableName TableName
ColumnNames []ColumnName
name string
}
func (index *NormalIndex) Name() string {
if index.name != "" {
return index.name
}
var b strings.Builder
b.WriteString(IndexTypeIndex.String())
b.WriteString("_")
b.WriteString(string(index.TableName))
b.WriteString("_")
for i, column := range index.ColumnNames {
if i > 0 {
b.WriteString("_")
}
b.WriteString(string(column))
}
return b.String()
}
func (index *NormalIndex) Named(name string) Index {
copyOfColumnNames := make([]ColumnName, len(index.ColumnNames))
copy(copyOfColumnNames, index.ColumnNames)
return &NormalIndex{
TableName: index.TableName,
ColumnNames: copyOfColumnNames,
name: name,
}
}
func (*NormalIndex) Type() IndexType {
return IndexTypeIndex
}
func (index *NormalIndex) Columns() []ColumnName {
return index.ColumnNames
}
func (index *NormalIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql := []byte{}
sql = append(sql, "CREATE INDEX IF NOT EXISTS "...)
sql = fmter.AppendIdent(sql, index.Name())
sql = append(sql, " ON "...)
sql = fmter.AppendIdent(sql, string(index.TableName))
sql = append(sql, " ("...)
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = append(sql, ")"...)
return sql
}
func (index *NormalIndex) ToDropSQL(fmter SQLFormatter) []byte {
sql := []byte{}
sql = append(sql, "DROP INDEX IF EXISTS "...)
sql = fmter.AppendIdent(sql, index.Name())
return sql
}

Some files were not shown because too many files have changed in this diff Show More