Merge branch 'enhancement/cmd-click-stack' of github.com:SigNoz/signoz into enhancement/cmd-click-stack

This commit is contained in:
manika-signoz 2025-09-30 17:41:07 +05:30
commit 7616cb89e4
90 changed files with 4361 additions and 703 deletions

View File

@ -26,10 +26,6 @@ type resources
define create: [user, role#assignee] define create: [user, role#assignee]
define list: [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 type resource
relations relations
define read: [user, anonymous, role#assignee] 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. // 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, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc { func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context()) claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil { if err != nil {
@ -115,13 +115,13 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
return return
} }
selector, parentSelectors, err := cb(req) selector, err := cb(req.Context(), claims)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return
} }
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...) err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,10 @@
import './styles.scss'; import './styles.scss';
import '../EvaluationSettings/styles.scss';
import { Button, Select, Typography } from 'antd'; import { Button, Select, Tooltip, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames'; import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-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 { useCreateAlertState } from '../context';
import { import {
@ -21,27 +17,30 @@ import {
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings'; import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils'; import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem'; import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types'; import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import { import {
getCategoryByOptionId, getCategoryByOptionId,
getCategorySelectOptionByName, getCategorySelectOptionByName,
getMatchTypeTooltip,
getQueryNames, getQueryNames,
RoutingPolicyBanner,
} from './utils'; } from './utils';
function AlertThreshold(): JSX.Element { function AlertThreshold({
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { const {
alertState, alertState,
thresholdState, thresholdState,
setThresholdState, setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState(); } = useCreateAlertState();
const { data, isLoading: isLoadingChannels } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout(); const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
@ -85,6 +84,65 @@ function AlertThreshold(): JSX.Element {
}); });
}; };
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 ? ( const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings /> <EvaluationSettings />
) : ( ) : (
@ -114,8 +172,7 @@ function AlertThreshold(): JSX.Element {
style={{ width: 80 }} style={{ width: 80 }}
options={queryNames} options={queryNames}
/> />
</div> <Typography.Text className="sentence-text">is</Typography.Text>
<div className="alert-condition-sentence">
<Select <Select
value={thresholdState.operator} value={thresholdState.operator}
onChange={(value): void => { onChange={(value): void => {
@ -124,7 +181,7 @@ function AlertThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 120 }} style={{ width: 180 }}
options={THRESHOLD_OPERATOR_OPTIONS} options={THRESHOLD_OPERATOR_OPTIONS}
/> />
<Typography.Text className="sentence-text"> <Typography.Text className="sentence-text">
@ -138,8 +195,8 @@ function AlertThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 140 }} style={{ width: 180 }}
options={THRESHOLD_MATCH_TYPE_OPTIONS} options={matchTypeOptionsWithTooltips}
/> />
<Typography.Text className="sentence-text"> <Typography.Text className="sentence-text">
during the {evaluationWindowContext} during the {evaluationWindowContext}
@ -158,6 +215,8 @@ function AlertThreshold(): JSX.Element {
channels={channels} channels={channels}
isLoadingChannels={isLoadingChannels} isLoadingChannels={isLoadingChannels}
units={categorySelectOptions} units={categorySelectOptions}
isErrorChannels={isErrorChannels}
refreshChannels={refreshChannels}
/> />
))} ))}
<Button <Button
@ -169,6 +228,11 @@ function AlertThreshold(): JSX.Element {
Add Threshold Add Threshold
</Button> </Button>
</div> </div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div> </div>
); );
} }

View File

@ -1,5 +1,6 @@
import { Select, Typography } from 'antd'; import { Select, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useCreateAlertState } from '../context'; import { useCreateAlertState } from '../context';
@ -10,10 +11,26 @@ import {
ANOMALY_THRESHOLD_OPERATOR_OPTIONS, ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS, ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants'; } from '../context/constants';
import { getQueryNames } from './utils'; import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
NotificationChannelsNotFoundContent,
RoutingPolicyBanner,
} from './utils';
function AnomalyThreshold(): JSX.Element { function AnomalyThreshold({
const { thresholdState, setThresholdState } = useCreateAlertState(); channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
}: AnomalyAndThresholdProps): JSX.Element {
const { user } = useAppContext();
const {
thresholdState,
setThresholdState,
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
@ -27,7 +44,11 @@ function AnomalyThreshold(): JSX.Element {
return options; return options;
}, []); }, []);
const updateThreshold = (id: string, field: string, value: string): void => { const updateThreshold = (
id: string,
field: string,
value: string | string[],
): void => {
setThresholdState({ setThresholdState({
type: 'SET_THRESHOLDS', type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) => payload: thresholdState.thresholds.map((t) =>
@ -53,7 +74,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={queryNames} options={queryNames}
/> />
<Typography.Text <Typography.Text
@ -71,12 +91,11 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={ANOMALY_TIME_DURATION_OPTIONS} options={ANOMALY_TIME_DURATION_OPTIONS}
/> />
</div> </div>
{/* Sentence 2 */}
<div className="alert-condition-sentence"> <div className="alert-condition-sentence">
{/* Sentence 2 */}
<Typography.Text data-testid="threshold-text" className="sentence-text"> <Typography.Text data-testid="threshold-text" className="sentence-text">
is is
</Typography.Text> </Typography.Text>
@ -90,7 +109,6 @@ function AnomalyThreshold(): JSX.Element {
value.toString(), value.toString(),
); );
}} }}
style={{ width: 80 }}
options={deviationOptions} options={deviationOptions}
/> />
<Typography.Text data-testid="deviations-text" className="sentence-text"> <Typography.Text data-testid="deviations-text" className="sentence-text">
@ -105,7 +123,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS} options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
/> />
<Typography.Text <Typography.Text
@ -123,7 +140,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS} options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
/> />
</div> </div>
@ -141,7 +157,6 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={ANOMALY_ALGORITHM_OPTIONS} options={ANOMALY_ALGORITHM_OPTIONS}
/> />
<Typography.Text <Typography.Text
@ -159,14 +174,58 @@ function AnomalyThreshold(): JSX.Element {
payload: value, payload: value,
}); });
}} }}
style={{ width: 80 }}
options={ANOMALY_SEASONALITY_OPTIONS} options={ANOMALY_SEASONALITY_OPTIONS}
/> />
<Typography.Text data-testid="seasonality-text" className="sentence-text"> {notificationSettings.routingPolicies ? (
seasonality <>
</Typography.Text> <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>
)}
</div> </div>
</div> </div>
<RoutingPolicyBanner
notificationSettings={notificationSettings}
setNotificationSettings={setNotificationSettings}
/>
</div> </div>
); );
} }

View File

