mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 16:14:42 +00:00
Merge branch 'enhancement/cmd-click-stack' into feat/cmd-click-impl-updated-history
This commit is contained in:
commit
018cab5d7e
@ -26,10 +26,6 @@ type resources
|
||||
define create: [user, role#assignee]
|
||||
define list: [user, role#assignee]
|
||||
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
|
||||
type resource
|
||||
relations
|
||||
define read: [user, anonymous, role#assignee]
|
||||
|
||||
@ -107,7 +107,7 @@ func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
|
||||
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, 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) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
@ -115,13 +115,13 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relatio
|
||||
return
|
||||
}
|
||||
|
||||
selector, parentSelectors, err := cb(req)
|
||||
selector, err := cb(req.Context(), claims)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
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 {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
28
frontend/src/api/alerts/createAlertRule.ts
Normal file
28
frontend/src/api/alerts/createAlertRule.ts
Normal 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;
|
||||
28
frontend/src/api/alerts/testAlertRule.ts
Normal file
28
frontend/src/api/alerts/testAlertRule.ts
Normal 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;
|
||||
@ -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: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
BaseBuilderQuery,
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
Filter,
|
||||
FunctionName,
|
||||
GroupByKey,
|
||||
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(
|
||||
queryData: IBuilderQuery,
|
||||
requestType: RequestType,
|
||||
@ -124,7 +143,7 @@ function createBaseSpec(
|
||||
return {
|
||||
stepInterval: queryData?.stepInterval || null,
|
||||
disabled: queryData.disabled,
|
||||
filter: queryData?.filter?.expression ? queryData.filter : undefined,
|
||||
filter: getFilter(queryData),
|
||||
groupBy:
|
||||
queryData.groupBy?.length > 0
|
||||
? queryData.groupBy.map(
|
||||
|
||||
@ -42,18 +42,31 @@ export function useNavigateToExplorer(): (
|
||||
builder: {
|
||||
...widgetQuery.builder,
|
||||
queryData: widgetQuery.builder.queryData
|
||||
.map((item) => ({
|
||||
.map((item) => {
|
||||
// filter out filters with unique ids
|
||||
const seen = new Set();
|
||||
const filterItems = [
|
||||
...(item.filters?.items || []),
|
||||
...selectedFilters,
|
||||
].filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
dataSource,
|
||||
aggregateOperator: MetricAggregateOperator.NOOP,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [...(item.filters?.items || []), ...selectedFilters],
|
||||
items: filterItems,
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
groupBy: [],
|
||||
disabled: false,
|
||||
}))
|
||||
};
|
||||
})
|
||||
.slice(0, 1),
|
||||
queryFormulas: [],
|
||||
},
|
||||
|
||||
@ -2,6 +2,8 @@ import { Form, Row } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
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 (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
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 { Channels } from 'types/api/channels/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||
@ -17,6 +22,16 @@ function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingChannels,
|
||||
isError: isErrorChannels,
|
||||
refetch: refreshChannels,
|
||||
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
const channels = data?.data || [];
|
||||
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
@ -27,15 +42,16 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
@ -76,8 +92,22 @@ function AlertCondition(): JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
|
||||
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
|
||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
|
||||
<AlertThreshold
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
)}
|
||||
{alertType === AlertTypes.ANOMALY_BASED_ALERT && (
|
||||
<AnomalyThreshold
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
)}
|
||||
{showCondensedLayoutFlag ? (
|
||||
<div className="condensed-advanced-options-container">
|
||||
<AdvancedOptions />
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
import './styles.scss';
|
||||
import '../EvaluationSettings/styles.scss';
|
||||
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
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 {
|
||||
@ -21,27 +17,30 @@ import {
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { UpdateThreshold } from './types';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
getMatchTypeTooltip,
|
||||
getQueryNames,
|
||||
RoutingPolicyBanner,
|
||||
} from './utils';
|
||||
|
||||
function AlertThreshold(): JSX.Element {
|
||||
function AlertThreshold({
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
}: AnomalyAndThresholdProps): JSX.Element {
|
||||
const {
|
||||
alertState,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
const { data, isLoading: isLoadingChannels } = useQuery<
|
||||
SuccessResponseV2<Channels[]>,
|
||||
APIError
|
||||
>(['getChannels'], {
|
||||
queryFn: () => getAllChannels(),
|
||||
});
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
const channels = data?.data || [];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@ -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 ? (
|
||||
<EvaluationSettings />
|
||||
) : (
|
||||
@ -114,8 +172,7 @@ function AlertThreshold(): JSX.Element {
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.operator}
|
||||
onChange={(value): void => {
|
||||
@ -124,7 +181,7 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
@ -138,8 +195,8 @@ function AlertThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 140 }}
|
||||
options={THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the {evaluationWindowContext}
|
||||
@ -158,6 +215,8 @@ function AlertThreshold(): JSX.Element {
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={categorySelectOptions}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
@ -169,6 +228,11 @@ function AlertThreshold(): JSX.Element {
|
||||
Add Threshold
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Select, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
@ -10,10 +11,26 @@ import {
|
||||
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
|
||||
ANOMALY_TIME_DURATION_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import { getQueryNames } from './utils';
|
||||
import { AnomalyAndThresholdProps } from './types';
|
||||
import {
|
||||
getQueryNames,
|
||||
NotificationChannelsNotFoundContent,
|
||||
RoutingPolicyBanner,
|
||||
} from './utils';
|
||||
|
||||
function AnomalyThreshold(): JSX.Element {
|
||||
const { thresholdState, setThresholdState } = useCreateAlertState();
|
||||
function AnomalyThreshold({
|
||||
channels,
|
||||
isLoadingChannels,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
}: AnomalyAndThresholdProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const {
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@ -27,7 +44,11 @@ function AnomalyThreshold(): JSX.Element {
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const updateThreshold = (id: string, field: string, value: string): void => {
|
||||
const updateThreshold = (
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | string[],
|
||||
): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.map((t) =>
|
||||
@ -53,7 +74,6 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
/>
|
||||
<Typography.Text
|
||||
@ -71,12 +91,11 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={ANOMALY_TIME_DURATION_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
{/* Sentence 2 */}
|
||||
<div className="alert-condition-sentence">
|
||||
{/* Sentence 2 */}
|
||||
<Typography.Text data-testid="threshold-text" className="sentence-text">
|
||||
is
|
||||
</Typography.Text>
|
||||
@ -90,7 +109,6 @@ function AnomalyThreshold(): JSX.Element {
|
||||
value.toString(),
|
||||
);
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={deviationOptions}
|
||||
/>
|
||||
<Typography.Text data-testid="deviations-text" className="sentence-text">
|
||||
@ -105,7 +123,6 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={ANOMALY_THRESHOLD_OPERATOR_OPTIONS}
|
||||
/>
|
||||
<Typography.Text
|
||||
@ -123,7 +140,6 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
@ -141,7 +157,6 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={ANOMALY_ALGORITHM_OPTIONS}
|
||||
/>
|
||||
<Typography.Text
|
||||
@ -159,14 +174,58 @@ function AnomalyThreshold(): JSX.Element {
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
seasonality to
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].channels}
|
||||
onChange={(value): void =>
|
||||
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
|
||||
}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
status={isErrorChannels ? 'error' : undefined}
|
||||
disabled={isLoadingChannels}
|
||||
notFoundContent={
|
||||
<NotificationChannelsNotFoundContent
|
||||
user={user}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { Button, Input, Select, Space, Tooltip, Typography } from 'antd';
|
||||
import { ChartLine, CircleX } from 'lucide-react';
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { CircleX, Trash } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { AlertThresholdOperator } from '../context/types';
|
||||
import { ThresholdItemProps } from './types';
|
||||
import { NotificationChannelsNotFoundContent } from './utils';
|
||||
|
||||
function ThresholdItem({
|
||||
threshold,
|
||||
@ -11,7 +15,12 @@ function ThresholdItem({
|
||||
showRemoveButton,
|
||||
channels,
|
||||
units,
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
isLoadingChannels,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { thresholdState, notificationSettings } = useCreateAlertState();
|
||||
const [showRecoveryThreshold, setShowRecoveryThreshold] = useState(false);
|
||||
|
||||
const yAxisUnitSelect = useMemo(() => {
|
||||
@ -45,6 +54,31 @@ function ThresholdItem({
|
||||
return component;
|
||||
}, [units, threshold.unit, updateThreshold, threshold.id]);
|
||||
|
||||
const getOperatorSymbol = (): string => {
|
||||
switch (thresholdState.operator) {
|
||||
case AlertThresholdOperator.IS_ABOVE:
|
||||
return '>';
|
||||
case AlertThresholdOperator.IS_BELOW:
|
||||
return '<';
|
||||
case AlertThresholdOperator.IS_EQUAL_TO:
|
||||
return '=';
|
||||
case AlertThresholdOperator.IS_NOT_EQUAL_TO:
|
||||
return '!=';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// const addRecoveryThreshold = (): void => {
|
||||
// setShowRecoveryThreshold(true);
|
||||
// updateThreshold(threshold.id, 'recoveryThresholdValue', 0);
|
||||
// };
|
||||
|
||||
const removeRecoveryThreshold = (): void => {
|
||||
setShowRecoveryThreshold(false);
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={threshold.id} className="threshold-item">
|
||||
<div className="threshold-row">
|
||||
@ -54,80 +88,111 @@ function ThresholdItem({
|
||||
style={{ backgroundColor: threshold.color }}
|
||||
/>
|
||||
</div>
|
||||
<Space className="threshold-controls">
|
||||
<div className="threshold-inputs">
|
||||
<Input.Group>
|
||||
<div className="threshold-controls">
|
||||
<Input
|
||||
placeholder="Enter threshold name"
|
||||
value={threshold.label}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'thresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
</Input.Group>
|
||||
</div>
|
||||
<Typography.Text className="sentence-text">to</Typography.Text>
|
||||
{!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: 260 }}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
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>
|
||||
{!showRecoveryThreshold && (
|
||||
{/* TODO: Add recovery threshold back once the functionality is implemented */}
|
||||
{/* {!showRecoveryThreshold && (
|
||||
<Tooltip title="Add recovery threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ChartLine size={16} />}
|
||||
className="icon-btn"
|
||||
onClick={(): void => setShowRecoveryThreshold(true)}
|
||||
onClick={addRecoveryThreshold}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
)} */}
|
||||
{showRemoveButton && (
|
||||
<Tooltip title="Remove threshold">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button.Group>
|
||||
</Space>
|
||||
</div>
|
||||
{showRecoveryThreshold && (
|
||||
<Input.Group className="recovery-threshold-input-group">
|
||||
<Input
|
||||
placeholder="Recovery threshold"
|
||||
disabled
|
||||
style={{ width: 260 }}
|
||||
className="recovery-threshold-label"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Enter recovery threshold value"
|
||||
value={threshold.recoveryThresholdValue}
|
||||
onChange={(e): void =>
|
||||
updateThreshold(threshold.id, 'recoveryThresholdValue', e.target.value)
|
||||
}
|
||||
style={{ width: 210 }}
|
||||
/>
|
||||
</Input.Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertCondition from '../AlertCondition';
|
||||
@ -105,7 +106,7 @@ const renderAlertCondition = (
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
@ -126,9 +127,10 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@ -136,7 +138,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
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', () => {
|
||||
@ -151,7 +154,8 @@ describe('AlertCondition', () => {
|
||||
).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();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
@ -165,7 +169,8 @@ describe('AlertCondition', () => {
|
||||
).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();
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
it('switches between threshold and anomaly tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
@ -201,7 +207,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@ -222,21 +229,21 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_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(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
renderAlertCondition('ANOMALY_BASED_ALERT');
|
||||
|
||||
// Both tabs should be visible
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
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', () => {
|
||||
|
||||
@ -3,11 +3,23 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertThreshold from '../AlertThreshold';
|
||||
|
||||
const mockChannels: Channels[] = [];
|
||||
const mockRefreshChannels = jest.fn();
|
||||
const mockIsLoadingChannels = false;
|
||||
const mockIsErrorChannels = false;
|
||||
const mockProps = {
|
||||
channels: mockChannels,
|
||||
isLoadingChannels: mockIsLoadingChannels,
|
||||
isErrorChannels: mockIsErrorChannels,
|
||||
refreshChannels: mockRefreshChannels,
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@ -99,7 +111,7 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
|
||||
const TEST_STRINGS = {
|
||||
ADD_THRESHOLD: 'Add Threshold',
|
||||
AT_LEAST_ONCE: 'AT LEAST ONCE',
|
||||
IS_ABOVE: 'IS ABOVE',
|
||||
IS_ABOVE: 'ABOVE',
|
||||
} as const;
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
@ -116,8 +128,8 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider>
|
||||
<AlertThreshold />
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertThreshold {...mockProps} />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
@ -125,7 +137,10 @@ const renderAlertThreshold = (): ReturnType<typeof render> => {
|
||||
};
|
||||
|
||||
const verifySelectRenders = (title: string): void => {
|
||||
const select = screen.getByTitle(title);
|
||||
let select = screen.queryByTitle(title);
|
||||
if (!select) {
|
||||
select = screen.getByText(title);
|
||||
}
|
||||
expect(select).toBeInTheDocument();
|
||||
};
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
|
||||
import * as context from '../../context';
|
||||
import AnomalyThreshold from '../AnomalyThreshold';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@ -23,12 +24,12 @@ jest.mock('uplot', () => {
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue({
|
||||
alertState: INITIAL_ALERT_STATE,
|
||||
setAlertState: mockSetAlertState,
|
||||
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
} as any);
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@ -54,7 +55,14 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
|
||||
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
render(<AnomalyThreshold />);
|
||||
render(
|
||||
<AnomalyThreshold
|
||||
channels={[]}
|
||||
isLoadingChannels={false}
|
||||
isErrorChannels={false}
|
||||
refreshChannels={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('AnomalyThreshold', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -2,15 +2,37 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import * as context from '../../context';
|
||||
import ThresholdItem from '../ThresholdItem';
|
||||
import { ThresholdItemProps } from '../types';
|
||||
|
||||
// Mock the enableRecoveryThreshold utility
|
||||
jest.mock('../../utils', () => ({
|
||||
enableRecoveryThreshold: jest.fn(() => true),
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock: any = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
uplotMock.paths = paths;
|
||||
return uplotMock;
|
||||
});
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
|
||||
const TEST_CONSTANTS = {
|
||||
THRESHOLD_ID: 'test-threshold-1',
|
||||
@ -21,6 +43,7 @@ const TEST_CONSTANTS = {
|
||||
CHANNEL_2: 'channel-2',
|
||||
CHANNEL_3: 'channel-3',
|
||||
EMAIL_CHANNEL_NAME: 'Email Channel',
|
||||
EMAIL_CHANNEL_TRUNCATED: 'Email Chan...',
|
||||
ENTER_THRESHOLD_NAME: 'Enter threshold name',
|
||||
ENTER_THRESHOLD_VALUE: 'Enter threshold value',
|
||||
ENTER_RECOVERY_THRESHOLD_VALUE: 'Enter recovery threshold value',
|
||||
@ -59,6 +82,8 @@ const defaultProps: ThresholdItemProps = {
|
||||
channels: mockChannels,
|
||||
isLoadingChannels: false,
|
||||
units: mockUnits,
|
||||
isErrorChannels: false,
|
||||
refreshChannels: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThresholdItem = (
|
||||
@ -77,10 +102,11 @@ const verifySelectorWidth = (
|
||||
expect(selector.closest('.ant-select')).toHaveStyle(`width: ${expectedWidth}`);
|
||||
};
|
||||
|
||||
const showRecoveryThreshold = (): void => {
|
||||
const recoveryButton = screen.getByRole('button', { name: '' });
|
||||
fireEvent.click(recoveryButton);
|
||||
};
|
||||
// TODO: Unskip this when recovery threshold is implemented
|
||||
// const showRecoveryThreshold = (): void => {
|
||||
// const recoveryButton = screen.getByRole('button', { name: '' });
|
||||
// fireEvent.click(recoveryButton);
|
||||
// };
|
||||
|
||||
const verifyComponentRendersWithLoading = (): void => {
|
||||
expect(
|
||||
@ -122,7 +148,7 @@ describe('ThresholdItem', () => {
|
||||
const valueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
expect(valueInput).toHaveValue('100');
|
||||
expect(valueInput).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('renders unit selector with correct value', () => {
|
||||
@ -132,15 +158,6 @@ describe('ThresholdItem', () => {
|
||||
expect(screen.getByText('Bytes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channels selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check for the channels selector by looking for the displayed text
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates threshold label when label input changes', () => {
|
||||
const updateThreshold = jest.fn();
|
||||
renderThresholdItem({ updateThreshold });
|
||||
@ -212,38 +229,31 @@ describe('ThresholdItem', () => {
|
||||
|
||||
// The remove button is the second button (with circle-x icon)
|
||||
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', () => {
|
||||
renderThresholdItem({ showRemoveButton: false });
|
||||
|
||||
// Only the recovery button should be present
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1); // Only recovery button
|
||||
// No buttons should be present
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
expect(buttons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls removeThreshold when remove button is clicked', () => {
|
||||
const removeThreshold = jest.fn();
|
||||
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 removeButton = buttons[1]; // Second button is the remove button
|
||||
const removeButton = buttons[0];
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
expect(removeThreshold).toHaveBeenCalledWith(TEST_CONSTANTS.THRESHOLD_ID);
|
||||
});
|
||||
|
||||
it('shows recovery threshold button when recovery threshold is enabled', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// The recovery button is the first button (with chart-line icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(1); // Recovery button
|
||||
});
|
||||
|
||||
it('shows recovery threshold inputs when recovery button is clicked', () => {
|
||||
// TODO: Unskip this when recovery threshold is implemented
|
||||
it.skip('shows recovery threshold inputs when recovery button is clicked', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// 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
|
||||
fireEvent.click(recoveryButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('Recovery threshold')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter recovery threshold value'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE),
|
||||
).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();
|
||||
renderThresholdItem({ updateThreshold });
|
||||
|
||||
@ -290,22 +303,6 @@ describe('ThresholdItem', () => {
|
||||
verifyUnitSelectorDisabled();
|
||||
});
|
||||
|
||||
it('renders channels as multiple select options', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
// Check that channels are rendered as multiple select
|
||||
expect(
|
||||
screen.getByText(TEST_CONSTANTS.EMAIL_CHANNEL_NAME),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should be able to select multiple channels
|
||||
const channelSelectors = screen.getAllByRole('combobox');
|
||||
const channelSelector = channelSelectors[1]; // Second combobox is the channels selector
|
||||
fireEvent.change(channelSelector, {
|
||||
target: { value: [TEST_CONSTANTS.CHANNEL_1, TEST_CONSTANTS.CHANNEL_2] },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty threshold values correctly', () => {
|
||||
const emptyThreshold = {
|
||||
...mockThreshold,
|
||||
@ -318,7 +315,7 @@ describe('ThresholdItem', () => {
|
||||
renderThresholdItem({ threshold: emptyThreshold });
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter threshold name')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue('0');
|
||||
expect(screen.getByPlaceholderText('Enter threshold value')).toHaveValue(0);
|
||||
});
|
||||
|
||||
it('renders with correct input widths', () => {
|
||||
@ -331,13 +328,13 @@ describe('ThresholdItem', () => {
|
||||
TEST_CONSTANTS.ENTER_THRESHOLD_VALUE,
|
||||
);
|
||||
|
||||
expect(labelInput).toHaveStyle('width: 260px');
|
||||
expect(valueInput).toHaveStyle('width: 210px');
|
||||
expect(labelInput).toHaveStyle('width: 200px');
|
||||
expect(valueInput).toHaveStyle('width: 100px');
|
||||
});
|
||||
|
||||
it('renders channels selector with correct width', () => {
|
||||
renderThresholdItem();
|
||||
verifySelectorWidth(1, '260px');
|
||||
verifySelectorWidth(1, '350px');
|
||||
});
|
||||
|
||||
it('renders unit selector with correct width', () => {
|
||||
@ -350,37 +347,14 @@ describe('ThresholdItem', () => {
|
||||
verifyComponentRendersWithLoading();
|
||||
});
|
||||
|
||||
it('renders recovery threshold with correct initial value', () => {
|
||||
it.skip('renders recovery threshold with correct initial value', () => {
|
||||
renderThresholdItem();
|
||||
showRecoveryThreshold();
|
||||
// showRecoveryThreshold();
|
||||
|
||||
const recoveryValueInput = screen.getByPlaceholderText(
|
||||
TEST_CONSTANTS.ENTER_RECOVERY_THRESHOLD_VALUE,
|
||||
);
|
||||
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();
|
||||
expect(recoveryValueInput).toHaveValue(80);
|
||||
});
|
||||
|
||||
it('handles threshold without channels', () => {
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
padding-right: 72px;
|
||||
background-color: var(--bg-ink-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
|
||||
.alert-condition-sentences {
|
||||
display: flex;
|
||||
@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px !important;
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--bg-ink-300);
|
||||
@ -148,6 +148,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-input {
|
||||
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,
|
||||
@ -293,7 +317,8 @@
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
@ -301,6 +326,7 @@
|
||||
.evaluate-alert-conditions-button-left {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.evaluate-alert-conditions-button-right {
|
||||
@ -308,6 +334,7 @@
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.evaluate-alert-conditions-button-right-text {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
import { Threshold } from '../context/types';
|
||||
import {
|
||||
NotificationSettingsAction,
|
||||
NotificationSettingsState,
|
||||
Threshold,
|
||||
} from '../context/types';
|
||||
|
||||
export type UpdateThreshold = {
|
||||
(thresholdId: string, field: 'channels', value: string[]): void;
|
||||
(
|
||||
thresholdId: string,
|
||||
field: Exclude<keyof Threshold, 'channels'>,
|
||||
value: string,
|
||||
value: string | number | null,
|
||||
): void;
|
||||
};
|
||||
|
||||
@ -20,4 +24,20 @@ export interface ThresholdItemProps {
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
channels: Channels[];
|
||||
isLoadingChannels: boolean;
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
}
|
||||
|
||||
export interface RoutingPolicyBannerProps {
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: (
|
||||
notificationSettings: NotificationSettingsAction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import { Button, Flex, Switch, Typography } from 'antd';
|
||||
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
|
||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
const involvedQueriesInTraceOperator = getInvolvedQueriesInTraceOperator(
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -38,7 +38,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-header__content">
|
||||
<input
|
||||
type="text"
|
||||
@ -49,15 +48,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={alertState.description}
|
||||
onChange={(e): void =>
|
||||
setAlertState({ type: 'SET_ALERT_DESCRIPTION', payload: e.target.value })
|
||||
}
|
||||
className="alert-header__input description"
|
||||
placeholder="Click to add description..."
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
onLabelsChange={(labels: Labels): void =>
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
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 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', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@ -27,7 +39,7 @@ jest.mock('react-router-dom', () => ({
|
||||
|
||||
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
@ -44,14 +56,6 @@ describe('CreateAlertHeader', () => {
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description input with placeholder', () => {
|
||||
renderCreateAlertHeader();
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
'Click to add description...',
|
||||
);
|
||||
expect(descriptionInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders LabelsInput component', () => {
|
||||
renderCreateAlertHeader();
|
||||
expect(screen.getByText('+ Add labels')).toBeInTheDocument();
|
||||
@ -65,13 +69,4 @@ describe('CreateAlertHeader', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,21 +3,6 @@
|
||||
font-family: inherit;
|
||||
color: var(--text-vanilla-100);
|
||||
|
||||
/* Top bar with diagonal stripes */
|
||||
&__tab-bar {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
#0f0f0f,
|
||||
#0f0f0f 10px,
|
||||
#101010 10px,
|
||||
#101010 20px
|
||||
);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Tab block visuals */
|
||||
&__tab {
|
||||
display: flex;
|
||||
@ -44,6 +29,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__input.title {
|
||||
@ -51,6 +38,8 @@
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-100);
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
&__input:focus,
|
||||
@ -64,6 +53,15 @@
|
||||
background-color: transparent;
|
||||
color: var(--text-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
color: var(--text-vanilla-100);
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-input {
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
$top-nav-background-1: #0f0f0f;
|
||||
$top-nav-background-2: #101010;
|
||||
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-ink-500);
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.top-nav-container {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
$top-nav-background-1,
|
||||
$top-nav-background-1 10px,
|
||||
$top-nav-background-2 10px,
|
||||
$top-nav-background-2 20px
|
||||
);
|
||||
margin-bottom: 0;
|
||||
.lightMode {
|
||||
.create-alert-v2-container {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-page-spinner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 10000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@ -2,27 +2,32 @@ import './CreateAlertV2.styles.scss';
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
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 { CreateAlertProvider } from './context';
|
||||
import { buildInitialAlertDef } from './context/utils';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import Footer from './Footer';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
import { CreateAlertV2Props } from './types';
|
||||
import { showCondensedLayout, Spinner } from './utils';
|
||||
|
||||
function CreateAlertV2({
|
||||
initialQuery = initialQueriesMap.metrics,
|
||||
}: {
|
||||
initialQuery?: Query;
|
||||
}): JSX.Element {
|
||||
useShareBuilderUrl({ defaultValue: initialQuery });
|
||||
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
||||
const queryToRedirect = buildInitialAlertDef(alertType);
|
||||
const currentQueryToRedirect = mapQueryDataFromApi(
|
||||
queryToRedirect.condition.compositeQuery,
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
|
||||
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
return (
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={alertType}>
|
||||
<Spinner />
|
||||
<div className="create-alert-v2-container">
|
||||
<CreateAlertHeader />
|
||||
<QuerySection />
|
||||
@ -30,6 +35,7 @@ function CreateAlertV2({
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<Footer />
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -114,6 +114,14 @@
|
||||
height: 32px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import { Collapse, Input, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptionItem from './AdvancedOptionItem';
|
||||
@ -8,10 +7,6 @@ import EvaluationCadence from './EvaluationCadence';
|
||||
function AdvancedOptions(): JSX.Element {
|
||||
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
|
||||
|
||||
const timeOptions = Y_AXIS_CATEGORIES.find(
|
||||
(category) => category.name === 'Time',
|
||||
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
|
||||
|
||||
return (
|
||||
<div className="advanced-options-container">
|
||||
<Collapse bordered={false}>
|
||||
@ -38,24 +33,15 @@ function AdvancedOptions(): JSX.Element {
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={timeOptions}
|
||||
placeholder="Select time unit"
|
||||
onChange={(value): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: {
|
||||
toleranceLimit:
|
||||
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
|
||||
timeUnit: value as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
onToggle={(): void =>
|
||||
setAdvancedOptions({
|
||||
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
|
||||
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
@ -80,8 +66,15 @@ function AdvancedOptions(): JSX.Element {
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</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"
|
||||
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."
|
||||
@ -119,7 +112,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import './styles.scss';
|
||||
import '../AdvancedOptionItem/styles.scss';
|
||||
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../../context';
|
||||
@ -36,10 +36,10 @@ function EvaluationCadence(): JSX.Element {
|
||||
);
|
||||
}, [advancedOptions.evaluationCadence.mode]);
|
||||
|
||||
const showCustomSchedule = (): void => {
|
||||
setIsEvaluationCadenceDetailsVisible(true);
|
||||
setIsCustomScheduleButtonVisible(false);
|
||||
};
|
||||
// const showCustomSchedule = (): void => {
|
||||
// setIsEvaluationCadenceDetailsVisible(true);
|
||||
// setIsCustomScheduleButtonVisible(false);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="evaluation-cadence-container">
|
||||
@ -98,13 +98,14 @@ function EvaluationCadence(): JSX.Element {
|
||||
}
|
||||
/>
|
||||
</Input.Group>
|
||||
<Button
|
||||
{/* TODO: Add custom schedule back once the functionality is implemented */}
|
||||
{/* <Button
|
||||
className="advanced-option-item-button"
|
||||
onClick={showCustomSchedule}
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Typography.Text>Add custom schedule</Typography.Text>
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -164,6 +164,14 @@
|
||||
background-color: var(--bg-ink-300);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -529,6 +537,15 @@
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import { useMemo } from 'react';
|
||||
|
||||
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
TIMEZONE_DATA,
|
||||
} from '../constants';
|
||||
import TimeInput from '../TimeInput';
|
||||
@ -116,7 +116,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentHour) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING AT MINUTE</Typography.Text>
|
||||
@ -134,7 +136,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentDay) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group time-select-group">
|
||||
<Typography.Text>STARTING AT</Typography.Text>
|
||||
@ -159,7 +163,9 @@ function EvaluationWindowDetails({
|
||||
if (isCurrentMonth) {
|
||||
return (
|
||||
<div className="evaluation-window-details">
|
||||
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
|
||||
<Typography.Text>
|
||||
{getCumulativeWindowDescription(evaluationWindow.timeframe)}
|
||||
</Typography.Text>
|
||||
<Typography.Text>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
<Typography.Text>STARTING ON DAY</Typography.Text>
|
||||
@ -192,7 +198,9 @@ function EvaluationWindowDetails({
|
||||
|
||||
return (
|
||||
<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>{displayText}</Typography.Text>
|
||||
<div className="select-group">
|
||||
|
||||
@ -3,10 +3,10 @@ import classNames from 'classnames';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
CUMULATIVE_WINDOW_DESCRIPTION,
|
||||
EVALUATION_WINDOW_TIMEFRAME,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
ROLLING_WINDOW_DESCRIPTION,
|
||||
getCumulativeWindowDescription,
|
||||
getRollingWindowDescription,
|
||||
} from '../constants';
|
||||
import {
|
||||
CumulativeWindowTimeframes,
|
||||
@ -96,7 +96,9 @@ function EvaluationWindowPopover({
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@ -108,7 +110,9 @@ function EvaluationWindowPopover({
|
||||
) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -28,9 +28,10 @@ describe('AdvancedOptions', () => {
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: Uncomment this when account for data delay is implemented
|
||||
// expect(
|
||||
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
// ).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be able to expand the advanced options', () => {
|
||||
@ -42,9 +43,10 @@ describe('AdvancedOptions', () => {
|
||||
expect(
|
||||
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: Uncomment this when account for data delay is implemented
|
||||
// expect(
|
||||
// screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
fireEvent.click(collapse);
|
||||
@ -52,7 +54,8 @@ describe('AdvancedOptions', () => {
|
||||
expect(screen.getByText('How often to check')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
|
||||
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
|
||||
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', () => {
|
||||
@ -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 />);
|
||||
|
||||
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
|
||||
@ -64,10 +64,12 @@ describe('EvaluationCadence', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
|
||||
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 />);
|
||||
|
||||
expect(
|
||||
@ -84,12 +86,14 @@ describe('EvaluationCadence', () => {
|
||||
).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 />);
|
||||
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(
|
||||
createMockAlertContextState({
|
||||
advancedOptions: {
|
||||
@ -118,7 +122,8 @@ describe('EvaluationCadence', () => {
|
||||
).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 />);
|
||||
|
||||
expect(
|
||||
|
||||
@ -24,9 +24,12 @@ describe('EvaluationWindowDetails', () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.textContent?.includes(
|
||||
'Monitors data over a fixed time period that moves forward continuously',
|
||||
) ?? false,
|
||||
)[0],
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
|
||||
|
||||
@ -125,9 +125,12 @@ describe('EvaluationWindowPopover', () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
|
||||
),
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.textContent?.includes(
|
||||
'Monitors data over a fixed time period that moves forward continuously',
|
||||
) ?? false,
|
||||
)[0],
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
|
||||
|
||||
@ -26,6 +26,11 @@ export const createMockAlertContextState = (
|
||||
setEvaluationWindow: jest.fn(),
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
setNotificationSettings: jest.fn(),
|
||||
discardAlertRule: jest.fn(),
|
||||
testAlertRule: jest.fn(),
|
||||
isCreatingAlertRule: false,
|
||||
isTestingAlertRule: false,
|
||||
createAlertRule: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@ -62,8 +62,87 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
|
||||
value: timezone.value,
|
||||
}));
|
||||
|
||||
export const CUMULATIVE_WINDOW_DESCRIPTION =
|
||||
'A Cumulative Window has a fixed starting point and expands over time.';
|
||||
export const getCumulativeWindowDescription = (timeframe?: string): string => {
|
||||
let example = '';
|
||||
switch (timeframe) {
|
||||
case 'currentHour':
|
||||
example =
|
||||
'An hourly cumulative window for error count alerts when errors exceed 100. Starting at the top of the hour, it tracks: 20 errors by :15, 55 by :30, 105 by :45 (alert fires).';
|
||||
break;
|
||||
case 'currentDay':
|
||||
example =
|
||||
'A daily cumulative window for sales alerts when total revenue exceeds $10,000. Starting at midnight, it tracks: $2,000 by 9 AM, $5,500 by noon, $11,000 by 3 PM (alert fires).';
|
||||
break;
|
||||
case 'currentMonth':
|
||||
example =
|
||||
'A monthly cumulative window for expense alerts when spending exceeds $50,000. Starting on the 1st, it tracks: $15,000 by the 7th, $32,000 by the 15th, $51,000 by the 22nd (alert fires).';
|
||||
break;
|
||||
default:
|
||||
example = '';
|
||||
}
|
||||
return `Monitors data accumulated since a fixed starting point. The window grows over time, keeping all historical data from the start.\n\nExample: ${example}`;
|
||||
};
|
||||
|
||||
export const ROLLING_WINDOW_DESCRIPTION =
|
||||
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export const getRollingWindowDescription = (duration?: string): string => {
|
||||
let timeWindow = '5-minute';
|
||||
let examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
|
||||
|
||||
if (duration) {
|
||||
const match = duration.match(/^(\d+)([mhs])/);
|
||||
if (match) {
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
if (unit === 'm' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-minute`;
|
||||
const endMinutes1 = 1 + value;
|
||||
const endMinutes2 = 2 + value;
|
||||
examples = `14:01:00-14:${String(endMinutes1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
|
||||
} else if (unit === 'h' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-hour`;
|
||||
const endHour1 = 14 + value;
|
||||
const endHour2 = 14 + value;
|
||||
examples = `14:00:00-${String(endHour1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00:00, 14:01:00-${String(endHour2).padStart(2, '0')}:01:00`;
|
||||
} else if (unit === 's' && !Number.isNaN(value)) {
|
||||
timeWindow = `${value}-second`;
|
||||
examples = `14:01:00-14:01:${String(value).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}, 14:01:01-14:01:${String(1 + value).padStart(2, '0')}`;
|
||||
}
|
||||
} else if (duration === 'custom') {
|
||||
timeWindow = '5-minute';
|
||||
examples = '14:01:00-14:06:00, 14:02:00-14:07:00';
|
||||
} else if (duration.includes('h')) {
|
||||
const hours = parseInt(duration, 10);
|
||||
if (!Number.isNaN(hours)) {
|
||||
timeWindow = `${hours}-hour`;
|
||||
const endHour = 14 + hours;
|
||||
examples = `14:00:00-${String(endHour).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00:00, 14:01:00-${String(endHour).padStart(2, '0')}:01:00`;
|
||||
}
|
||||
} else if (duration.includes('m')) {
|
||||
const minutes = parseInt(duration, 10);
|
||||
if (!Number.isNaN(minutes)) {
|
||||
timeWindow = `${minutes}-minute`;
|
||||
const endMinutes1 = 1 + minutes;
|
||||
const endMinutes2 = 2 + minutes;
|
||||
examples = `14:01:00-14:${String(endMinutes1).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}:00, 14:02:00-14:${String(endMinutes2).padStart(2, '0')}:00`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `Monitors data over a fixed time period that moves forward continuously.\n\nExample: A ${timeWindow} rolling window for error rate alerts with 1 minute evaluation cadence. Unlike fixed windows, this checks continuously: ${examples}, etc.`;
|
||||
};
|
||||
|
||||
@ -209,6 +209,16 @@
|
||||
|
||||
.ant-select {
|
||||
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);
|
||||
color: var(--bg-vanilla-100);
|
||||
height: 32px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@ -379,6 +397,14 @@
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
169
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal file
169
frontend/src/container/CreateAlertV2/Footer/Footer.tsx
Normal 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;
|
||||
3
frontend/src/container/CreateAlertV2/Footer/index.ts
Normal file
3
frontend/src/container/CreateAlertV2/Footer/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Footer from './Footer';
|
||||
|
||||
export default Footer;
|
||||
51
frontend/src/container/CreateAlertV2/Footer/styles.scss
Normal file
51
frontend/src/container/CreateAlertV2/Footer/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
frontend/src/container/CreateAlertV2/Footer/types.ts
Normal file
20
frontend/src/container/CreateAlertV2/Footer/types.ts
Normal 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;
|
||||
}
|
||||
347
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal file
347
frontend/src/container/CreateAlertV2/Footer/utils.tsx
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@ -10,46 +10,46 @@ function NotificationMessage(): JSX.Element {
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const templateVariables = [
|
||||
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
{
|
||||
variable: '{{value}}',
|
||||
description: 'Current value that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{threshold}}',
|
||||
description: 'Threshold value from alert condition',
|
||||
},
|
||||
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
{
|
||||
variable: '{{severity}}',
|
||||
description: 'Alert severity level (Critical, Warning, Info)',
|
||||
},
|
||||
{
|
||||
variable: '{{queryname}}',
|
||||
description: 'Name of the query that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{labels}}',
|
||||
description: 'All labels associated with the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{timestamp}}',
|
||||
description: 'Timestamp when alert was triggered',
|
||||
},
|
||||
];
|
||||
// const templateVariables = [
|
||||
// { variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
// {
|
||||
// variable: '{{value}}',
|
||||
// description: 'Current value that triggered the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{threshold}}',
|
||||
// description: 'Threshold value from alert condition',
|
||||
// },
|
||||
// { variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
// {
|
||||
// variable: '{{severity}}',
|
||||
// description: 'Alert severity level (Critical, Warning, Info)',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{queryname}}',
|
||||
// description: 'Name of the query that triggered the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{labels}}',
|
||||
// description: 'All labels associated with the alert',
|
||||
// },
|
||||
// {
|
||||
// variable: '{{timestamp}}',
|
||||
// description: 'Timestamp when alert was triggered',
|
||||
// },
|
||||
// ];
|
||||
|
||||
const templateVariableContent = (
|
||||
<div className="template-variable-content">
|
||||
<Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
{templateVariables.map((item) => (
|
||||
<div className="template-variable-content-item" key={item.variable}>
|
||||
<code>{item.variable}</code>
|
||||
<Typography.Text>{item.description}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
// const templateVariableContent = (
|
||||
// <div className="template-variable-content">
|
||||
// <Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
// {templateVariables.map((item) => (
|
||||
// <div className="template-variable-content-item" key={item.variable}>
|
||||
// <code>{item.variable}</code>
|
||||
// <Typography.Text>{item.description}</Typography.Text>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className="notification-message-container">
|
||||
@ -67,12 +67,13 @@ function NotificationMessage(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</Popover> */}
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
|
||||
@ -84,12 +84,28 @@
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
width: 120px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-multiple {
|
||||
.ant-select-selector {
|
||||
width: 200px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -202,6 +218,15 @@
|
||||
flex-shrink: 0;
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -327,6 +352,15 @@
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
@ -16,7 +17,7 @@ export interface ChartPreviewProps {
|
||||
|
||||
function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
const { currentQuery, panelType, stagedQuery } = useQueryBuilder();
|
||||
const { thresholdState, alertState } = useCreateAlertState();
|
||||
const { thresholdState, alertState, setAlertState } = useCreateAlertState();
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
@ -25,14 +26,24 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const yAxisUnit = alertState.yAxisUnit || '';
|
||||
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={
|
||||
const headline = (
|
||||
<div className="chart-preview-headline">
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
}
|
||||
<YAxisUnitSelector
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQBChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={headline}
|
||||
name=""
|
||||
query={stagedQuery}
|
||||
selectedInterval={globalSelectedInterval}
|
||||
@ -47,12 +58,7 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
|
||||
const renderPromAndChQueryChartPreview = (): JSX.Element => (
|
||||
<ChartPreviewComponent
|
||||
headline={
|
||||
<PlotTag
|
||||
queryType={currentQuery.queryType}
|
||||
panelType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||
/>
|
||||
}
|
||||
headline={headline}
|
||||
name="Chart Preview"
|
||||
query={stagedQuery}
|
||||
alertDef={alertDef}
|
||||
|
||||
@ -2,12 +2,13 @@ import './styles.scss';
|
||||
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QuerySectionComponent from 'container/FormAlertRules/QuerySection';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { BarChart2, DraftingCompass, FileText, ScrollText } from 'lucide-react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import Stepper from '../Stepper';
|
||||
@ -15,17 +16,20 @@ import ChartPreview from './ChartPreview';
|
||||
import { buildAlertDefForChartPreview } from './utils';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { currentQuery, handleRunQuery } = useQueryBuilder();
|
||||
const {
|
||||
alertState,
|
||||
setAlertState,
|
||||
alertType,
|
||||
setAlertType,
|
||||
thresholdState,
|
||||
} = useCreateAlertState();
|
||||
currentQuery,
|
||||
handleRunQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
const { alertType, setAlertType, thresholdState } = useCreateAlertState();
|
||||
|
||||
const alertDef = buildAlertDefForChartPreview({ alertType, thresholdState });
|
||||
|
||||
const onQueryCategoryChange = (val: EQueryType): void => {
|
||||
const query: Query = { ...currentQuery, queryType: val };
|
||||
redirectWithQueryBuilderData(query);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Metrics',
|
||||
@ -51,17 +55,8 @@ function QuerySection(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper
|
||||
stepNumber={1}
|
||||
label="Define the query you want to set an alert on"
|
||||
/>
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
<ChartPreview alertDef={alertDef} />
|
||||
<YAxisUnitSelector
|
||||
value={alertState.yAxisUnit}
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
/>
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
{tabs.map((tab) => (
|
||||
@ -82,7 +77,7 @@ function QuerySection(): JSX.Element {
|
||||
</div>
|
||||
<QuerySectionComponent
|
||||
queryCategory={currentQuery.queryType}
|
||||
setQueryCategory={(): void => {}}
|
||||
setQueryCategory={onQueryCategoryChange}
|
||||
alertType={alertType}
|
||||
runQuery={handleRunQuery}
|
||||
alertDef={alertDef}
|
||||
|
||||
@ -134,7 +134,7 @@ const renderChartPreview = (): ReturnType<typeof render> =>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<ChartPreview alertDef={mockAlertDef} />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
@ -104,7 +105,7 @@ const renderQuerySection = (): ReturnType<typeof render> =>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
</MemoryRouter>
|
||||
@ -135,7 +136,7 @@ describe('QuerySection', () => {
|
||||
expect(screen.getByTestId('stepper')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('step-number')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('step-label')).toHaveTextContent(
|
||||
'Define the query you want to set an alert on',
|
||||
'Define the query',
|
||||
);
|
||||
|
||||
// Check if ChartPreview is rendered
|
||||
@ -186,6 +187,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@ -200,6 +202,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
|
||||
@ -77,6 +77,14 @@
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -88,6 +96,18 @@
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
.ant-card-body {
|
||||
background-color: var(--bg-ink-500);
|
||||
|
||||
.chart-preview-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.y-axis-unit-selector-component {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -99,3 +119,78 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,3 +42,22 @@
|
||||
background-position: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.step-number {
|
||||
background-color: var(--bg-robin-400);
|
||||
color: var(--text-slate-400);
|
||||
}
|
||||
|
||||
.step-label {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.dotted-line {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--bg-ink-200) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
name: '',
|
||||
description: '',
|
||||
labels: {},
|
||||
yAxisUnit: undefined,
|
||||
};
|
||||
@ -30,7 +29,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'CRITICAL',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_SAKURA_500,
|
||||
@ -40,7 +39,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'WARNING',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_AMBER_500,
|
||||
@ -50,7 +49,7 @@ export const INITIAL_INFO_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'INFO',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: Color.BG_ROBIN_500,
|
||||
@ -60,7 +59,7 @@ export const INITIAL_RANDOM_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: '',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: getRandomColor(),
|
||||
@ -80,9 +79,11 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: 15,
|
||||
timeUnit: UniversalYAxisUnit.MINUTES,
|
||||
enabled: false,
|
||||
},
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: 0,
|
||||
enabled: false,
|
||||
},
|
||||
delayEvaluation: {
|
||||
delay: 5,
|
||||
@ -120,10 +121,10 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
|
||||
};
|
||||
|
||||
export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'IS EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'IS NOT EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_EQUAL_TO, label: 'EQUAL TO' },
|
||||
{ value: AlertThresholdOperator.IS_NOT_EQUAL_TO, label: 'NOT EQUAL TO' },
|
||||
];
|
||||
|
||||
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
|
||||
@ -169,7 +170,6 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
|
||||
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
|
||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||
@ -189,4 +189,5 @@ export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
routingPolicies: false,
|
||||
};
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
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 { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import {
|
||||
@ -72,6 +75,10 @@ export function CreateAlertProvider(
|
||||
currentQueryToRedirect,
|
||||
{
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@ -107,6 +114,35 @@ export function CreateAlertProvider(
|
||||
});
|
||||
}, [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(
|
||||
() => ({
|
||||
alertState,
|
||||
@ -121,6 +157,11 @@ export function CreateAlertProvider(
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
@ -130,6 +171,11 @@ export function CreateAlertProvider(
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
discardAlertRule,
|
||||
createAlertRule,
|
||||
isCreatingAlertRule,
|
||||
testAlertRule,
|
||||
isTestingAlertRule,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
|
||||
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch } from 'react';
|
||||
import { UseMutateFunction } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export interface ICreateAlertContextProps {
|
||||
@ -16,10 +21,26 @@ export interface ICreateAlertContextProps {
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: Dispatch<NotificationSettingsAction>;
|
||||
isCreatingAlertRule: boolean;
|
||||
createAlertRule: UseMutateFunction<
|
||||
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2,
|
||||
unknown
|
||||
>;
|
||||
isTestingAlertRule: boolean;
|
||||
testAlertRule: UseMutateFunction<
|
||||
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
|
||||
Error,
|
||||
PostableAlertRuleV2,
|
||||
unknown
|
||||
>;
|
||||
discardAlertRule: () => void;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialAlertType: AlertTypes;
|
||||
}
|
||||
|
||||
export enum AlertCreationStep {
|
||||
@ -31,14 +52,12 @@ export enum AlertCreationStep {
|
||||
|
||||
export interface AlertState {
|
||||
name: string;
|
||||
description: string;
|
||||
labels: Labels;
|
||||
yAxisUnit: string | undefined;
|
||||
}
|
||||
|
||||
export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'RESET' };
|
||||
@ -47,7 +66,7 @@ export interface Threshold {
|
||||
id: string;
|
||||
label: string;
|
||||
thresholdValue: number;
|
||||
recoveryThresholdValue: number;
|
||||
recoveryThresholdValue: number | null;
|
||||
unit: string;
|
||||
channels: string[];
|
||||
color: string;
|
||||
@ -114,9 +133,11 @@ export interface AdvancedOptionsState {
|
||||
sendNotificationIfDataIsMissing: {
|
||||
toleranceLimit: number;
|
||||
timeUnit: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
enforceMinimumDatapoints: {
|
||||
minimumDatapoints: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
delayEvaluation: {
|
||||
delay: number;
|
||||
@ -147,10 +168,18 @@ export type AdvancedOptionsAction =
|
||||
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: { toleranceLimit: number; timeUnit: string };
|
||||
}
|
||||
| {
|
||||
type: 'TOGGLE_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
|
||||
payload: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: { minimumDatapoints: number };
|
||||
}
|
||||
| {
|
||||
type: 'TOGGLE_ENFORCE_MINIMUM_DATAPOINTS';
|
||||
payload: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'SET_DELAY_EVALUATION';
|
||||
payload: { delay: number; timeUnit: string };
|
||||
@ -203,6 +232,7 @@ export interface NotificationSettingsState {
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
description: string;
|
||||
routingPolicies: boolean;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
@ -220,4 +250,5 @@ export type NotificationSettingsAction =
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@ -41,11 +41,6 @@ export const alertCreationReducer = (
|
||||
...state,
|
||||
name: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_DESCRIPTION':
|
||||
return {
|
||||
...state,
|
||||
description: action.payload,
|
||||
};
|
||||
case 'SET_ALERT_LABELS':
|
||||
return {
|
||||
...state,
|
||||
@ -99,6 +94,10 @@ export function getInitialAlertTypeFromURL(
|
||||
urlSearchParams: URLSearchParams,
|
||||
currentQuery: Query,
|
||||
): AlertTypes {
|
||||
const ruleType = urlSearchParams.get(QueryParams.ruleType);
|
||||
if (ruleType === 'anomaly_rule') {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
const alertTypeFromURL = urlSearchParams.get(QueryParams.alertType);
|
||||
return alertTypeFromURL
|
||||
? (alertTypeFromURL as AlertTypes)
|
||||
@ -131,9 +130,38 @@ export const advancedOptionsReducer = (
|
||||
): AdvancedOptionsState => {
|
||||
switch (action.type) {
|
||||
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':
|
||||
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':
|
||||
return { ...state, delayEvaluation: action.payload };
|
||||
case 'SET_EVALUATION_CADENCE':
|
||||
@ -190,6 +218,8 @@ export const notificationSettingsReducer = (
|
||||
return { ...state, reNotification: action.payload };
|
||||
case 'SET_DESCRIPTION':
|
||||
return { ...state, description: action.payload };
|
||||
case 'SET_ROUTING_POLICIES':
|
||||
return { ...state, routingPolicies: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
default:
|
||||
|
||||
5
frontend/src/container/CreateAlertV2/types.ts
Normal file
5
frontend/src/container/CreateAlertV2/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
export interface CreateAlertV2Props {
|
||||
alertType: AlertTypes;
|
||||
}
|
||||
@ -1,3 +1,8 @@
|
||||
import { Spin } from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useCreateAlertState } from './context';
|
||||
|
||||
// UI side feature flag
|
||||
export const showNewCreateAlertsPage = (): boolean =>
|
||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
||||
@ -7,3 +12,16 @@ export const showNewCreateAlertsPage = (): boolean =>
|
||||
// Layout 2 - Condensed layout
|
||||
export const showCondensedLayout = (): boolean =>
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.prom-ql-icon {
|
||||
height: 14px;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import './QuerySection.styles.scss';
|
||||
|
||||
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 PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
@ -71,6 +71,7 @@ function QuerySection({
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -81,6 +82,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -95,6 +97,7 @@ function QuerySection({
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -105,6 +108,7 @@ function QuerySection({
|
||||
<Tooltip title="ClickHouse">
|
||||
<Button className="nav-btns">
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
@ -117,6 +121,7 @@ function QuerySection({
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
<Typography.Text>PromQL</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
20
frontend/src/hooks/alerts/useCreateAlertRule.ts
Normal file
20
frontend/src/hooks/alerts/useCreateAlertRule.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
18
frontend/src/hooks/alerts/useTestAlertRule.ts
Normal file
18
frontend/src/hooks/alerts/useTestAlertRule.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import {
|
||||
Location,
|
||||
NavigateFunction,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom-v5-compat';
|
||||
|
||||
// state uses 'any' because react-router's NavigateOptions interface uses it
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: any;
|
||||
@ -83,15 +89,28 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
|
||||
|
||||
return newKeys.length > 0;
|
||||
};
|
||||
// Helper function to determine if an argument is an event
|
||||
const isEventObject = (arg: any): boolean =>
|
||||
arg &&
|
||||
(arg instanceof MouseEvent ||
|
||||
|
||||
// Event types that have metaKey/ctrlKey properties
|
||||
type EventWithModifiers =
|
||||
| MouseEvent
|
||||
| KeyboardEvent
|
||||
| React.MouseEvent<any, MouseEvent>
|
||||
| React.KeyboardEvent<any>;
|
||||
|
||||
// Helper function to determine if an argument is an event - Also used in utils/history.ts
|
||||
export const isEventObject = (arg: unknown): arg is EventWithModifiers => {
|
||||
if (!arg || typeof arg !== 'object') return false;
|
||||
|
||||
return (
|
||||
arg instanceof MouseEvent ||
|
||||
arg instanceof KeyboardEvent ||
|
||||
arg.nativeEvent instanceof MouseEvent ||
|
||||
arg.nativeEvent instanceof KeyboardEvent ||
|
||||
arg.metaKey !== undefined ||
|
||||
arg.ctrlKey !== undefined);
|
||||
('nativeEvent' in arg &&
|
||||
(arg.nativeEvent instanceof MouseEvent ||
|
||||
arg.nativeEvent instanceof KeyboardEvent)) ||
|
||||
'metaKey' in arg ||
|
||||
'ctrlKey' in arg
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to extract options from arguments
|
||||
const extractOptions = (
|
||||
@ -106,8 +125,7 @@ const extractOptions = (
|
||||
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
|
||||
|
||||
const shouldOpenInNewTab =
|
||||
isEvent &&
|
||||
((optionsOrEvent as any).metaKey || (optionsOrEvent as any).ctrlKey);
|
||||
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
|
||||
|
||||
return {
|
||||
...actualOptions,
|
||||
@ -118,7 +136,7 @@ const extractOptions = (
|
||||
// Helper function to create target URL
|
||||
const createTargetUrl = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: { pathname: string; search: string },
|
||||
location: Location,
|
||||
): URL => {
|
||||
if (typeof to === 'string') {
|
||||
return new URL(to, window.location.origin);
|
||||
@ -132,7 +150,7 @@ const createTargetUrl = (
|
||||
// Helper function to handle new tab navigation
|
||||
const handleNewTabNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
location: { pathname: string; search: string },
|
||||
location: Location,
|
||||
): void => {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
@ -145,8 +163,8 @@ const handleNewTabNavigation = (
|
||||
const performNavigation = (
|
||||
to: string | SafeNavigateParams,
|
||||
navigationOptions: NavigateOptions,
|
||||
navigate: any,
|
||||
location: { pathname: string; search: string },
|
||||
navigate: NavigateFunction,
|
||||
location: Location,
|
||||
): void => {
|
||||
if (typeof to === 'string') {
|
||||
navigate(to, navigationOptions);
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
LocationDescriptorObject,
|
||||
LocationState,
|
||||
} from 'history';
|
||||
import { isEventObject } from 'hooks/useSafeNavigate';
|
||||
|
||||
// Create the base history instance
|
||||
const baseHistory = createBrowserHistory();
|
||||
@ -37,14 +38,7 @@ history.push = function (
|
||||
state?: any,
|
||||
): void {
|
||||
// Check if second argument is an event object
|
||||
const isEvent =
|
||||
eventOrState &&
|
||||
(eventOrState instanceof MouseEvent ||
|
||||
eventOrState instanceof KeyboardEvent ||
|
||||
eventOrState.nativeEvent instanceof MouseEvent ||
|
||||
eventOrState.nativeEvent instanceof KeyboardEvent ||
|
||||
eventOrState.metaKey !== undefined ||
|
||||
eventOrState.ctrlKey !== undefined);
|
||||
const isEvent = isEventObject(eventOrState);
|
||||
|
||||
// If it's an event and meta/ctrl key is pressed, open in new tab
|
||||
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
|
||||
|
||||
@ -1,16 +1,6 @@
|
||||
import CreateAlertRule from 'container/CreateAlertRule';
|
||||
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const CreateAlertV2 = lazy(() => import('container/CreateAlertV2'));
|
||||
|
||||
function CreateAlertPage(): JSX.Element {
|
||||
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
|
||||
|
||||
if (showNewCreateAlertsPageFlag) {
|
||||
return <CreateAlertV2 />;
|
||||
}
|
||||
|
||||
return <CreateAlertRule />;
|
||||
}
|
||||
|
||||
|
||||
68
frontend/src/types/api/alerts/alertTypesV2.ts
Normal file
68
frontend/src/types/api/alerts/alertTypesV2.ts
Normal 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;
|
||||
}
|
||||
@ -12,8 +12,14 @@ type AuthZ interface {
|
||||
factory.Service
|
||||
|
||||
// Check returns error when the upstream authorization server is unavailable or the subject (s) doesn't have relation (r) on object (o).
|
||||
Check(context.Context, *openfgav1.CheckRequestTupleKey) error
|
||||
Check(context.Context, *openfgav1.TupleKey) error
|
||||
|
||||
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
|
||||
CheckWithTupleCreation(context.Context, authtypes.Claims, authtypes.Relation, authtypes.Typeable, authtypes.Selector, 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)
|
||||
}
|
||||
|
||||
@ -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(
|
||||
ctx,
|
||||
&openfgav1.CheckRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
TupleKey: tupleReq,
|
||||
TupleKey: &openfgav1.CheckRequestTupleKey{
|
||||
User: tupleReq.User,
|
||||
Relation: tupleReq.Relation,
|
||||
Object: tupleReq.Object,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuples, err := typeable.Tuples(subject, relation, selector, parentTypeable, parentSelectors...)
|
||||
tuples, err := typeable.Tuples(subject, relation, selectors)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
check, err := provider.sequentialCheck(ctx, tuples)
|
||||
err = provider.BatchCheck(ctx, tuples)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !check {
|
||||
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "subject %s cannot %s object %s", subject, relation.StringValue(), typeable.Type().StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) sequentialCheck(ctx context.Context, tuplesReq []*openfgav1.CheckRequestTupleKey) (bool, error) {
|
||||
for _, tupleReq := range tuplesReq {
|
||||
err := provider.Check(ctx, tupleReq)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Ast(err, errors.TypeInternal) {
|
||||
// return at the first internal error as the evaluation will be incorrect
|
||||
return false, err
|
||||
}
|
||||
func (provider *provider) Write(ctx context.Context, req *openfgav1.WriteRequest) error {
|
||||
_, err := provider.openfgaServer.Write(ctx, &openfgav1.WriteRequest{
|
||||
StoreId: provider.storeID,
|
||||
AuthorizationModelId: provider.modelID,
|
||||
Writes: req.Writes,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
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 authtypes.MustNewObjectsFromStringSlice(response.Objects), nil
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ func (middleware *AuthZ) Check(next http.HandlerFunc, _ authtypes.Relation, tran
|
||||
return
|
||||
}
|
||||
|
||||
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, translation, authtypes.TypeableOrganization, authtypes.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 {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/role"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
@ -26,6 +27,7 @@ type Module interface {
|
||||
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
|
||||
|
||||
statsreporter.StatsCollector
|
||||
role.RegisterTypeable
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@ -222,3 +223,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
|
||||
return dashboardtypes.NewStatsFromStorableDashboards(dashboards), nil
|
||||
}
|
||||
|
||||
func (module *module) MustGetTypeables() []authtypes.Typeable {
|
||||
return []authtypes.Typeable{dashboardtypes.ResourceDashboard, dashboardtypes.ResourcesDashboards}
|
||||
}
|
||||
|
||||
291
pkg/modules/role/implrole/handler.go
Normal file
291
pkg/modules/role/implrole/handler.go
Normal 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)
|
||||
}
|
||||
172
pkg/modules/role/implrole/module.go
Normal file
172
pkg/modules/role/implrole/module.go
Normal 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)
|
||||
}
|
||||
103
pkg/modules/role/implrole/store.go
Normal file
103
pkg/modules/role/implrole/store.go
Normal 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
66
pkg/modules/role/role.go
Normal 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)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@ -14,14 +15,39 @@ type Name struct {
|
||||
val string
|
||||
}
|
||||
|
||||
func MustNewName(name string) Name {
|
||||
func NewName(name string) (Name, error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,6 +1,13 @@
|
||||
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 (
|
||||
RelationCreate = Relation{valuer.NewString("create")}
|
||||
@ -12,12 +19,33 @@ var (
|
||||
RelationAssignee = Relation{valuer.NewString("assignee")}
|
||||
)
|
||||
|
||||
var (
|
||||
TypeUserSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete}
|
||||
TypeRoleSupportedRelations = []Relation{RelationAssignee, RelationRead, RelationUpdate, RelationDelete}
|
||||
TypeOrganizationSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList}
|
||||
TypeResourceSupportedRelations = []Relation{RelationRead, RelationUpdate, RelationDelete, RelationBlock}
|
||||
TypeResourcesSupportedRelations = []Relation{RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList}
|
||||
)
|
||||
var TypeableRelations = map[Type][]Relation{
|
||||
TypeUser: {RelationRead, RelationUpdate, RelationDelete},
|
||||
TypeRole: {RelationAssignee, RelationRead, RelationUpdate, RelationDelete},
|
||||
TypeOrganization: {RelationCreate, RelationRead, RelationUpdate, RelationDelete, RelationList},
|
||||
TypeResource: {RelationRead, RelationUpdate, RelationDelete, RelationBlock},
|
||||
TypeResources: {RelationCreate, RelationList},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,53 +1,67 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeAuthZInvalidSelectorRegex = errors.MustNewCode("authz_invalid_selector_regex")
|
||||
)
|
||||
|
||||
var (
|
||||
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}$`)
|
||||
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}$`)
|
||||
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 {
|
||||
val string
|
||||
}
|
||||
|
||||
func NewSelector(typed Type, selector string) (Selector, error) {
|
||||
switch typed {
|
||||
case TypeUser:
|
||||
if !typeUserSelectorRegex.MatchString(selector) {
|
||||
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())
|
||||
}
|
||||
err := IsValidSelector(typed, Selector{val: selector})
|
||||
if err != nil {
|
||||
return Selector{}, err
|
||||
}
|
||||
|
||||
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 {
|
||||
selector, err := NewSelector(typed, input)
|
||||
if err != nil {
|
||||
@ -60,3 +74,16 @@ func MustNewSelector(typed Type, input string) Selector {
|
||||
func (selector Selector) String() string {
|
||||
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
|
||||
}
|
||||
|
||||
108
pkg/types/authtypes/transaction.go
Normal file
108
pkg/types/authtypes/transaction.go
Normal 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
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
@ -9,9 +11,6 @@ import (
|
||||
var (
|
||||
ErrCodeAuthZUnavailable = errors.MustNewCode("authz_unavailable")
|
||||
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 (
|
||||
@ -23,14 +22,92 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
TypeableUser = &user{}
|
||||
TypeableRole = &role{}
|
||||
TypeableOrganization = &organization{}
|
||||
TypeableUser = &typeableUser{}
|
||||
TypeableRole = &typeableRole{}
|
||||
TypeableOrganization = &typeableOrganization{}
|
||||
)
|
||||
|
||||
type Typeable interface {
|
||||
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 }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
33
pkg/types/authtypes/typeable_organization.go
Normal file
33
pkg/types/authtypes/typeable_organization.go
Normal 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()
|
||||
}
|
||||
47
pkg/types/authtypes/typeable_resource.go
Normal file
47
pkg/types/authtypes/typeable_resource.go
Normal 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()}, ":")
|
||||
}
|
||||
47
pkg/types/authtypes/typeable_resources.go
Normal file
47
pkg/types/authtypes/typeable_resources.go
Normal 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()}, ":")
|
||||
}
|
||||
33
pkg/types/authtypes/typeable_role.go
Normal file
33
pkg/types/authtypes/typeable_role.go
Normal 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()
|
||||
}
|
||||
33
pkg/types/authtypes/typeable_user.go
Normal file
33
pkg/types/authtypes/typeable_user.go
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -7,10 +7,16 @@ import (
|
||||
|
||||
"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"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ResourceDashboard = authtypes.MustNewTypeableResource(authtypes.MustNewName("dashboard"))
|
||||
ResourcesDashboards = authtypes.MustNewTypeableResources(authtypes.MustNewName("dashboards"))
|
||||
)
|
||||
|
||||
type StorableDashboard struct {
|
||||
bun.BaseModel `bun:"table:dashboard"`
|
||||
|
||||
|
||||
224
pkg/types/roletypes/role.go
Normal file
224
pkg/types/roletypes/role.go
Normal 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
|
||||
}
|
||||
16
pkg/types/roletypes/store.go
Normal file
16
pkg/types/roletypes/store.go
Normal 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
|
||||
}
|
||||
@ -110,6 +110,10 @@ func detectPlatform() string {
|
||||
return "railway"
|
||||
case os.Getenv("ECS_CONTAINER_METADATA_URI_V4") != "":
|
||||
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
|
||||
@ -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
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user