@ -1,8 +1,12 @@
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd'; import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { ChartLine, CircleX } from 'lucide-react'; import { CircleX, Trash } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { ThresholdItemProps } from './types'; import { ThresholdItemProps } from './types';
import { NotificationChannelsNotFoundContent } from './utils';
function ThresholdItem({ function ThresholdItem({
threshold, threshold,
@ -11,7 +15,12 @@ function ThresholdItem({
showRemoveButton, showRemoveButton,
channels, channels,
units, units,
isErrorChannels,
refreshChannels,
isLoadingChannels,
}: ThresholdItemProps): JSX.Element { }: ThresholdItemProps): JSX.Element {
const { user } = useAppContext();
const { thresholdState, notificationSettings } = useCreateAlertState();
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false); const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
const yAxisUnitSelect = useMemo(() => { const yAxisUnitSelect = useMemo(() => {
@ -45,6 +54,31 @@ function ThresholdItem({
return component; return component;
}, [units, threshold.unit, updateThreshold, threshold.id]); }, [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 ( return (
<div key={threshold.id} className="threshold-item"> <div key={threshold.id} className="threshold-item">
<div className="threshold-row"> <div className="threshold-row">
@ -54,80 +88,111 @@ function ThresholdItem({
style={{ backgroundColor: threshold.color }} style={{ backgroundColor: threshold.color }}
/> />
</div> </div>
<Space className="threshold-controls"> <div className="threshold-controls">
<div className="threshold-inputs"> <Input
<Input.Group> placeholder="Enter threshold name"
<Input value={threshold.label}
placeholder="Enter threshold name" onChange={(e): void =>
value={threshold.label} updateThreshold(threshold.id, 'label', e.target.value)
onChange={(e): void =>
updateThreshold(threshold.id, 'label', e.target.value)
}
style={{ width: 260 }}
/>
<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 }} style={{ width: 200 }}
options={channels.map((channel) => ({
value: channel.id,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
/> />
<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>
<Input
placeholder="Enter recovery threshold value"
value={threshold.recoveryThresholdValue ?? ''}
onChange={(e): void =>
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
}
style={{ width: 100 }}
type="number"
/>
<Tooltip title="Remove recovery threshold">
<Button
type="default"
icon={<Trash size={16} />}
onClick={removeRecoveryThreshold}
className="icon-btn"
/>
</Tooltip>
</>
)}
<Button.Group> <Button.Group>
{!showRecoveryThreshold && ( {/* TODO: Add recovery threshold back once the functionality is implemented */}
<Button {/* {!showRecoveryThreshold && (
type="default" <Tooltip title="Add recovery threshold">
icon={<ChartLine size={16} />} <Button
className="icon-btn" type="default"
onClick={(): void => setShowRecoveryThreshold(true)} icon={<ChartLine size={16} />}
/> className="icon-btn"
)} onClick={addRecoveryThreshold}
/>
</Tooltip>
)} */}
{showRemoveButton && ( {showRemoveButton && (
<Button <Tooltip title="Remove threshold">
type="default" <Button
icon={<CircleX size={16} />} type="default"
onClick={(): void => removeThreshold(threshold.id)} icon={<CircleX size={16} />}
className="icon-btn" onClick={(): void => removeThreshold(threshold.id)}
/> className="icon-btn"
/>
</Tooltip>
)} )}
</Button.Group> </Button.Group>
</Space> </div>
</div> </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> </div>
); );
} }

View File

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

View File

@ -3,11 +3,23 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll'; import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context'; import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold'; 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', () => { jest.mock('uplot', () => {
const paths = { const paths = {
spline: jest.fn(), spline: jest.fn(),
@ -99,7 +111,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
const TEST_STRINGS = { const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold', ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE', AT_LEAST_ONCE: 'AT LEAST ONCE',
IS_ABOVE: 'IS ABOVE', IS_ABOVE: 'ABOVE',
} as const; } as const;
const createTestQueryClient = (): QueryClient => const createTestQueryClient = (): QueryClient =>
@ -116,8 +128,8 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
return render( return render(
<MemoryRouter> <MemoryRouter>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<CreateAlertProvider> <CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<AlertThreshold /> <AlertThreshold {...mockProps} />
</CreateAlertProvider> </CreateAlertProvider>
</QueryClientProvider> </QueryClientProvider>
</MemoryRouter>, </MemoryRouter>,
@ -125,7 +137,10 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
}; };
const verifySelectRenders = (title: string): void => { const verifySelectRenders = (title: string): void => {
const select = screen.getByTitle(title); let select = screen.queryByTitle(title);
if (!select) {
select = screen.getByText(title);
}
expect(select).toBeInTheDocument(); expect(select).toBeInTheDocument();
}; };

View File

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

View File

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

View File

@ -67,7 +67,7 @@
padding-right: 72px; padding-right: 72px;
background-color: var(--bg-ink-500); background-color: var(--bg-ink-500);
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
width: fit-content; width: 100%;
.alert-condition-sentences { .alert-condition-sentences {
display: flex; display: flex;
@ -90,7 +90,7 @@
} }
.ant-select { .ant-select {
width: 240px !important; width: 240px;
.ant-select-selector { .ant-select-selector {
background-color: var(--bg-ink-300); background-color: var(--bg-ink-300);
@ -148,6 +148,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
.ant-input { .ant-input {
background-color: var(--bg-ink-400); background-color: var(--bg-ink-400);
@ -277,6 +278,29 @@
} }
} }
} }
.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, .condensed-alert-threshold-container,
@ -293,7 +317,8 @@
.ant-btn { .ant-btn {
display: flex; display: flex;
align-items: center; align-items: center;
width: 240px; min-width: 240px;
width: auto;
justify-content: space-between; justify-content: space-between;
background-color: var(--bg-ink-300); background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
@ -301,6 +326,7 @@
.evaluate-alert-conditions-button-left { .evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-size: 12px; font-size: 12px;
flex-shrink: 0;
} }
.evaluate-alert-conditions-button-right { .evaluate-alert-conditions-button-right {
@ -308,6 +334,7 @@
align-items: center; align-items: center;
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
gap: 8px; gap: 8px;
flex-shrink: 0;
.evaluate-alert-conditions-button-right-text { .evaluate-alert-conditions-button-right-text {
font-size: 12px; font-size: 12px;
@ -318,3 +345,229 @@
} }
} }
} }
.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 {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-300);
&: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,14 +1,18 @@
import { DefaultOptionType } from 'antd/es/select'; import { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll'; import { Channels } from 'types/api/channels/getAll';
import { Threshold } from '../context/types'; import {
NotificationSettingsAction,
NotificationSettingsState,
Threshold,
} from '../context/types';
export type UpdateThreshold = { export type UpdateThreshold = {
(thresholdId: string, field: 'channels', value: string[]): void; (thresholdId: string, field: 'channels', value: string[]): void;
( (
thresholdId: string, thresholdId: string,
field: Exclude<keyof Threshold, 'channels'>, field: Exclude<keyof Threshold, 'channels'>,
value: string, value: string | number | null,
): void; ): void;
}; };
@ -20,4 +24,20 @@ export interface ThresholdItemProps {
channels: Channels[]; channels: Channels[];
isLoadingChannels: boolean; isLoadingChannels: boolean;
units: DefaultOptionType[]; 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,9 +1,19 @@
import { Button, Flex, Switch, Typography } from 'antd';
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select'; import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils'; import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants'; 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 { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { RoutingPolicyBannerProps } from './types';
export function getQueryNames(currentQuery: Query): BaseOptionType[] { export function getQueryNames(currentQuery: Query): BaseOptionType[] {
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator( const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
@ -44,3 +54,360 @@ 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

@ -38,7 +38,6 @@ function CreateAlertHeader(): JSX.Element {
<div className="alert-header__tab-bar"> <div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div> <div className="alert-header__tab">New Alert Rule</div>
</div> </div>
<div className="alert-header__content"> <div className="alert-header__content">
<input <input
type="text" type="text"
@ -49,15 +48,6 @@ function CreateAlertHeader(): JSX.Element {
className="alert-header__input title" className="alert-header__input title"
placeholder="Enter alert rule name" 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 <LabelsInput
labels={alertState.labels} labels={alertState.labels}
onLabelsChange={(labels: Labels): void => onLabelsChange={(labels: Labels): void =>

View File

@ -1,9 +1,21 @@
/* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import { CreateAlertProvider } from '../../context'; import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader'; 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.mock('uplot', () => { jest.mock('uplot', () => {
const paths = { const paths = {
spline: jest.fn(), spline: jest.fn(),
@ -27,7 +39,7 @@ jest.mock('react-router-dom', () => ({
const renderCreateAlertHeader = (): ReturnType<typeof render> => const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render( render(
<CreateAlertProvider> <CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<CreateAlertHeader /> <CreateAlertHeader />
</CreateAlertProvider>, </CreateAlertProvider>,
); );
@ -44,14 +56,6 @@ describe('CreateAlertHeader', () => {
expect(nameInput).toBeInTheDocument(); 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', () => { it('renders LabelsInput component', () => {
renderCreateAlertHeader(); renderCreateAlertHeader();
expect(screen.getByText('+ Add labels')).toBeInTheDocument(); expect(screen.getByText('+ Add labels')).toBeInTheDocument();
@ -65,13 +69,4 @@ describe('CreateAlertHeader', () => {
expect(nameInput).toHaveValue('Test Alert'); expect(nameInput).toHaveValue('Test Alert');
}); });
it('updates description when typing in description input', () => {
renderCreateAlertHeader();
const descriptionInput = screen.getByPlaceholderText(
'Click to add description...',
);
fireEvent.change(descriptionInput, { target: { value: 'Test Description' } });
expect(descriptionInput).toHaveValue('Test Description');
});
}); });

View File

@ -3,21 +3,6 @@
font-family: inherit; font-family: inherit;
color: var(--text-vanilla-100); 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 block visuals */
&__tab { &__tab {
display: flex; display: flex;
@ -44,6 +29,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
min-width: 300px;
flex: 1;
} }
&__input.title { &__input.title {
@ -51,6 +38,8 @@
font-weight: 500; font-weight: 500;
background-color: transparent; background-color: transparent;
color: var(--text-vanilla-100); color: var(--text-vanilla-100);
width: 100%;
min-width: 300px;
} }
&__input:focus, &__input:focus,
@ -64,6 +53,15 @@
background-color: transparent; background-color: transparent;
color: var(--text-vanilla-300); 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 { .labels-input {
@ -149,3 +147,65 @@
} }
} }
} }
.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);
}
}
.labels-input {
&__add-button {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
&: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,17 +1,20 @@
$top-nav-background-1: #0f0f0f;
$top-nav-background-2: #101010;
.create-alert-v2-container { .create-alert-v2-container {
background-color: var(--bg-ink-500); background-color: var(--bg-ink-500);
padding-bottom: 100px;
} }
.top-nav-container { .lightMode {
background: repeating-linear-gradient( .create-alert-v2-container {
-45deg, background-color: var(--bg-vanilla-100);
$top-nav-background-1, }
$top-nav-background-1 10px, }
$top-nav-background-2 10px,
$top-nav-background-2 20px .sticky-page-spinner {
); position: fixed;
margin-bottom: 0; inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.35);
z-index: 10000;
pointer-events: auto;
} }

View File

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

View File

@ -114,6 +114,14 @@
height: 32px; height: 32px;
border: 1px solid var(--bg-slate-400); 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 { .ant-select-selection-placeholder {
font-family: 'Space Mono'; font-family: 'Space Mono';
} }

View File

@ -1,5 +1,4 @@
import { Collapse, Input, Select, Typography } from 'antd'; import { Collapse, Input, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context'; import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem'; import AdvancedOptionItem from './AdvancedOptionItem';
@ -8,10 +7,6 @@ import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element { function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState(); const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return ( return (
<div className="advanced-options-container"> <div className="advanced-options-container">
<Collapse bordered={false}> <Collapse bordered={false}>
@ -38,24 +33,15 @@ function AdvancedOptions(): JSX.Element {
} }
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit} value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/> />
<Select <Typography.Text>Minutes</Typography.Text>
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> </div>
} }
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
})
}
/> />
<AdvancedOptionItem <AdvancedOptionItem
title="Minimum data required" title="Minimum data required"
@ -80,8 +66,15 @@ function AdvancedOptions(): JSX.Element {
<Typography.Text>Datapoints</Typography.Text> <Typography.Text>Datapoints</Typography.Text>
</div> </div>
} }
onToggle={(): void =>
setAdvancedOptions({
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS',
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
})
}
/> />
<AdvancedOptionItem {/* TODO: Add back when the functionality is implemented */}
{/* <AdvancedOptionItem
title="Account for data delay" title="Account for data delay"
description="Shift the evaluation window backwards to account for data processing delays." 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." 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."
@ -119,7 +112,7 @@ function AdvancedOptions(): JSX.Element {
/> />
</div> </div>
} }
/> /> */}
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>
</div> </div>

View File

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

View File

@ -164,6 +164,14 @@
background-color: var(--bg-ink-300); background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100); color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
} }
} }
@ -529,6 +537,15 @@
background-color: var(--bg-vanilla-300); background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400); 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 { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import { import {
CUMULATIVE_WINDOW_DESCRIPTION, getCumulativeWindowDescription,
ROLLING_WINDOW_DESCRIPTION, getRollingWindowDescription,
TIMEZONE_DATA, TIMEZONE_DATA,
} from '../constants'; } from '../constants';
import TimeInput from '../TimeInput'; import TimeInput from '../TimeInput';
@ -116,7 +116,9 @@ function EvaluationWindowDetails({
if (isCurrentHour) { if (isCurrentHour) {
return ( return (
<div className="evaluation-window-details"> <div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text> <Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text> <Typography.Text>{displayText}</Typography.Text>
<div className="select-group"> <div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text> <Typography.Text>STARTING AT MINUTE</Typography.Text>
@ -134,7 +136,9 @@ function EvaluationWindowDetails({
if (isCurrentDay) { if (isCurrentDay) {
return ( return (
<div className="evaluation-window-details"> <div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text> <Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text> <Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group"> <div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text> <Typography.Text>STARTING AT</Typography.Text>
@ -159,7 +163,9 @@ function EvaluationWindowDetails({
if (isCurrentMonth) { if (isCurrentMonth) {
return ( return (
<div className="evaluation-window-details"> <div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text> <Typography.Text>
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>{displayText}</Typography.Text> <Typography.Text>{displayText}</Typography.Text>
<div className="select-group"> <div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text> <Typography.Text>STARTING ON DAY</Typography.Text>
@ -192,7 +198,9 @@ function EvaluationWindowDetails({
return ( return (
<div className="evaluation-window-details"> <div className="evaluation-window-details">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text> <Typography.Text>
{getRollingWindowDescription(evaluationWindow.timeframe)}
</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text> <Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text> <Typography.Text>{displayText}</Typography.Text>
<div className="select-group"> <div className="select-group">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -62,8 +62,87 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
value: timezone.value, value: timezone.value,
})); }));
export const CUMULATIVE_WINDOW_DESCRIPTION = export const getCumulativeWindowDescription = (timeframe?: string): string => {
'A Cumulative Window has a fixed starting point and expands over time.'; 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 ROLLING_WINDOW_DESCRIPTION = // eslint-disable-next-line sonarjs/cognitive-complexity
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.'; 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.`;
};

View File

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

View File

@ -0,0 +1,169 @@
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,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleDiscard = (): void => discardAlertRule();
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,
});
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
}, [
alertType,
basicAlertState,
thresholdState,
advancedOptions,
evaluationWindow,
notificationSettings,
currentQuery,
createAlertRule,
safeNavigate,
]);
const disableButtons =
isCreatingAlertRule || isTestingAlertRule || !!alertValidationMessage;
const saveAlertButton = useMemo(() => {
let button = (
<Button type="primary" onClick={handleSaveAlert} disabled={disableButtons}>
<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}
>
<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="text" onClick={handleDiscard} disabled={disableButtons}>
<X size={14} /> Discard
</Button>
<div className="button-group">
{testAlertButton}
{saveAlertButton}
</div>
</div>
);
}
export default Footer;

View File

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

View File

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

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

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

View File

@ -84,12 +84,28 @@
.ant-select { .ant-select {
.ant-select-selector { .ant-select-selector {
width: 120px; width: 120px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
} }
} }
.ant-select-multiple { .ant-select-multiple {
.ant-select-selector { .ant-select-selector {
width: 200px; width: 200px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
} }
} }
} }
@ -202,6 +218,15 @@
flex-shrink: 0; flex-shrink: 0;
.ant-select-selector { .ant-select-selector {
border: 1px solid var(--bg-slate-400); 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);
}
} }
} }
@ -327,6 +352,15 @@
.ant-select { .ant-select {
.ant-select-selector { .ant-select-selector {
border: 1px solid var(--bg-vanilla-300); 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,3 +1,4 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context'; import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview'; import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
@ -16,7 +17,7 @@ export interface ChartPreviewProps {
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element { function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const { currentQuery, panelType, stagedQuery } = useQueryBuilder(); const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
const { thresholdState, alertState } = useCreateAlertState(); const { thresholdState, alertState, setAlertState } = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector< const { selectedTime: globalSelectedInterval } = useSelector<
AppState, AppState,
GlobalReducer GlobalReducer
@ -25,14 +26,24 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const yAxisUnit = alertState.yAxisUnit || ''; 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 => ( const renderQBChartPreview = (): JSX.Element => (
<ChartPreviewComponent <ChartPreviewComponent
headline={ headline={headline}
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="" name=""
query={stagedQuery} query={stagedQuery}
selectedInterval={globalSelectedInterval} selectedInterval={globalSelectedInterval}
@ -47,12 +58,7 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
const renderPromAndChQueryChartPreview = (): JSX.Element => ( const renderPromAndChQueryChartPreview = (): JSX.Element => (
<ChartPreviewComponent <ChartPreviewComponent
headline={ headline={headline}
<PlotTag
queryType={currentQuery.queryType}
panelType={panelType || PANEL_TYPES.TIME_SERIES}
/>
}
name="Chart Preview" name="Chart Preview"
query={stagedQuery} query={stagedQuery}
alertDef={alertDef} alertDef={alertDef}

View File

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

View File

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

View File

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

View File

@ -77,6 +77,14 @@
.ant-select-selector { .ant-select-selector {
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300); background: var(--bg-ink-300);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
}
} }
} }
} }
@ -88,6 +96,18 @@
border: 1px solid var(--bg-slate-500); border: 1px solid var(--bg-slate-500);
.ant-card-body { .ant-card-body {
background-color: var(--bg-ink-500); 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;
}
}
} }
} }
} }
@ -99,3 +119,78 @@
border: 1px solid var(--bg-slate-400); 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-100);
&: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,3 +42,22 @@
background-position: center; background-position: center;
margin-left: 8px; 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

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

View File

@ -1,4 +1,7 @@
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi'; import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { import {
@ -72,6 +75,10 @@ export function CreateAlertProvider(
currentQueryToRedirect, currentQueryToRedirect,
{ {
[QueryParams.alertType]: value, [QueryParams.alertType]: value,
[QueryParams.ruleType]:
value === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
}, },
undefined, undefined,
true, true,
@ -107,6 +114,35 @@ export function CreateAlertProvider(
}); });
}, [alertType]); }, [alertType]);
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 contextValue: ICreateAlertContextProps = useMemo( const contextValue: ICreateAlertContextProps = useMemo(
() => ({ () => ({
alertState, alertState,
@ -121,6 +157,11 @@ export function CreateAlertProvider(
setAdvancedOptions, setAdvancedOptions,
notificationSettings, notificationSettings,
setNotificationSettings, setNotificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
}), }),
[ [
alertState, alertState,
@ -130,6 +171,11 @@ export function CreateAlertProvider(
evaluationWindow, evaluationWindow,
advancedOptions, advancedOptions,
notificationSettings, notificationSettings,
discardAlertRule,
createAlertRule,
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
], ],
); );

View File

@ -1,6 +1,11 @@
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { Dayjs } from 'dayjs'; import { Dayjs } from 'dayjs';
import { Dispatch } from 'react'; import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def'; import { Labels } from 'types/api/alerts/def';
export interface ICreateAlertContextProps { export interface ICreateAlertContextProps {
@ -16,10 +21,26 @@ export interface ICreateAlertContextProps {
setEvaluationWindow: Dispatch<EvaluationWindowAction>; setEvaluationWindow: Dispatch<EvaluationWindowAction>;
notificationSettings: NotificationSettingsState; notificationSettings: NotificationSettingsState;
setNotificationSettings: Dispatch<NotificationSettingsAction>; 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;
} }
export interface ICreateAlertProviderProps { export interface ICreateAlertProviderProps {
children: React.ReactNode; children: React.ReactNode;
initialAlertType: AlertTypes;
} }
export enum AlertCreationStep { export enum AlertCreationStep {
@ -31,14 +52,12 @@ export enum AlertCreationStep {
export interface AlertState { export interface AlertState {
name: string; name: string;
description: string;
labels: Labels; labels: Labels;
yAxisUnit: string | undefined; yAxisUnit: string | undefined;
} }
export type CreateAlertAction = export type CreateAlertAction =
| { type: 'SET_ALERT_NAME'; payload: string } | { type: 'SET_ALERT_NAME'; payload: string }
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
| { type: 'SET_ALERT_LABELS'; payload: Labels } | { type: 'SET_ALERT_LABELS'; payload: Labels }
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined } | { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
| { type: 'RESET' }; | { type: 'RESET' };
@ -47,7 +66,7 @@ export interface Threshold {
id: string; id: string;
label: string; label: string;
thresholdValue: number; thresholdValue: number;
recoveryThresholdValue: number; recoveryThresholdValue: number | null;
unit: string; unit: string;
channels: string[]; channels: string[];
color: string; color: string;
@ -114,9 +133,11 @@ export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: { sendNotificationIfDataIsMissing: {
toleranceLimit: number; toleranceLimit: number;
timeUnit: string; timeUnit: string;
enabled: boolean;
}; };
enforceMinimumDatapoints: { enforceMinimumDatapoints: {
minimumDatapoints: number; minimumDatapoints: number;
enabled: boolean;
}; };
delayEvaluation: { delayEvaluation: {
delay: number; delay: number;
@ -147,10 +168,18 @@ export type AdvancedOptionsAction =
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING'; type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string }; payload: { toleranceLimit: number; timeUnit: string };
} }
| {
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: boolean;
}
| { | {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS'; type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number }; payload: { minimumDatapoints: number };
} }
| {
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS';
payload: boolean;
}
| { | {
type: 'SET_DELAY_EVALUATION'; type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string }; payload: { delay: number; timeUnit: string };
@ -203,6 +232,7 @@ export interface NotificationSettingsState {
conditions: ('firing' | 'no-data')[]; conditions: ('firing' | 'no-data')[];
}; };
description: string; description: string;
routingPolicies: boolean;
} }
export type NotificationSettingsAction = export type NotificationSettingsAction =
@ -220,4 +250,5 @@ export type NotificationSettingsAction =
}; };
} }
| { type: 'SET_DESCRIPTION'; payload: string } | { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'RESET' }; | { type: 'RESET' };

View File

@ -41,11 +41,6 @@ export const alertCreationReducer = (
...state, ...state,
name: action.payload, name: action.payload,
}; };
case 'SET_ALERT_DESCRIPTION':
return {
...state,
description: action.payload,
};
case 'SET_ALERT_LABELS': case 'SET_ALERT_LABELS':
return { return {
...state, ...state,
@ -99,6 +94,10 @@ export function getInitialAlertTypeFromURL(
urlSearchParams: URLSearchParams, urlSearchParams: URLSearchParams,
currentQuery: Query, currentQuery: Query,
): AlertTypes { ): AlertTypes {
const ruleType = urlSearchParams.get(QueryParams.ruleType);
if (ruleType === 'anomaly_rule') {
return AlertTypes.ANOMALY_BASED_ALERT;
}
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType); const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
return alertTypeFromURL return alertTypeFromURL
? (alertTypeFromURL as AlertTypes) ? (alertTypeFromURL as AlertTypes)
@ -131,9 +130,38 @@ export const advancedOptionsReducer = (
): AdvancedOptionsState => { ): AdvancedOptionsState => {
switch (action.type) { switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING': case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return { ...state, sendNotificationIfDataIsMissing: action.payload }; 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,
},
};
case 'SET_ENFORCE_MINIMUM_DATAPOINTS': case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return { ...state, enforceMinimumDatapoints: action.payload }; return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
minimumDatapoints: action.payload.minimumDatapoints,
},
};
case 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS':
return {
...state,
enforceMinimumDatapoints: {
...state.enforceMinimumDatapoints,
enabled: action.payload,
},
};
case 'SET_DELAY_EVALUATION': case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload }; return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE': case 'SET_EVALUATION_CADENCE':
@ -190,6 +218,8 @@ export const notificationSettingsReducer = (
return { ...state, reNotification: action.payload }; return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION': case 'SET_DESCRIPTION':
return { ...state, description: action.payload }; return { ...state, description: action.payload };
case 'SET_ROUTING_POLICIES':
return { ...state, routingPolicies: action.payload };
case 'RESET': case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE; return INITIAL_NOTIFICATION_SETTINGS_STATE;
default: default:

View File

@ -0,0 +1,5 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
export interface CreateAlertV2Props {
alertType: AlertTypes;
}

View File

@ -1,3 +1,8 @@
import { Spin } from 'antd';
import { createPortal } from 'react-dom';
import { useCreateAlertState } from './context';
// UI side feature flag // UI side feature flag
export const showNewCreateAlertsPage = (): boolean => export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true'; localStorage.getItem('showNewCreateAlertsPage') === 'true';
@ -7,3 +12,16 @@ export const showNewCreateAlertsPage = (): boolean =>
// Layout 2 - Condensed layout // Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean => export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true'; localStorage.getItem('showCondensedLayout') === 'true';
export function Spinner(): JSX.Element | null {
const { isCreatingAlertRule } = useCreateAlertState();
if (!isCreatingAlertRule) return null;
return createPortal(
<div className="sticky-page-spinner">
<Spin size="large" spinning />
</div>,
document.body,
);
}

View File

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

View File

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

View File

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

@ -0,0 +1,18 @@
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,16 +1,6 @@
import CreateAlertRule from 'container/CreateAlertRule'; 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 { function CreateAlertPage(): JSX.Element {
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
if (showNewCreateAlertsPageFlag) {
return <CreateAlertV2 />;
}
return <CreateAlertRule />; return <CreateAlertRule />;
} }

View File

@ -0,0 +1,68 @@
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;
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: {
notificationGroupBy: string[];
renotify?: string;
alertStates: string[];
notificationPolicy: boolean;
};
version: string;
source: string;
}
export interface AlertRuleV2 extends PostableAlertRuleV2 {
schemaVersion: string;
state: string;
disabled: boolean;
createAt: string;
createBy: string;
updateAt: string;
updateBy: string;
}

View File

@ -12,8 +12,14 @@ type AuthZ interface {
factory.Service 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 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.CheckRequestTupleKey) error Check(context.Context, *openfgav1.TupleKey) error
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does. // CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, authtypes.Typeable, ...authtypes.Selector) error 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)
} }

View File

@ -176,13 +176,17 @@ func (provider *provider) isModelEqual(expected *openfgav1.AuthorizationModel, a
} }
func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRequestTupleKey) error { func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.TupleKey) error {
checkResponse, err := provider.openfgaServer.Check( checkResponse, err := provider.openfgaServer.Check(
ctx, ctx,
&openfgav1.CheckRequest{ &openfgav1.CheckRequest{
StoreId: provider.storeID, StoreId: provider.storeID,
AuthorizationModelId: provider.modelID, AuthorizationModelId: provider.modelID,
TupleKey: tupleReq, TupleKey: &openfgav1.CheckRequestTupleKey{
User: tupleReq.User,
Relation: tupleReq.Relation,
Object: tupleReq.Object,
},
}) })
if err != nil { if err != nil {
return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error()) return errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthZUnavailable, "authorization server is unavailable").WithAdditional(err.Error())
@ -195,39 +199,79 @@ func (provider *provider) Check(ctx context.Context, tupleReq *openfgav1.CheckRe
return nil return nil
} }
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 { 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 {
subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{}) subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
if err != nil { if err != nil {
return err return err
} }
tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...) tuples, err := typeable.Tuples(subject, relation, selectors)
if err != nil { if err != nil {
return err return err
} }
check, err := provider.sequentialCheck(ctx, tuples) err = provider.BatchCheck(ctx, tuples)
if err != nil { if err != nil {
return err 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 return nil
} }
func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) { func (provider *provider) Write(ctx context.Context, req *openfgav1.WriteRequest) error {
for _, tupleReq := range tuplesReq { _, err := provider.openfgaServer.Write(ctx, &openfgav1.WriteRequest{
err := provider.Check(ctx, tupleReq) StoreId: provider.storeID,
if err == nil { AuthorizationModelId: provider.modelID,
return true, nil Writes: req.Writes,
} })
if errors.Ast(err, errors.TypeInternal) {
// return at the first internal error as the evaluation will be incorrect return err
return false, 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())
} }
return false, nil return authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
} }

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,103 @@
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)
})
}

66
pkg/modules/role/role.go Normal file
View File

@ -0,0 +1,66 @@
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,6 +1,7 @@
package authtypes package authtypes
import ( import (
"encoding/json"
"regexp" "regexp"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
@ -14,14 +15,39 @@ type Name struct {
val string val string
} }
func MustNewName(name string) Name { func NewName(name string) (Name, error) {
if !nameRegex.MatchString(name) { if !nameRegex.MatchString(name) {
panic(errors.NewInternalf(errors.CodeInternal, "name must conform to regex %s", nameRegex.String())) return Name{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name must conform to regex %s", nameRegex.String())
} }
return Name{val: name} return Name{val: name}, nil
}
func MustNewName(name string) Name {
named, err := NewName(name)
if err != nil {
panic(err)
}
return named
} }
func (name Name) String() string { func (name Name) String() string {
return name.val return name.val
} }
func (name *Name) UnmarshalJSON(data []byte) error {
nameStr := ""
err := json.Unmarshal(data, &nameStr)
if err != nil {
return err
}
shadow, err := NewName(nameStr)
if err != nil {
return err
}
*name = shadow
return nil
}

View File

@ -1,23 +0,0 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(organization)
type organization struct{}
func (organization *organization) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (organization *organization) Type() Type {
return TypeOrganization
}

View File

@ -1,6 +1,13 @@
package authtypes package authtypes
import "github.com/SigNoz/signoz/pkg/valuer" import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeAuthZInvalidRelation = errors.MustNewCode("authz_invalid_relation")
)
var ( var (
RelationCreate = Relation{valuer.NewString("create")} RelationCreate = Relation{valuer.NewString("create")}
@ -12,12 +19,33 @@ var (
RelationAssignee = Relation{valuer.NewString("assignee")} RelationAssignee = Relation{valuer.NewString("assignee")}
) )
var ( var TypeableRelations = map[Type][]Relation{
TypeUserSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete} TypeUser: {RelationRead, RelationUpdate, RelationDelete},
TypeRoleSupportedRelations = []Relation{RelationAssignee, RelationRead, RelationUpdate, RelationDelete} TypeRole: {RelationAssignee, RelationRead, RelationUpdate, RelationDelete},
TypeOrganizationSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} TypeOrganization: {RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList},
TypeResourceSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete, RelationBlock} TypeResource: {RelationRead, RelationUpdate, RelationDelete, RelationBlock},
TypeResourcesSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList} TypeResources: {RelationCreate, RelationList},
) }
type Relation struct{ valuer.String } type Relation struct{ valuer.String }
func NewRelation(relation string) (Relation, error) {
switch relation {
case "create":
return RelationCreate, nil
case "read":
return RelationRead, nil
case "update":
return RelationUpdate, nil
case "delete":
return RelationDelete, nil
case "list":
return RelationList, nil
case "block":
return RelationBlock, nil
case "assignee":
return RelationAssignee, nil
default:
return Relation{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidRelation, "invalid relation %s", relation)
}
}

View File

@ -1,37 +0,0 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(resource)
type resource struct {
name Name
}
func MustNewResource(name string) Typeable {
return &resource{name: MustNewName(name)}
}
func (resource *resource) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
for _, selector := range parentSelectors {
resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil)
if err != nil {
return nil, err
}
tuples = append(tuples, resourcesTuples...)
}
object := strings.Join([]string{TypeResource.StringValue(), resource.name.String(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (resource *resource) Type() Type {
return TypeResource
}

View File

@ -1,26 +0,0 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(resources)
type resources struct {
name Name
}
func MustNewResources(name string) Typeable {
return &resources{name: MustNewName(name)}
}
func (resources *resources) Tuples(subject string, relation Relation, selector Selector, _ Typeable, _ ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
object := strings.Join([]string{TypeResources.StringValue(), resources.name.String(), selector.String()}, ":")
return []*openfgav1.CheckRequestTupleKey{{User: subject, Relation: relation.StringValue(), Object: object}}, nil
}
func (resources *resources) Type() Type {
return TypeResources
}

View File

@ -1,31 +0,0 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(role)
type role struct{}
func (role *role) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
for _, selector := range parentSelectors {
resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil)
if err != nil {
return nil, err
}
tuples = append(tuples, resourcesTuples...)
}
object := strings.Join([]string{TypeRole.StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (role *role) Type() Type {
return TypeRole
}

View File

@ -1,53 +1,67 @@
package authtypes package authtypes
import ( import (
"net/http" "context"
"encoding/json"
"regexp" "regexp"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
) )
var (
ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex")
)
var ( var (
typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeUserSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeRoleSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeOrganizationSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeResourceSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeResourceSelectorRegex = regexp.MustCompile(`^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
typeResourcesSelectorRegex = regexp.MustCompile(`^org:[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`) typeResourcesSelectorRegex = regexp.MustCompile(`^org/[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)
) )
type SelectorCallbackFn func(*http.Request) (Selector, []Selector, error) type SelectorCallbackFn func(context.Context, Claims) ([]Selector, error)
type Selector struct { type Selector struct {
val string val string
} }
func NewSelector(typed Type, selector string) (Selector, error) { func NewSelector(typed Type, selector string) (Selector, error) {
switch typed { err := IsValidSelector(typed, Selector{val: selector})
case TypeUser: if err != nil {
if !typeUserSelectorRegex.MatchString(selector) { return Selector{}, err
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String())
}
case TypeRole:
if !typeRoleSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String())
}
case TypeOrganization:
if !typeOrganizationSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String())
}
case TypeResource:
if !typeResourceSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String())
}
case TypeResources:
if !typeResourcesSelectorRegex.MatchString(selector) {
return Selector{}, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String())
}
} }
return Selector{val: selector}, nil return Selector{val: selector}, nil
} }
func IsValidSelector(typed Type, selector Selector) error {
switch typed {
case TypeUser:
if !typeUserSelectorRegex.MatchString(selector.String()) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeUserSelectorRegex.String())
}
case TypeRole:
if !typeRoleSelectorRegex.MatchString(selector.String()) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeRoleSelectorRegex.String())
}
case TypeOrganization:
if !typeOrganizationSelectorRegex.MatchString(selector.String()) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeOrganizationSelectorRegex.String())
}
case TypeResource:
if !typeResourceSelectorRegex.MatchString(selector.String()) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourceSelectorRegex.String())
}
case TypeResources:
if !typeResourcesSelectorRegex.MatchString(selector.String()) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidSelectorRegex, "selector must conform to regex %s", typeResourcesSelectorRegex.String())
}
}
return nil
}
func MustNewSelector(typed Type, input string) Selector { func MustNewSelector(typed Type, input string) Selector {
selector, err := NewSelector(typed, input) selector, err := NewSelector(typed, input)
if err != nil { if err != nil {
@ -60,3 +74,16 @@ func MustNewSelector(typed Type, input string) Selector {
func (selector Selector) String() string { func (selector Selector) String() string {
return selector.val return selector.val
} }
func (typed *Selector) UnmarshalJSON(data []byte) error {
str := ""
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
shadow := Selector{val: str}
*typed = shadow
return nil
}

View File

@ -0,0 +1,108 @@
package authtypes
import (
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
type Resource struct {
Name Name `json:"name"`
Type Type `json:"type"`
}
type Object struct {
Resource Resource `json:"resource"`
Selector Selector `json:"selector"`
}
type Transaction struct {
Relation Relation `json:"relation"`
Object Object `json:"object"`
}
func NewObject(resource Resource, selector Selector) (*Object, error) {
err := IsValidSelector(resource.Type, selector)
if err != nil {
return nil, err
}
return &Object{Resource: resource, Selector: selector}, nil
}
func MustNewObjectFromString(input string) *Object {
parts := strings.Split(input, ":")
if len(parts) != 3 {
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid list objects output: %s", input))
}
resource := Resource{
Type: MustNewType(parts[0]),
Name: MustNewName(parts[1]),
}
object := &Object{
Resource: resource,
Selector: MustNewSelector(resource.Type, parts[2]),
}
return object
}
func MustNewObjectsFromStringSlice(input []string) []*Object {
objects := make([]*Object, 0, len(input))
for _, str := range input {
objects = append(objects, MustNewObjectFromString(str))
}
return objects
}
func (object *Object) UnmarshalJSON(data []byte) error {
var shadow = struct {
Resource Resource
Selector Selector
}{}
err := json.Unmarshal(data, &shadow)
if err != nil {
return err
}
obj, err := NewObject(shadow.Resource, shadow.Selector)
if err != nil {
return err
}
*object = *obj
return nil
}
func NewTransaction(relation Relation, object Object) (*Transaction, error) {
if !slices.Contains(TypeableRelations[object.Resource.Type], relation) {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAuthZInvalidRelation, "invalid relation %s for type %s", relation.StringValue(), object.Resource.Type.StringValue())
}
return &Transaction{Relation: relation, Object: object}, nil
}
func (transaction *Transaction) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation
Object Object
}{}
err := json.Unmarshal(data, &shadow)
if err != nil {
return err
}
txn, err := NewTransaction(shadow.Relation, shadow.Object)
if err != nil {
return err
}
*transaction = *txn
return nil
}

View File

@ -1,17 +1,16 @@
package authtypes package authtypes
import ( import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer" "github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1"
) )
var ( var (
ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable") ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable")
ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden") ErrCodeAuthZForbidden = errors.MustNewCode("authz_forbidden")
ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex")
ErrCodeAuthZUnsupportedRelation = errors.MustNewCode("authz_unsupported_relation")
ErrCodeAuthZInvalidSubject = errors.MustNewCode("authz_invalid_subject")
) )
var ( var (
@ -23,14 +22,92 @@ var (
) )
var ( var (
TypeableUser = &user{} TypeableUser = &typeableUser{}
TypeableRole = &role{} TypeableRole = &typeableRole{}
TypeableOrganization = &organization{} TypeableOrganization = &typeableOrganization{}
) )
type Typeable interface { type Typeable interface {
Type() Type Type() Type
Tuples(subject string, relation Relation, selector Selector, parentType Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) Name() Name
Prefix() string
Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error)
} }
type Type struct{ valuer.String } type Type struct{ valuer.String }
func MustNewType(input string) Type {
typed, err := NewType(input)
if err != nil {
panic(err)
}
return typed
}
func NewType(input string) (Type, error) {
switch input {
case "user":
return TypeUser, nil
case "role":
return TypeRole, nil
case "organization":
return TypeOrganization, nil
case "resource":
return TypeResource, nil
case "resources":
return TypeResources, nil
default:
return Type{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid type: %s", input)
}
}
func (typed *Type) UnmarshalJSON(data []byte) error {
str := ""
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
shadow, err := NewType(str)
if err != nil {
return err
}
*typed = shadow
return nil
}
func NewTypeableFromType(typed Type, name Name) (Typeable, error) {
switch typed {
case TypeRole:
return TypeableRole, nil
case TypeUser:
return TypeableUser, nil
case TypeOrganization:
return TypeableOrganization, nil
case TypeResource:
resource, err := NewTypeableResource(name)
if err != nil {
return nil, err
}
return resource, nil
case TypeResources:
resources, err := NewTypeableResources(name)
if err != nil {
return nil, err
}
return resources, nil
}
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid type")
}
func MustNewTypeableFromType(typed Type, name Name) Typeable {
typeable, err := NewTypeableFromType(typed, name)
if err != nil {
panic(err)
}
return typeable
}

View File

@ -0,0 +1,33 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(typeableOrganization)
type typeableOrganization struct{}
func (typeableOrganization *typeableOrganization) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, selector := range selector {
object := strings.Join([]string{typeableOrganization.Type().StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object})
}
return tuples, nil
}
func (typeableOrganization *typeableOrganization) Type() Type {
return TypeOrganization
}
func (typeableOrganization *typeableOrganization) Name() Name {
return MustNewName("organization")
}
func (typeableOrganization *typeableOrganization) Prefix() string {
return typeableOrganization.Type().StringValue()
}

View File

@ -0,0 +1,47 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(typeableResource)
type typeableResource struct {
name Name
}
func NewTypeableResource(name Name) (Typeable, error) {
return &typeableResource{name: name}, nil
}
func MustNewTypeableResource(name Name) Typeable {
typeableesource, err := NewTypeableResource(name)
if err != nil {
panic(err)
}
return typeableesource
}
func (typeableResource *typeableResource) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, selector := range selector {
object := typeableResource.Prefix() + "/" + selector.String()
tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object})
}
return tuples, nil
}
func (typeableResource *typeableResource) Type() Type {
return TypeResource
}
func (typeableResource *typeableResource) Name() Name {
return typeableResource.name
}
func (typeableResource *typeableResource) Prefix() string {
return strings.Join([]string{typeableResource.Type().StringValue(), typeableResource.Name().String()}, ":")
}

View File

@ -0,0 +1,47 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(typeableResources)
type typeableResources struct {
name Name
}
func NewTypeableResources(name Name) (Typeable, error) {
return &typeableResources{name: name}, nil
}
func MustNewTypeableResources(name Name) Typeable {
resources, err := NewTypeableResources(name)
if err != nil {
panic(err)
}
return resources
}
func (typeableResources *typeableResources) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, selector := range selector {
object := typeableResources.Prefix() + "/" + selector.String()
tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object})
}
return tuples, nil
}
func (typeableResources *typeableResources) Type() Type {
return TypeResources
}
func (typeableResources *typeableResources) Name() Name {
return typeableResources.name
}
func (typeableResources *typeableResources) Prefix() string {
return strings.Join([]string{typeableResources.Type().StringValue(), typeableResources.Name().String()}, ":")
}

View File

@ -0,0 +1,33 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(typeableRole)
type typeableRole struct{}
func (typeableRole *typeableRole) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, selector := range selector {
object := strings.Join([]string{typeableRole.Type().StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object})
}
return tuples, nil
}
func (typeableRole *typeableRole) Type() Type {
return TypeRole
}
func (typeableRole *typeableRole) Name() Name {
return MustNewName("role")
}
func (typeableRole *typeableRole) Prefix() string {
return typeableRole.Type().StringValue()
}

View File

@ -0,0 +1,33 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(typeableUser)
type typeableUser struct{}
func (typeableUser *typeableUser) Tuples(subject string, relation Relation, selector []Selector) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, selector := range selector {
object := strings.Join([]string{typeableUser.Type().StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.TupleKey{User: subject, Relation: relation.StringValue(), Object: object})
}
return tuples, nil
}
func (typeableUser *typeableUser) Type() Type {
return TypeUser
}
func (typeableUser *typeableUser) Name() Name {
return MustNewName("user")
}
func (typeableUser *typeableUser) Prefix() string {
return typeableUser.Type().StringValue()
}

View File

@ -1,31 +0,0 @@
package authtypes
import (
"strings"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
var _ Typeable = new(user)
type user struct{}
func (user *user) Tuples(subject string, relation Relation, selector Selector, parentTypeable Typeable, parentSelectors ...Selector) ([]*openfgav1.CheckRequestTupleKey, error) {
tuples := make([]*openfgav1.CheckRequestTupleKey, 0)
for _, selector := range parentSelectors {
resourcesTuples, err := parentTypeable.Tuples(subject, relation, selector, nil)
if err != nil {
return nil, err
}
tuples = append(tuples, resourcesTuples...)
}
object := strings.Join([]string{TypeUser.StringValue(), selector.String()}, ":")
tuples = append(tuples, &openfgav1.CheckRequestTupleKey{User: subject, Relation: relation.StringValue(), Object: object})
return tuples, nil
}
func (user *user) Type() Type {
return TypeUser
}

View File

@ -7,10 +7,16 @@ import (
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer" "github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
var (
ResourceDashboard = authtypes.MustNewTypeableResource(authtypes.MustNewName("dashboard"))
ResourcesDashboards = authtypes.MustNewTypeableResources(authtypes.MustNewName("dashboards"))
)
type StorableDashboard struct { type StorableDashboard struct {
bun.BaseModel `bun:"table:dashboard"` bun.BaseModel `bun:"table:dashboard"`

224
pkg/types/roletypes/role.go Normal file
View File

@ -0,0 +1,224 @@
package roletypes
import (
"encoding/json"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/uptrace/bun"
)
var (
ErrCodeRoleInvalidInput = errors.MustNewCode("role_invalid_input")
ErrCodeRoleEmptyPatch = errors.MustNewCode("role_empty_patch")
ErrCodeInvalidTypeRelation = errors.MustNewCode("role_invalid_type_relation")
ErrCodeRoleNotFound = errors.MustNewCode("role_not_found")
ErrCodeRoleFailedTransactionsFromString = errors.MustNewCode("role_failed_transactions_from_string")
)
type StorableRole struct {
bun.BaseModel `bun:"table:role"`
types.Identifiable
types.TimeAuditable
DisplayName string `bun:"display_name,type:string"`
Description string `bun:"description,type:string"`
OrgID string `bun:"org_id,type:string"`
}
type Role struct {
types.Identifiable
types.TimeAuditable
DisplayName string `json:"displayName"`
Description string `json:"description"`
OrgID valuer.UUID `json:"org_id"`
}
type PostableRole struct {
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
type PatchableRole struct {
DisplayName *string `json:"displayName"`
Description *string `json:"description"`
}
type PatchableObjects struct {
Additions []*authtypes.Object `json:"additions"`
Deletions []*authtypes.Object `json:"deletions"`
}
func NewStorableRoleFromRole(role *Role) (*StorableRole, error) {
return &StorableRole{
Identifiable: role.Identifiable,
TimeAuditable: role.TimeAuditable,
DisplayName: role.DisplayName,
Description: role.Description,
OrgID: role.OrgID.StringValue(),
}, nil
}
func NewRoleFromStorableRole(storableRole *StorableRole) (*Role, error) {
orgID, err := valuer.NewUUID(storableRole.OrgID)
if err != nil {
return nil, err
}
return &Role{
Identifiable: storableRole.Identifiable,
TimeAuditable: storableRole.TimeAuditable,
DisplayName: storableRole.DisplayName,
Description: storableRole.Description,
OrgID: orgID,
}, nil
}
func NewRole(displayName, description string, orgID valuer.UUID) *Role {
return &Role{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
DisplayName: displayName,
Description: description,
OrgID: orgID,
}
}
func NewPatchableObjects(additions []*authtypes.Object, deletions []*authtypes.Object, relation authtypes.Relation) (*PatchableObjects, error) {
if len(additions) == 0 && len(deletions) == 0 {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty object patch request received, at least one of additions or deletions must be present")
}
for _, object := range additions {
if !slices.Contains(authtypes.TypeableRelations[object.Resource.Type], relation) {
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeAuthZInvalidRelation, "relation %s is invalid for type %s", relation.StringValue(), object.Resource.Type.StringValue())
}
}
for _, object := range deletions {
if !slices.Contains(authtypes.TypeableRelations[object.Resource.Type], relation) {
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeAuthZInvalidRelation, "relation %s is invalid for type %s", relation.StringValue(), object.Resource.Type.StringValue())
}
}
return &PatchableObjects{Additions: additions, Deletions: deletions}, nil
}
func (role *Role) PatchMetadata(displayName, description *string) {
if displayName != nil {
role.DisplayName = *displayName
}
if description != nil {
role.Description = *description
}
role.UpdatedAt = time.Now()
}
func (role *PostableRole) UnmarshalJSON(data []byte) error {
type shadowPostableRole struct {
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
var shadowRole shadowPostableRole
if err := json.Unmarshal(data, &shadowRole); err != nil {
return err
}
if shadowRole.DisplayName == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "displayName is missing from the request")
}
role.DisplayName = shadowRole.DisplayName
role.Description = shadowRole.Description
return nil
}
func (role *PatchableRole) UnmarshalJSON(data []byte) error {
type shadowPatchableRole struct {
DisplayName *string `json:"displayName"`
Description *string `json:"description"`
}
var shadowRole shadowPatchableRole
if err := json.Unmarshal(data, &shadowRole); err != nil {
return err
}
if shadowRole.DisplayName == nil && shadowRole.Description == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleEmptyPatch, "empty role patch request received, at least one of displayName or description must be present")
}
role.DisplayName = shadowRole.DisplayName
role.Description = shadowRole.Description
return nil
}
func GetAdditionTuples(id valuer.UUID, relation authtypes.Relation, additions []*authtypes.Object) ([]*openfgav1.TupleKey, error) {
tuples := make([]*openfgav1.TupleKey, 0)
for _, object := range additions {
typeable := authtypes.MustNewTypeableFromType(object.Resource.Type, object.Resource.Name)
transactionTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeRole,
id.String(),
authtypes.RelationAssignee,
),
relation,
[]authtypes.Selector{object.Selector},
)
if err != nil {
return nil, err
}
tuples = append(tuples, transactionTuples...)
}
return tuples, nil
}
func GetDeletionTuples(id valuer.UUID, relation authtypes.Relation, deletions []*authtypes.Object) ([]*openfgav1.TupleKeyWithoutCondition, error) {
tuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
for _, object := range deletions {
typeable := authtypes.MustNewTypeableFromType(object.Resource.Type, object.Resource.Name)
transactionTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeRole,
id.String(),
authtypes.RelationAssignee,
),
relation,
[]authtypes.Selector{object.Selector},
)
if err != nil {
return nil, err
}
deletionTuples := make([]*openfgav1.TupleKeyWithoutCondition, len(transactionTuples))
for idx, tuple := range transactionTuples {
deletionTuples[idx] = &openfgav1.TupleKeyWithoutCondition{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
}
}
tuples = append(tuples, deletionTuples...)
}
return tuples, nil
}

View File

@ -0,0 +1,16 @@
package roletypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
Create(context.Context, *StorableRole) error
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableRole, error)
List(context.Context, valuer.UUID) ([]*StorableRole, error)
Update(context.Context, valuer.UUID, *StorableRole) error
Delete(context.Context, valuer.UUID, valuer.UUID) error
RunInTx(context.Context, func(ctx context.Context) error) error
}

View File

@ -110,6 +110,10 @@ func detectPlatform() string {
return "railway" return "railway"
case os.Getenv("ECS_CONTAINER_METADATA_URI_V4") != "": case os.Getenv("ECS_CONTAINER_METADATA_URI_V4") != "":
return "ecs" return "ecs"
case os.Getenv("NOMAD_ALLOC_ID") != "":
return "nomad"
case os.Getenv("CONTAINER_APP_HOSTNAME") != "":
return "aca"
} }
// Try to detect cloud provider through metadata endpoints // Try to detect cloud provider through metadata endpoints
@ -157,6 +161,16 @@ func detectPlatform() string {
} }
} }
// Vultr metadata
if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/v1/hostname", nil); err == nil {
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return "vultr"
}
}
}
// Hetzner metadata // Hetzner metadata
if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/hetzner/v1/metadata", nil); err == nil { if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/hetzner/v1/metadata", nil); err == nil {
if resp, err := client.Do(req); err == nil { if resp, err := client.Do(req); err == nil {